@yss123/cage-admin 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -3
- package/dist/bootstrap.d.ts +27 -1
- package/dist/bootstrap.js +52 -16
- package/dist/bootstrap.js.map +1 -1
- package/dist/catalog.d.ts +48 -0
- package/dist/catalog.js +96 -0
- package/dist/catalog.js.map +1 -0
- package/dist/diagnostics.d.ts +14 -0
- package/dist/diagnostics.js +163 -0
- package/dist/diagnostics.js.map +1 -0
- package/dist/domain/constraints.d.ts +23 -0
- package/dist/domain/constraints.js +27 -0
- package/dist/domain/constraints.js.map +1 -1
- package/dist/domain/index.d.ts +5 -0
- package/dist/domain/index.js +5 -0
- package/dist/domain/index.js.map +1 -1
- package/dist/domain/org.d.ts +230 -149
- package/dist/domain/org.js +98 -4
- package/dist/domain/org.js.map +1 -1
- package/dist/http.d.ts +13 -1
- package/dist/http.js +28 -13
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/package.json +9 -5
- package/ui/dist/app/shell.js +135 -0
- package/ui/dist/app.js +34 -0
- package/ui/dist/modules/audit/audit-page.js +107 -0
- package/ui/dist/modules/audit/index.js +5 -0
- package/ui/dist/modules/auth/index.js +5 -0
- package/ui/dist/modules/auth/login-page.js +38 -0
- package/ui/dist/modules/models/index.js +5 -0
- package/ui/dist/modules/models/models-page.js +60 -0
- package/ui/dist/modules/operations/index.js +6 -0
- package/ui/dist/modules/operations/operations-page.js +110 -0
- package/ui/dist/modules/operations/sync-page.js +60 -0
- package/ui/dist/modules/org/index.js +5 -0
- package/ui/dist/modules/org/org-page.js +243 -0
- package/ui/dist/modules/personalities/index.js +5 -0
- package/ui/dist/modules/personalities/personalities-page.js +161 -0
- package/ui/dist/modules/roles/index.js +5 -0
- package/ui/dist/modules/roles/roles-page.js +348 -0
- package/ui/dist/modules/tasks/index.js +5 -0
- package/ui/dist/modules/tasks/tasks-page.js +68 -0
- package/ui/dist/shared/api.js +166 -0
- package/ui/dist/shared/dom.js +139 -0
- package/ui/dist/shared/labels.js +84 -0
- package/ui/dist/shared/navigation.js +14 -0
- package/ui/dist/shared/types.js +1 -0
- package/ui/index.html +12 -10
- package/ui/style.css +1657 -41
- package/ui/app.js +0 -339
package/ui/dist/app.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 系统管理浏览器入口:恢复登录身份、加载展示目录,再交给 shell 组合页面。
|
|
3
|
+
* 监听 hash 导航、刷新及会话过期事件;未登录进入登录页,网络或服务异常显示可重试连接状态。
|
|
4
|
+
* 本入口不保存授权或执行领域逻辑,真实身份与目录通过同源 HTTP 请求获取。
|
|
5
|
+
*/
|
|
6
|
+
import { api, ApiError, state } from './shared/api.js';
|
|
7
|
+
import { h, icon } from './shared/dom.js';
|
|
8
|
+
import { render } from './app/shell.js';
|
|
9
|
+
/** 浏览器入口恢复真实会话与展示目录,不包含具体页面、表单或授权逻辑。 */
|
|
10
|
+
/**
|
|
11
|
+
* 按顺序读取 /api/me 与管理目录,成功后渲染当前 hash 页面。
|
|
12
|
+
* 仅 UNAUTHENTICATED 走未登录界面;其他故障保留明确重连入口,避免把网络异常伪装为退出。
|
|
13
|
+
* 此函数也作为登录成功回调和重新连接动作,恢复失败通过页面反馈。
|
|
14
|
+
*/
|
|
15
|
+
async function boot() {
|
|
16
|
+
try {
|
|
17
|
+
state.me = await api('GET', '/api/me');
|
|
18
|
+
state.catalog = await api('GET', '/api/sys/catalog');
|
|
19
|
+
await render(boot);
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
if (error instanceof ApiError && error.code === 'UNAUTHENTICATED') {
|
|
23
|
+
await render(boot);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
document
|
|
27
|
+
.getElementById('app')
|
|
28
|
+
.replaceChildren(h('main', { class: 'connection-state' }, h('span', { class: 'brand-mark' }, icon('grid')), h('h1', {}, '暂时无法连接工作空间'), h('p', {}, '请检查网络与 API 服务,然后重新尝试。'), h('button', { class: 'primary', onclick: boot }, '重新连接')));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
window.addEventListener('hashchange', () => void render(boot));
|
|
32
|
+
window.addEventListener('cage:refresh', () => void render(boot));
|
|
33
|
+
window.addEventListener('cage:session-expired', () => void render(boot));
|
|
34
|
+
void boot();
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 操作审计查询页面,通过安全审计接口读取记录并展示操作人、时间和变化详情。
|
|
3
|
+
* 筛选、分页与详情均属于读操作;界面不补写审计,也不将查到的部分记录当作完整请求轨迹。
|
|
4
|
+
*/
|
|
5
|
+
import { api, run, state } from '../../shared/api.js';
|
|
6
|
+
import { badge, field, formatTime, h, panel, table } from '../../shared/dom.js';
|
|
7
|
+
import { capabilityName, objectName } from '../../shared/labels.js';
|
|
8
|
+
const kinds = {
|
|
9
|
+
write: '业务写入',
|
|
10
|
+
read: '数据读取',
|
|
11
|
+
denied: '拒绝访问',
|
|
12
|
+
ops: '运维操作',
|
|
13
|
+
};
|
|
14
|
+
const actors = {
|
|
15
|
+
user: '用户',
|
|
16
|
+
process: '审批流程',
|
|
17
|
+
external: '外部系统',
|
|
18
|
+
job: '定时任务',
|
|
19
|
+
system: '系统',
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* 构建审计筛选器与列表,等待初次 load 完成;服务端仍校验 Sys.audit.read 与安全视图。
|
|
23
|
+
* 详情通过 DOM 文本展示,包含结构化差异时也不解释为 HTML。
|
|
24
|
+
*/
|
|
25
|
+
export async function renderAudit(main) {
|
|
26
|
+
const actor = h('input', { placeholder: '操作人的用户 ID' });
|
|
27
|
+
const aggregate = h('select', {}, h('option', { value: '' }, '全部业务对象'), state.catalog.aggregates.map((item) => h('option', { value: item.name }, item.label)));
|
|
28
|
+
const aggregateId = h('input', { placeholder: '完整单据 ID' });
|
|
29
|
+
const command = h('input', { placeholder: '例如 PurchaseOrder.approve' });
|
|
30
|
+
const kind = h('select', {}, h('option', { value: '' }, '全部操作类型'), Object.entries(kinds).map(([value, label]) => h('option', { value }, label)));
|
|
31
|
+
const fields = { actor, aggregate, aggregateId, command, kind };
|
|
32
|
+
const out = h('div');
|
|
33
|
+
const search = h('button', { type: 'submit', class: 'primary' }, '查询记录');
|
|
34
|
+
const previous = h('button', {
|
|
35
|
+
class: 'small',
|
|
36
|
+
onclick: () => {
|
|
37
|
+
page--;
|
|
38
|
+
void load();
|
|
39
|
+
},
|
|
40
|
+
}, '上一页');
|
|
41
|
+
const next = h('button', {
|
|
42
|
+
class: 'small',
|
|
43
|
+
onclick: () => {
|
|
44
|
+
page++;
|
|
45
|
+
void load();
|
|
46
|
+
},
|
|
47
|
+
}, '下一页');
|
|
48
|
+
const pageText = h('span', { class: 'muted' });
|
|
49
|
+
let page = 1;
|
|
50
|
+
main.append(panel('审计记录', '查询谁在什么时间操作了哪些数据,以及操作是否成功。', h('form', {
|
|
51
|
+
class: 'audit-filters',
|
|
52
|
+
onsubmit: (event) => {
|
|
53
|
+
event.preventDefault();
|
|
54
|
+
page = 1;
|
|
55
|
+
void load();
|
|
56
|
+
},
|
|
57
|
+
}, field('业务对象', aggregate), field('操作类型', kind), field('操作人', actor), field('单据编号', aggregateId), field('操作标识', command), search), out, h('div', { class: 'pagination' }, pageText, h('div', { class: 'row-actions' }, previous, next))));
|
|
58
|
+
/**
|
|
59
|
+
* 按当前筛选和页码请求审计记录,更新表格及分页控制。
|
|
60
|
+
* 查询失败保留明确错误反馈,不生成占位成功记录;请求只读,不重放任何业务命令。
|
|
61
|
+
*/
|
|
62
|
+
async function load() {
|
|
63
|
+
const params = new URLSearchParams(Object.entries(fields)
|
|
64
|
+
.filter(([, input]) => input.value)
|
|
65
|
+
.map(([key, input]) => [key, input.value]));
|
|
66
|
+
params.set('page', String(page));
|
|
67
|
+
params.set('pageSize', '50');
|
|
68
|
+
const result = await run(() => api('GET', `/api/sys/audit?${params}`), undefined, search);
|
|
69
|
+
if (!result)
|
|
70
|
+
return;
|
|
71
|
+
out.replaceChildren(table([
|
|
72
|
+
['发生时间', (item) => formatTime(item.at)],
|
|
73
|
+
[
|
|
74
|
+
'操作类型',
|
|
75
|
+
(item) => badge(kinds[item.kind] || '系统记录', item.kind === 'denied' ? 'red' : 'neutral'),
|
|
76
|
+
],
|
|
77
|
+
[
|
|
78
|
+
'操作主体',
|
|
79
|
+
(item) => h('div', { class: 'stack-cell' }, h('strong', {}, actors[item.actor_kind] || '系统主体'), h('code', { title: item.actor }, item.actor.slice(0, 12)), item.on_behalf_of && h('small', {}, `代表用户 ${item.on_behalf_of.slice(0, 8)}`)),
|
|
80
|
+
],
|
|
81
|
+
[
|
|
82
|
+
'执行操作',
|
|
83
|
+
(item) => h('div', { class: 'stack-cell' }, h('strong', {}, capabilityName(item.command || item.capability || '')), h('code', {}, item.command || item.capability || item.source)),
|
|
84
|
+
],
|
|
85
|
+
[
|
|
86
|
+
'关联对象',
|
|
87
|
+
(item) => item.aggregate
|
|
88
|
+
? h('div', { class: 'stack-cell' }, h('span', {}, objectName(item.aggregate)), h('code', { title: item.aggregate_id }, item.aggregate_id?.slice(0, 12) || '—'))
|
|
89
|
+
: '—',
|
|
90
|
+
],
|
|
91
|
+
[
|
|
92
|
+
'结果',
|
|
93
|
+
(item) => badge(item.result === 'ok' ? '成功' : '未成功', item.result === 'ok' ? 'green' : 'red'),
|
|
94
|
+
],
|
|
95
|
+
[
|
|
96
|
+
'变更详情',
|
|
97
|
+
(item) => item.diff
|
|
98
|
+
? h('details', {}, h('summary', {}, '查看变更'), h('pre', {}, JSON.stringify(item.diff, null, 2)))
|
|
99
|
+
: h('span', { class: 'muted' }, '无字段变更'),
|
|
100
|
+
],
|
|
101
|
+
], result.items));
|
|
102
|
+
pageText.textContent = `第 ${page} 页 · 本页 ${result.items.length} 条,每页最多 50 条`;
|
|
103
|
+
previous.disabled = page === 1;
|
|
104
|
+
next.disabled = result.items.length < 50;
|
|
105
|
+
}
|
|
106
|
+
await load();
|
|
107
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 构造系统管理登录页面,复用统一 JSON/CSRF 请求和操作反馈。
|
|
3
|
+
* 账号密码只用于本次登录表单;成功清掉旧 CSRF 后由上层重新加载真实身份和目录,SSO 使用独立服务端入口。
|
|
4
|
+
*/
|
|
5
|
+
import { api, run, state } from '../../shared/api.js';
|
|
6
|
+
import { field, h, icon } from '../../shared/dom.js';
|
|
7
|
+
/**
|
|
8
|
+
* 返回未挂载的登录页面 DOM。提交期间禁用按钮,失败由 run 显示原因并保留可重试表单。
|
|
9
|
+
* onLogin 仅在登录请求成功后等待执行,负责恢复会话并切换页面;本组件不自行伪造身份或能力列表。
|
|
10
|
+
*/
|
|
11
|
+
export function loginPage(onLogin) {
|
|
12
|
+
const username = h('input', {
|
|
13
|
+
name: 'username',
|
|
14
|
+
placeholder: '请输入登录账号',
|
|
15
|
+
autocomplete: 'username',
|
|
16
|
+
required: true,
|
|
17
|
+
});
|
|
18
|
+
const password = h('input', {
|
|
19
|
+
name: 'password',
|
|
20
|
+
type: 'password',
|
|
21
|
+
placeholder: '请输入密码',
|
|
22
|
+
autocomplete: 'current-password',
|
|
23
|
+
required: true,
|
|
24
|
+
});
|
|
25
|
+
const submit = h('button', { class: 'primary', type: 'submit' }, '登录系统管理', icon('arrow'));
|
|
26
|
+
const form = h('form', {
|
|
27
|
+
class: 'stack-form',
|
|
28
|
+
onsubmit: async (event) => {
|
|
29
|
+
event.preventDefault();
|
|
30
|
+
const result = await run(() => api('POST', '/auth/login', { username: username.value, password: password.value }), undefined, submit);
|
|
31
|
+
if (result) {
|
|
32
|
+
state.csrf = null;
|
|
33
|
+
await onLogin();
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
}, field('登录账号', username), field('密码', password), submit);
|
|
37
|
+
return h('main', { class: 'login-layout' }, h('section', { class: 'login-story' }, h('div', { class: 'login-brand' }, h('span', { class: 'brand-mark' }, icon('grid')), 'Cage'), h('div', {}, h('span', { class: 'eyebrow' }, '系统管理工作台'), h('h1', {}, '让协作有序,', h('br'), '让权限清晰。'), h('p', {}, '从团队与角色,到流程与运行记录。', h('br'), '在一个工作空间里,管理业务的基础。')), h('small', {}, '团队协作 · 权限管理 · 运行追踪')), h('section', { class: 'login-panel' }, h('span', { class: 'eyebrow' }, '欢迎回来'), h('h2', {}, '登录你的工作空间'), h('p', { class: 'muted' }, '使用管理员为你配置的账号继续。'), form, h('a', { class: 'sso-link', href: '/auth/sso' }, '使用企业单点登录', icon('arrow')), h('p', { class: 'login-footnote' }, icon('lock'), '使用安全会话连接,操作受权限与数据范围约束。')));
|
|
38
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { api, run } from '../../shared/api.js';
|
|
2
|
+
import { badge, emptyState, field, formatTime, h, panel } from '../../shared/dom.js';
|
|
3
|
+
/** Admin 只管理框架模型配置;审批规则、业务工具与聊天界面仍由各自声明和应用负责。 */
|
|
4
|
+
/**
|
|
5
|
+
* 读取托管模型列表并在 main 中构建配置表单,无模型时显示启用提示。
|
|
6
|
+
* 表单以秒显示超时,提交转换为毫秒;保存携带已读取的 version 防止覆盖其他编辑。
|
|
7
|
+
* 测试仅使用已保存配置,连接成功不证明全部协议特性或业务工具验收通过;错误由共享操作层反馈。
|
|
8
|
+
*/
|
|
9
|
+
export async function renderModels(main) {
|
|
10
|
+
const models = await api('GET', '/api/ai/admin/models');
|
|
11
|
+
if (!models.length) {
|
|
12
|
+
main.append(panel('尚无可管理的模型', undefined, emptyState('应用尚未启用托管模型', '应用声明模型后,可在这里配置服务地址与密钥。', 'grid')));
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
main.append(h('div', { class: 'model-overview' }, h('div', {}, h('span', { class: 'eyebrow' }, 'MODEL CONNECTIONS'), h('h2', {}, '一个连接,支撑每次智能协作'), h('p', {}, '模型按当前租户配置。API Key 加密保存,不会回显到页面。')), badge(`${models.length} 个模型入口`, 'green')));
|
|
16
|
+
for (let saved of models) {
|
|
17
|
+
const label = h('input', { value: saved.label, required: true, maxlength: 100 });
|
|
18
|
+
const baseUrl = h('input', { type: 'url', value: saved.baseUrl, placeholder: 'https://api.example.com/v1', required: true, maxlength: 1000 });
|
|
19
|
+
const model = h('input', { value: saved.model, placeholder: '填写服务商提供的模型名称', required: true, maxlength: 200 });
|
|
20
|
+
const apiKey = h('input', { type: 'password', autocomplete: 'new-password', placeholder: saved.hasApiKey ? '已保存;留空保留原密钥' : '粘贴 API Key', maxlength: 4096 });
|
|
21
|
+
const enabled = h('input', { type: 'checkbox', checked: saved.enabled });
|
|
22
|
+
const timeout = h('input', { type: 'number', min: 1, max: 180, value: saved.timeoutMs / 1000, required: true });
|
|
23
|
+
const compaction = h('input', { type: 'checkbox', checked: saved.responsesCompaction, disabled: saved.apiFormat !== 'responses' });
|
|
24
|
+
const apiFormat = h('select', { value: saved.apiFormat, onchange: () => {
|
|
25
|
+
compaction.disabled = apiFormat.value !== 'responses';
|
|
26
|
+
if (compaction.disabled)
|
|
27
|
+
compaction.checked = false;
|
|
28
|
+
} }, h('option', { value: 'chat-completions' }, 'Chat Completions'), h('option', { value: 'responses' }, 'Responses'));
|
|
29
|
+
const contextWindow = h('input', { type: 'number', min: 8192, max: 2000000, step: 1, value: saved.contextWindowTokens, required: true });
|
|
30
|
+
const status = h('div', { class: 'model-save-status', role: 'status' });
|
|
31
|
+
const summary = h('div', { class: 'model-meta' });
|
|
32
|
+
const refresh = () => summary.replaceChildren(badge(saved.enabled ? '已启用' : '未启用', saved.enabled ? 'green' : 'neutral'), h('span', {}, saved.hasApiKey ? '密钥已保存' : '等待配置'), h('span', {}, saved.apiFormat === 'responses' ? 'Responses · 服务端接续' : 'Chat Completions'), h('span', {}, `更新于 ${formatTime(saved.updatedAt)}`));
|
|
33
|
+
refresh();
|
|
34
|
+
const save = h('button', { class: 'primary', type: 'submit' }, '保存配置');
|
|
35
|
+
const test = h('button', { type: 'button', disabled: !saved.enabled, onclick: async () => {
|
|
36
|
+
status.textContent = '正在测试已保存的配置…';
|
|
37
|
+
const result = await run(() => api('POST', `/api/ai/admin/models/${encodeURIComponent(saved.key)}/test`), undefined, test);
|
|
38
|
+
status.textContent = result ? result.ok ? `连接成功 · ${result.latencyMs} ms · 配置版本 ${result.version}`
|
|
39
|
+
: `连接未成功(${result.code}),请核对地址、模型名称与密钥。` : '测试未完成,请根据错误提示检查配置。';
|
|
40
|
+
} }, '测试连接');
|
|
41
|
+
const form = h('form', { class: 'model-form', onsubmit: async (event) => {
|
|
42
|
+
event.preventDefault();
|
|
43
|
+
test.disabled = true;
|
|
44
|
+
const result = await run(() => api('POST', `/api/ai/admin/models/${encodeURIComponent(saved.key)}`, {
|
|
45
|
+
version: saved.version, label: label.value, baseUrl: baseUrl.value, model: model.value, apiKey: apiKey.value || undefined,
|
|
46
|
+
enabled: enabled.checked, timeoutMs: Number(timeout.value) * 1000,
|
|
47
|
+
apiFormat: apiFormat.value, contextWindowTokens: Number(contextWindow.value), responsesCompaction: compaction.checked,
|
|
48
|
+
}).catch(error => { status.textContent = error.message; throw error; }), '模型配置已保存', save);
|
|
49
|
+
if (result) {
|
|
50
|
+
saved = result;
|
|
51
|
+
apiKey.value = '';
|
|
52
|
+
apiKey.placeholder = '已保存;留空保留原密钥';
|
|
53
|
+
refresh();
|
|
54
|
+
status.textContent = '已保存,后续模型调用将使用这份配置。';
|
|
55
|
+
}
|
|
56
|
+
test.disabled = !saved.enabled;
|
|
57
|
+
} }, h('div', { class: 'model-fields' }, field('显示名称', label), field('模型名称', model), field('接口协议', apiFormat, 'Responses 可由服务端接续对话,兼容服务须支持对应协议。'), field('模型上下文窗口(Token)', contextWindow, '按服务商公布的容量填写,用于预留回答空间和限制每次输入。'), field('API 基地址', baseUrl, '支持 HTTP / HTTPS。可填写基础地址或对应接口完整路径;更换地址时需重新填写密钥。'), field('API Key', apiKey, '仅在首次配置或更换密钥时填写。'), field('调用超时(秒)', timeout), field('服务端自动压缩', compaction, '仅对支持 context_management 的 Responses 服务启用;历史仍计入上下文与输入费用。'), h('label', { class: 'model-enabled' }, enabled, '启用此模型')), h('div', { class: 'model-actions' }, status, test, save));
|
|
58
|
+
main.append(panel(saved.label, `模型入口:${saved.key}`, summary, form));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 异步运行运维页面,读取 outbox 汇总、未处理死信和注册任务状态。
|
|
3
|
+
* 运维读取与管理能力分开,重放/丢弃与启停任务通过系统接口执行;界面不直接操作数据库。
|
|
4
|
+
*/
|
|
5
|
+
import { api, can, run } from '../../shared/api.js';
|
|
6
|
+
import { badge, field, formatTime, h, icon, modal, panel, table } from '../../shared/dom.js';
|
|
7
|
+
import { capabilityName, eventName, jobName, statusName } from '../../shared/labels.js';
|
|
8
|
+
import { navigate } from '../../shared/navigation.js';
|
|
9
|
+
/**
|
|
10
|
+
* 并行取得运行快照并渲染统计、队列、死信和任务列表。
|
|
11
|
+
* Sys.ops.manage 控制动作按钮,成功请求后重新加载数据;重放仅表示重新投递,不保证远端业务最终成功。
|
|
12
|
+
*/
|
|
13
|
+
export async function renderOperations(main) {
|
|
14
|
+
const [summary, dead, jobs] = await Promise.all([
|
|
15
|
+
api('GET', '/api/sys/ops/summary'),
|
|
16
|
+
api('GET', '/api/sys/ops/dead-letters'),
|
|
17
|
+
api('GET', '/api/sys/ops/jobs'),
|
|
18
|
+
]);
|
|
19
|
+
const manageable = can('Sys.ops.manage');
|
|
20
|
+
main.append(h('div', { class: 'stats-row' }, stat('待投递事件', summary.outbox.reduce((sum, item) => sum + item.n, 0), 'inbox'), stat('待处理死信', summary.deadLetters, 'info', summary.deadLetters > 0), stat('运行中定时任务', jobs.filter((job) => job.enabled).length, 'clock')));
|
|
21
|
+
main.append(panel('事件投递队列', '事件由后台 worker 按顺序投递,失败后自动退避重试。', table([
|
|
22
|
+
[
|
|
23
|
+
'处理状态',
|
|
24
|
+
(item) => badge(statusName(item.status), item.status === 'pending' ? 'amber' : 'neutral'),
|
|
25
|
+
],
|
|
26
|
+
['事件数量', (item) => h('strong', {}, item.n)],
|
|
27
|
+
['最早进入时间', (item) => formatTime(item.oldest)],
|
|
28
|
+
], summary.outbox, '投递队列已清空,没有积压事件')));
|
|
29
|
+
main.append(panel('需要处理的死信', '请先排查失败原因再重放。同一单据的后续事件会等待这条事件处理完成。', table([
|
|
30
|
+
[
|
|
31
|
+
'业务事件',
|
|
32
|
+
(item) => h('div', { class: 'stack-cell' }, h('strong', {}, eventName(item.event)), h('code', {}, item.event)),
|
|
33
|
+
],
|
|
34
|
+
['关联单据', (item) => h('code', { title: item.aggregate_id }, item.aggregate_id.slice(0, 12))],
|
|
35
|
+
['尝试次数', (item) => badge(`${item.attempts} 次`, 'amber')],
|
|
36
|
+
[
|
|
37
|
+
'失败原因',
|
|
38
|
+
(item) => h('details', {}, h('summary', { class: 'failure-summary' }, (item.error || '无错误说明').slice(0, 65)), h('pre', {}, item.error || '无错误说明')),
|
|
39
|
+
],
|
|
40
|
+
['进入时间', (item) => formatTime(item.created_at)],
|
|
41
|
+
[
|
|
42
|
+
'处理',
|
|
43
|
+
(item) => manageable
|
|
44
|
+
? h('div', { class: 'row-actions' }, h('button', {
|
|
45
|
+
class: 'small primary',
|
|
46
|
+
onclick: async (event) => {
|
|
47
|
+
if (await run(() => api('POST', `/api/sys/ops/dead-letters/${item.id}/replay`), '事件已重新进入投递队列', event.currentTarget))
|
|
48
|
+
navigate('ops');
|
|
49
|
+
},
|
|
50
|
+
}, '重新投递'), h('button', { class: 'small danger', onclick: () => discard(item) }, '丢弃'))
|
|
51
|
+
: h('span', { class: 'muted' }, '仅查看'),
|
|
52
|
+
],
|
|
53
|
+
], dead, '当前没有待处理的死信')));
|
|
54
|
+
main.append(panel('定时任务', '按业务声明的时间执行;停用后不会继续触发新的调度。', table([
|
|
55
|
+
[
|
|
56
|
+
'任务名称',
|
|
57
|
+
(job) => h('div', { class: 'stack-cell' }, h('strong', {}, jobName(job.name)), h('code', {}, job.name)),
|
|
58
|
+
],
|
|
59
|
+
['执行计划', (job) => h('code', {}, job.cron)],
|
|
60
|
+
[
|
|
61
|
+
'业务操作',
|
|
62
|
+
(job) => h('div', { class: 'tag-list' }, job.calls.map((name) => badge(capabilityName(name)))),
|
|
63
|
+
],
|
|
64
|
+
[
|
|
65
|
+
'上次运行',
|
|
66
|
+
(job) => h('div', { class: 'stack-cell' }, formatTime(job.lastRunAt), job.lastStatus &&
|
|
67
|
+
badge(statusName(job.lastStatus), job.lastStatus === 'ok' ? 'green' : 'amber')),
|
|
68
|
+
],
|
|
69
|
+
[
|
|
70
|
+
'下次计划',
|
|
71
|
+
(job) => (job.enabled ? formatTime(job.nextRunAt) : h('span', { class: 'muted' }, '已暂停调度')),
|
|
72
|
+
],
|
|
73
|
+
[
|
|
74
|
+
'状态与操作',
|
|
75
|
+
(job) => h('div', { class: 'row-actions' }, badge(job.enabled ? '运行中' : '已停用', job.enabled ? 'green' : 'neutral'), manageable &&
|
|
76
|
+
h('button', {
|
|
77
|
+
class: 'small',
|
|
78
|
+
onclick: async (event) => {
|
|
79
|
+
if (await run(() => api('POST', `/api/sys/ops/jobs/${encodeURIComponent(job.name)}/${job.enabled ? 'disable' : 'enable'}`), '定时任务状态已更新', event.currentTarget))
|
|
80
|
+
navigate('ops');
|
|
81
|
+
},
|
|
82
|
+
}, job.enabled ? '停用' : '启用')),
|
|
83
|
+
],
|
|
84
|
+
], jobs, '当前业务没有声明定时任务')));
|
|
85
|
+
/**
|
|
86
|
+
* 打开针对单条死信的确认操作,提交 discard 后按成功结果刷新页面。
|
|
87
|
+
* 丢弃会改变后续事件处理条件,因此需要用户明确选择,不能作为自动修复失败的默认动作。
|
|
88
|
+
*/
|
|
89
|
+
function discard(item) {
|
|
90
|
+
const confirm = h('input', { type: 'checkbox', required: true });
|
|
91
|
+
const save = h('button', { class: 'danger', type: 'submit' }, '确认丢弃事件');
|
|
92
|
+
const form = h('form', {
|
|
93
|
+
class: 'stack-form',
|
|
94
|
+
onsubmit: async (event) => {
|
|
95
|
+
event.preventDefault();
|
|
96
|
+
if (await run(() => api('POST', `/api/sys/ops/dead-letters/${item.id}/discard`), '事件已丢弃', save)) {
|
|
97
|
+
dialog.close();
|
|
98
|
+
navigate('ops');
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
}, h('p', {}, '丢弃后,这条事件将不再重试。请确认业务补偿或人工处理已经完成。'), field('我已了解此操作会停止该事件的后续处理', confirm), h('div', { class: 'form-actions' }, save));
|
|
102
|
+
const dialog = modal('丢弃死信事件', form);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* 以既有计数生成运行统计卡,warning 仅决定视觉提示,不改变后台任务状态。
|
|
107
|
+
*/
|
|
108
|
+
function stat(label, value, glyph, warning = false) {
|
|
109
|
+
return h('div', { class: `stat-card ${warning ? 'attention' : ''}` }, h('span', { class: 'stat-icon' }, icon(glyph)), h('div', {}, h('span', {}, label), h('strong', {}, value)));
|
|
110
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 第三方同步记录查询页面,以连接器和状态筛选展示实际外呼记录。
|
|
3
|
+
* 响应受服务器数量上限限制,结果记录与业务最终完成是不同事实,页面没有绕过未知结果隔离的重发入口。
|
|
4
|
+
*/
|
|
5
|
+
import { api, run, state } from '../../shared/api.js';
|
|
6
|
+
import { badge, field, formatTime, h, panel, table } from '../../shared/dom.js';
|
|
7
|
+
/**
|
|
8
|
+
* 构建连接器/状态筛选与同步表格,初始 load 完成后返回。
|
|
9
|
+
* 读取需运维权限,错误和 HTTP 状态用于定位外部调用,不把 fake 或单次回执当真实系统验收。
|
|
10
|
+
*/
|
|
11
|
+
export async function renderSync(main) {
|
|
12
|
+
const connector = h('input', { placeholder: '连接器标识,留空查看全部' });
|
|
13
|
+
const status = h('select', {}, h('option', { value: '' }, '全部结果'), h('option', { value: 'ok' }, '同步成功'), h('option', { value: 'failed' }, '同步失败'));
|
|
14
|
+
const query = h('button', { class: 'primary', type: 'submit' }, '查询记录');
|
|
15
|
+
const out = h('div');
|
|
16
|
+
main.append(panel('外部系统同步记录', '查看出站请求的结果、重试次数与失败原因,辅助第三方联调与排障。', h('form', {
|
|
17
|
+
class: 'sync-filters',
|
|
18
|
+
onsubmit: (event) => {
|
|
19
|
+
event.preventDefault();
|
|
20
|
+
void load();
|
|
21
|
+
},
|
|
22
|
+
}, field('连接器', connector), field('同步结果', status), query), out));
|
|
23
|
+
/**
|
|
24
|
+
* 根据当前连接器及状态筛选发起只读请求,再替换同步记录列表。
|
|
25
|
+
* 查询失败通过页面反馈,不修改失败计数、回执或任务状态。
|
|
26
|
+
*/
|
|
27
|
+
async function load() {
|
|
28
|
+
const params = new URLSearchParams();
|
|
29
|
+
if (connector.value)
|
|
30
|
+
params.set('connector', connector.value);
|
|
31
|
+
if (status.value)
|
|
32
|
+
params.set('status', status.value);
|
|
33
|
+
const result = await run(() => api('GET', `/api/sys/ops/sync-records?${params}`), undefined, query);
|
|
34
|
+
if (!result)
|
|
35
|
+
return;
|
|
36
|
+
out.replaceChildren(table([
|
|
37
|
+
[
|
|
38
|
+
'连接器 / 操作',
|
|
39
|
+
(item) => h('div', { class: 'stack-cell' }, h('strong', {}, state.catalog.labels[`connector:connector:${item.connector}`] ||
|
|
40
|
+
state.catalog.labels[`connector:${item.connector}`] ||
|
|
41
|
+
'外部系统连接器'), h('code', {}, `${item.connector}.${item.operation}`)),
|
|
42
|
+
],
|
|
43
|
+
['业务单据', (item) => h('code', {}, item.ref_id || '—')],
|
|
44
|
+
[
|
|
45
|
+
'同步结果',
|
|
46
|
+
(item) => badge(item.status === 'ok' ? '同步成功' : '同步失败', item.status === 'ok' ? 'green' : 'red'),
|
|
47
|
+
],
|
|
48
|
+
['响应状态', (item) => item.http_status ?? '未收到响应'],
|
|
49
|
+
['尝试次数', (item) => `${item.attempts} 次`],
|
|
50
|
+
[
|
|
51
|
+
'错误详情',
|
|
52
|
+
(item) => item.error
|
|
53
|
+
? h('details', {}, h('summary', {}, '查看原因'), h('pre', {}, item.error))
|
|
54
|
+
: h('span', { class: 'muted' }, '—'),
|
|
55
|
+
],
|
|
56
|
+
['最近更新', (item) => formatTime(item.updated_at)],
|
|
57
|
+
], result, '暂无符合条件的同步记录'));
|
|
58
|
+
}
|
|
59
|
+
await load();
|
|
60
|
+
}
|