mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 08:05:22 +08:00
feat(ql3): paginate native task trigger and run ledgers
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
# QingLong 3.0 Architecture RFC
|
||||
|
||||
- D-431/ADR-0533(原生 Console 源码候选):Task、Trigger、Run 列表接入既有 keyset API,开放下一页、回到首页和当前页刷新,每次最多 64 条并替换 DOM,不缓存历史页、不轮询或扩大后端查询。验证规范 cursor、页内/跨页顺序与继续边界;请求/选择代次防止旧列表、错误、详情和 Secret catalog 覆盖当前页面。客户端与生产 HTTP parser 集成 15/15、完整 Local API 114/114,静态资产共 111,857 bytes;默认 headless/Cluster 不变。远端 CI、浏览器证据和 exact 阶段实物分别核验,不借用 D-430 archive。
|
||||
|
||||
- D-430/ADR-0532(原生 Console 源码候选):在原生运行详情中开放日志下一片段、回到开头与当前片段刷新,固定 32 KiB 替换窗口,不累计全文或后台轮询。严格验证响应身份、字节范围与前进 cursor,按不含凭据的选择代次丢弃过期响应,防止切换或断开后覆盖新详情。复用既有 API/Policy/Audit,不新增包、依赖、迁移或后端 authority;默认 headless 与 Cluster 不变。客户端行为测试和真实 Chromium 预览覆盖分页、重复点击、过期响应与字节边界;远端 CI 和 exact 双架构 Console 实物尚待验证。
|
||||
|
||||
- D-429/ADR-0531(exact 双架构 Console 阶段实物已交付):现有面板的 Cron “日志”入口不恢复 2.x `/api/crons/:id/log` 或文件系统日志权威,而是复用规范 v3 Run/Attempt/Artifact 链。`BoundedRunListItem` 仅增加经 128 字节无控制字符验证的可选 `triggerId`;页面按 `triggerId + taskId` 在 Edge 64/Standalone 256 条 Run 总预算中定位最新 Run,再读取 detail 的 latest Attempt 与 16/32 KiB 首个日志片段,明确呈现 pending/retired/truncated。所有请求继续走 Bearer、Project Policy、durable audit、credential reconfirm 与 Artifact retention;不暴露路径、Artifact id、Secret 或其他 Run 私有字段。QL3 Action 列只开放日志,列表关闭旧 10 秒轮询,日志关闭旧 2 秒轮询,仅由初次进入、点击与手动刷新驱动;2.x 行为保持不变。没有新增 package、dependency、migration、schema、listener、连接或后台进程,headless 与 Cluster authority 不变。本地 runtime-core 594/594、Local API 93/93、全包/后端/架构审计、Node 20 production build、240 files / 11,965,017 bytes 闭包审计和真实 Chromium journey 均通过;浏览器稳定 15 秒只有一次 Cron list,日志稳定 5 秒只有 Run list/detail/log 各一次。运行时代码提交 `09ef1745226c05521a9a44accb9d9ef95dd46c85` 的主 CI run `33787481413` 为 42 success / 3 expected skip / 0 fail,Console artifact run `33789576578` 为 43 success / 2 expected skip / 0 fail;amd64/arm64/milestone artifact `9907367021`/`9907250404`/`9907412968` 保留至 2026-10-03,下载后的 `SHA256SUMS` 与 v7 离线 auditor 均返回 `compatible=true`。
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# ADR-0533:原生 Console 的任务、定时与运行列表分页
|
||||
|
||||
- 状态:Accepted(D-431 源码候选;远端 CI 与阶段实物待验证)
|
||||
- 日期:2026-09-04
|
||||
- 关联:QL-RFC-0001 D-431、ADR-0532
|
||||
|
||||
## 问题
|
||||
|
||||
原生 Console 已能创建任务、配置定时、运行和读取日志,但 Task、Trigger、Run 列表在第 64 条后要求操作者自行调用 API。超过一屏的数据无法从页面到达,不能作为日常管理体验。后端已有三类规范 keyset API,不需要新增分页服务或恢复旧 Cron 权威。
|
||||
|
||||
## 决策
|
||||
|
||||
- 三类列表共用当前静态客户端中的小型分页逻辑,不新增 workspace package、依赖或后端路由。页脚提供“下一页”“回到首页”“刷新当前页”;顶部刷新、切换栏目和管理写入后的列表刷新从首页开始。
|
||||
- 每个显式动作最多一个列表 GET、64 条记录,替换当前 DOM;只保留当前请求边界,不缓存历史页、累积记录、自动翻页、轮询或增加总数扫描。Run 详情和日志仍按既有显式选择流程读取。
|
||||
- Task/Trigger 分别按 `taskId`/`triggerId` 升序;Run 按 `createdAtMs`、同时间戳下 `runId` 降序。客户端核对页内及跨页顺序、重复 ID、64 条上限、继续标志、完整 cursor 字段与最后一行一致性。异常页不显示继续操作。
|
||||
- 查询使用正式 HTTP parser 接受的 canonical ASCII ID;这些 ID 不含查询分隔符,不能把合法冒号编码成 parser 不接受的别名。新增测试将真实客户端生成的 URL 送入生产 HTTP surface,验证三类 continuation 被解析成正确的 typed cursor。
|
||||
- 每轮刷新绑定 Project、栏目与不含 credential 的请求代次;过期成功、失败和 finally 不覆盖新的列表、连接状态或 busy 状态。刷新清空旧详情选择;Task/Trigger 详情响应也绑定选择代次和列表代次。Secret 仍保持原有有界目录,但列表响应在提交 catalog/DOM 前同样检查代次。
|
||||
- 页脚在旧 DOM 被替换后不能再发起请求;重复点击同一个页脚只提交一次。失败的后续页保留当前边界重试与回首页入口,空的后续页不会被误报为整个 Project 没有数据。
|
||||
|
||||
## 明确边界
|
||||
|
||||
这不是数据库快照:浏览期间新建或更新的数据可能需要回首页重读。没有虚构总页数、随机跳页或无界上一页历史。它不包含 Secret 全目录分页、Run Event/Step 分页、筛选搜索、Cluster UI 或旧面板写操作。默认 headless 的资源和安装闭包不变。
|
||||
|
||||
## 验证
|
||||
|
||||
客户端测试直接执行发布的 `console.js`,覆盖三类第 65 条到达、刷新/回首页、同时间戳 Run、空页、503 重试、错误 cursor/顺序、重复点击、切换栏目、断开、同栏目重读、旧详情和旧 Secret catalog 丢弃;含真实 HTTP parser 集成合计 15/15。完整 Local API 为 114/114,无失败或跳过;完整后端 1685 项(1683 pass / 2 环境条件 skip / 0 fail)。Local image 与 18-package boundary audit 均 compatible。
|
||||
|
||||
原生 JS 为 80,336 bytes,三项静态资产共 111,857 bytes,仍小于单文件 96 KiB / 合计 192 KiB。D-430 的 `12ed38b7` 构建不含本切片;本地测试不等于 exact 双架构产物交付。
|
||||
|
||||
真实 Chromium 使用当前原生静态资产与合成 HTTP 数据验证了 Task、Trigger、Run 的第 65 条可达,任务详情可从第二页打开,DOM 每次只保留当前页。请求记录包含三个规范 continuation URL,浏览期间没有自动列表轮询。该浏览器证据验证交互,不冒充真实数据库、Owner 写入或双架构镜像验收。
|
||||
@@ -536,6 +536,7 @@
|
||||
| [ADR-0530](./ADR-0530-bounded-local-panel-capability-bootstrap.md) | 有界 Local 面板能力发现与启动适配 | Accepted(D-428 exact Console 双架构阶段实物已交付) |
|
||||
| [ADR-0531](./ADR-0531-canonical-run-log-bridge-for-legacy-panel.md) | 现有面板到规范 Run 日志的有界桥接 | Accepted(D-429 exact Console 双架构阶段实物已交付) |
|
||||
| [ADR-0532](./ADR-0532-native-console-log-window-navigation.md) | 原生 Console 日志窗口导航 | Accepted(D-430 源码候选;实物待验证) |
|
||||
| [ADR-0533](./ADR-0533-native-console-ledger-pagination.md) | 原生 Console 任务、定时与运行列表分页 | Accepted(D-431 源码候选;实物待验证) |
|
||||
|
||||
## 规则
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ D-418 已闭合独立 Console image/Trial Kit;D-419 的 v5 quickstart 进一
|
||||
|
||||
上表的旧面板是 bundle 自带的适配版本,不是未修改的 2.x 静态页面;旧账号密码/JWT、现有 2.x 数据和 Cluster 管理能力不会因页面可打开而自动接通。
|
||||
|
||||
D-431 源码候选进一步给原生 Task、Trigger、Run 列表增加页脚“下一页/回到首页/刷新当前页”。每次只显示最多 64 条,翻页替换旧记录和详情,不累积全文;顶部刷新与切换栏目回首页,页脚刷新保留当前边界。浏览期间数据可能变化,因此不提供固定总页数或快照承诺。Secret 目录和 Run Event/Step 仍维持现有窗口限制。此功能尚未交付到上表的 D-429 或 D-430 archive,见 [ADR-0533](../adr/ADR-0533-native-console-ledger-pagination.md)。
|
||||
|
||||
D-426b2c 又补齐了 Console 镜像的 adopted-target 入口证据:切换演练使用 `ql3-local-api --cutover-probe --config <local-api.json>`,同时绑定外层 API 配置、内层 Application 配置与 exact mounts,但该模式不会启动本页使用的 listener、credential、scheduler 或 mutation surface。正常启动仍使用下文不带 `--cutover-probe` 的命令;提交 `229c3cb4e826866a0c7c4d81cb5e52cdc3975eec` 的 [artifact run 33463415938](https://github.com/whyour/qinglong/actions/runs/33463415938) 已交付 exact amd64/arm64 Console Trial Kit 与 milestone,三份下载产物的离线 auditor 均为 `compatible=true`。
|
||||
|
||||
## 前置条件
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user