@marsken/dsh-studio 0.0.0-stage → 0.1.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/AGENTS.md +148 -0
- package/LICENSE +202 -0
- package/README.md +1356 -3
- package/client.js +952 -0
- package/client.src.js +2466 -0
- package/cordis.patch.yml +9 -0
- package/dist/client.js +952 -0
- package/docs/compatibility.md +99 -0
- package/docs/publish.md +86 -0
- package/docs/quickstart.md +53 -0
- package/icon.svg +6 -0
- package/index.js +2674 -0
- package/locale/en.json +11 -0
- package/locale/zh.json +11 -0
- package/package.json +63 -4
- package/scripts/smoke-store.mjs +76 -0
- package/src/host/cases.js +135 -0
- package/src/host/design.js +87 -0
- package/src/host/dispatcher.js +936 -0
- package/src/host/domain.js +1966 -0
- package/src/host/executor.js +1396 -0
- package/src/host/gitlab-facts.js +22 -0
- package/src/host/impact.js +32 -0
- package/src/host/integrations.js +587 -0
- package/src/host/quality.js +106 -0
- package/src/host/routes/admin.js +100 -0
- package/src/host/routes/agents.js +71 -0
- package/src/host/routes/auth.js +155 -0
- package/src/host/routes/coding.js +203 -0
- package/src/host/routes/connect.js +166 -0
- package/src/host/routes/exports.js +122 -0
- package/src/host/routes/insights.js +97 -0
- package/src/host/routes/releases.js +335 -0
- package/src/host/routes/requirements.js +430 -0
- package/src/host/routes/skills.js +75 -0
- package/src/host/routes/tasks.js +573 -0
- package/src/host/store.js +522 -0
- package/src/host/team-memory.js +46 -0
- package/src/host/tools.js +1211 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { INTEGRATION, feishuPush, gitlabReady, gitlabRequest, gitlabSync, healthReport, projectReady } from '../integrations.js'
|
|
2
|
+
import { cleanId } from '../domain.js'
|
|
3
|
+
const INJECTED = ["applyConfig","installedPackages","publicState","readJsonBody","resolvePackageName","runPluginCli","sendJson","getSyncTimer","upsertById"]
|
|
4
|
+
let host = {
|
|
5
|
+
applyConfig: () => {},
|
|
6
|
+
installedPackages: () => {},
|
|
7
|
+
publicState: () => {},
|
|
8
|
+
readJsonBody: () => {},
|
|
9
|
+
resolvePackageName: () => {},
|
|
10
|
+
runPluginCli: () => {},
|
|
11
|
+
sendJson: () => {},
|
|
12
|
+
getSyncTimer: () => undefined,
|
|
13
|
+
upsertById: () => {},
|
|
14
|
+
}
|
|
15
|
+
/** 市场、集成健康、GitLab 同步、仓库与飞书机器人。宿主能力由 configureConnectRoutes() 注入。 */
|
|
16
|
+
export function configureConnectRoutes(services) {
|
|
17
|
+
host = { ...host, ...(services ?? {}) }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function handleConnectRoute({ method, route, req, res, store, url }) {
|
|
21
|
+
if (method === 'GET' && route === '/market') {
|
|
22
|
+
host.sendJson(res, 200, {
|
|
23
|
+
ok: true,
|
|
24
|
+
title: store.snapshot().config.market.title,
|
|
25
|
+
items: store.snapshot().config.market.items,
|
|
26
|
+
installed: await host.installedPackages(),
|
|
27
|
+
})
|
|
28
|
+
return true
|
|
29
|
+
}
|
|
30
|
+
if (method === 'POST' && route === '/market/install') {
|
|
31
|
+
const body = await host.readJsonBody(req)
|
|
32
|
+
const item = store.snapshot().config.market.items.find(entry => entry.id === String(body.id ?? ''))
|
|
33
|
+
if (item === undefined) throw new Error('unknown market item')
|
|
34
|
+
const result = await host.runPluginCli(['add', item.spec])
|
|
35
|
+
host.sendJson(res, 200, { ok: result.ok, result, installed: await host.installedPackages(), restartRequired: result.ok })
|
|
36
|
+
return true
|
|
37
|
+
}
|
|
38
|
+
if (method === 'POST' && route === '/market/uninstall') {
|
|
39
|
+
const body = await host.readJsonBody(req)
|
|
40
|
+
const item = store.snapshot().config.market.items.find(entry => entry.id === String(body.id ?? ''))
|
|
41
|
+
const requested = String(body.pkg ?? '').trim()
|
|
42
|
+
const pkg = requested.length > 0 ? requested : (item === undefined ? undefined : await host.resolvePackageName(item.spec))
|
|
43
|
+
if (pkg === undefined) throw new Error('unknown market item')
|
|
44
|
+
const result = await host.runPluginCli(['remove', pkg])
|
|
45
|
+
host.sendJson(res, 200, { ok: result.ok, pkg, result, installed: await host.installedPackages(), restartRequired: result.ok })
|
|
46
|
+
return true
|
|
47
|
+
}
|
|
48
|
+
if (method === 'GET' && route === '/integrations') {
|
|
49
|
+
const config = store.snapshot().config
|
|
50
|
+
host.sendJson(res, 200, {
|
|
51
|
+
ok: true,
|
|
52
|
+
gitlab: {
|
|
53
|
+
ready: gitlabReady(config),
|
|
54
|
+
syncing: host.getSyncTimer() !== undefined,
|
|
55
|
+
projects: (config.gitlab.projects ?? []).map(project => ({
|
|
56
|
+
id: project.id,
|
|
57
|
+
ready: projectReady(config, project),
|
|
58
|
+
lastSyncAt: project.lastSyncAt,
|
|
59
|
+
lastSyncError: project.lastSyncError,
|
|
60
|
+
})),
|
|
61
|
+
},
|
|
62
|
+
feishu: {
|
|
63
|
+
ready: (config.feishu.bots ?? []).some(bot => bot.enabled && bot.webhook.length > 0),
|
|
64
|
+
lastError: INTEGRATION.lastPushError,
|
|
65
|
+
},
|
|
66
|
+
health: healthReport(),
|
|
67
|
+
})
|
|
68
|
+
return true
|
|
69
|
+
}
|
|
70
|
+
if (method === 'GET' && route === '/integrations/health') {
|
|
71
|
+
host.sendJson(res, 200, { ok: true, ...healthReport() })
|
|
72
|
+
return true
|
|
73
|
+
}
|
|
74
|
+
if (method === 'POST' && route === '/gitlab/sync') {
|
|
75
|
+
const body = await host.readJsonBody(req).catch(() => ({}))
|
|
76
|
+
const summary = await gitlabSync(store, body.project === undefined ? undefined : String(body.project))
|
|
77
|
+
host.sendJson(res, 200, { ok: true, summary, state: host.publicState(store) })
|
|
78
|
+
return true
|
|
79
|
+
}
|
|
80
|
+
if (method === 'POST' && route === '/repos/save') {
|
|
81
|
+
const body = await host.readJsonBody(req)
|
|
82
|
+
// 老前端发 {project},新脚本发 {repo},两个都认。
|
|
83
|
+
const incoming = body.repo ?? body.project ?? body
|
|
84
|
+
if (String(incoming.name ?? incoming.project ?? '').trim() === '') {
|
|
85
|
+
throw new Error('仓库至少要填一个名字(或者 GitLab 项目路径),否则存下来是一条空记录')
|
|
86
|
+
}
|
|
87
|
+
const existing = store.snapshot().config.gitlab.projects
|
|
88
|
+
const wantedId = cleanId(incoming.id ?? incoming.name ?? incoming.project, 'repo')
|
|
89
|
+
const saved = existing.find(item => item.id === wantedId)
|
|
90
|
+
const merged = {
|
|
91
|
+
...incoming,
|
|
92
|
+
id: wantedId,
|
|
93
|
+
// An empty token field means "keep the one already stored".
|
|
94
|
+
token: String(incoming.token ?? '').trim() || (saved?.token ?? ''),
|
|
95
|
+
}
|
|
96
|
+
await host.applyConfig(store, { gitlab: { projects: host.upsertById(existing, merged) } })
|
|
97
|
+
// 只回公开视图:token 不能出现在响应里。
|
|
98
|
+
const state = host.publicState(store)
|
|
99
|
+
host.sendJson(res, 200, { ok: true, repos: state.config.gitlab.projects, state })
|
|
100
|
+
return true
|
|
101
|
+
}
|
|
102
|
+
if (method === 'POST' && route === '/repos/delete') {
|
|
103
|
+
const body = await host.readJsonBody(req)
|
|
104
|
+
const id = cleanId(body.id, 'repo')
|
|
105
|
+
await host.applyConfig(store, { gitlab: { projects: store.snapshot().config.gitlab.projects.filter(entry => entry.id !== id) } })
|
|
106
|
+
host.sendJson(res, 200, { ok: true, state: host.publicState(store) })
|
|
107
|
+
return true
|
|
108
|
+
}
|
|
109
|
+
if (method === 'POST' && route === '/repos/test') {
|
|
110
|
+
const body = await host.readJsonBody(req)
|
|
111
|
+
const config = store.snapshot().config
|
|
112
|
+
const project = config.gitlab.projects.find(entry => entry.id === cleanId(body.id, 'repo'))
|
|
113
|
+
if (project === undefined) throw new Error('unknown project')
|
|
114
|
+
if (!projectReady(config, project)) throw new Error('这个仓库还没填全:地址 / 项目路径 / 访问令牌')
|
|
115
|
+
const info = await gitlabRequest(config, project, '/issues?per_page=1')
|
|
116
|
+
host.sendJson(res, 200, { ok: true, reachable: true, sample: Array.isArray(info) ? info.length : 0 })
|
|
117
|
+
return true
|
|
118
|
+
}
|
|
119
|
+
if (method === 'POST' && route === '/bots/save') {
|
|
120
|
+
const body = await host.readJsonBody(req)
|
|
121
|
+
const incoming = body.bot ?? body
|
|
122
|
+
const existing = store.snapshot().config.feishu.bots
|
|
123
|
+
const wantedId = cleanId(incoming.id ?? incoming.name, 'bot')
|
|
124
|
+
const saved = existing.find(item => item.id === wantedId)
|
|
125
|
+
const merged = {
|
|
126
|
+
...incoming,
|
|
127
|
+
id: wantedId,
|
|
128
|
+
// An empty webhook field means "keep the one already stored".
|
|
129
|
+
webhook: String(incoming.webhook ?? '').trim() || (saved?.webhook ?? ''),
|
|
130
|
+
}
|
|
131
|
+
await host.applyConfig(store, { feishu: { bots: host.upsertById(existing, merged) } })
|
|
132
|
+
const state = host.publicState(store)
|
|
133
|
+
host.sendJson(res, 200, { ok: true, bots: state.config.feishu.bots, state })
|
|
134
|
+
return true
|
|
135
|
+
}
|
|
136
|
+
if (method === 'POST' && route === '/bots/delete') {
|
|
137
|
+
const body = await host.readJsonBody(req)
|
|
138
|
+
const id = cleanId(body.id, 'bot')
|
|
139
|
+
await host.applyConfig(store, { feishu: { bots: store.snapshot().config.feishu.bots.filter(entry => entry.id !== id) } })
|
|
140
|
+
host.sendJson(res, 200, { ok: true, state: host.publicState(store) })
|
|
141
|
+
return true
|
|
142
|
+
}
|
|
143
|
+
if (method === 'POST' && route === '/feishu/test') {
|
|
144
|
+
const body = await host.readJsonBody(req).catch(() => ({}))
|
|
145
|
+
const config = store.snapshot().config
|
|
146
|
+
const wanted = body.id === undefined ? '' : cleanId(body.id, 'bot')
|
|
147
|
+
const bots = (config.feishu.bots ?? []).filter(bot => wanted.length === 0 || bot.id === wanted)
|
|
148
|
+
const results = []
|
|
149
|
+
for (const bot of bots) {
|
|
150
|
+
try {
|
|
151
|
+
const result = await feishuPush(bot.webhook, '【需求&任务工作空间】这是一条测试消息,收到即说明群通知已接通。')
|
|
152
|
+
results.push({ bot: bot.id, name: bot.name, ...result })
|
|
153
|
+
} catch (error) {
|
|
154
|
+
results.push({ bot: bot.id, name: bot.name, ok: false, error: String(error?.message ?? error).slice(0, 200) })
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
host.sendJson(res, 200, { ok: results.some(entry => entry.ok === true), results })
|
|
158
|
+
return true
|
|
159
|
+
}
|
|
160
|
+
if (method === 'POST' && route === '/reset') {
|
|
161
|
+
await store.mutate(state => { state.tasks = [] })
|
|
162
|
+
host.sendJson(res, 200, { ok: true, state: host.publicState(store) })
|
|
163
|
+
return true
|
|
164
|
+
}
|
|
165
|
+
return false
|
|
166
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { REQ_STATUS_LABEL, STATUSES } from '../domain.js'
|
|
2
|
+
|
|
3
|
+
let host = { sendCsv: () => {}, requirementProgress: () => null, sessions: new Map() }
|
|
4
|
+
|
|
5
|
+
/** 导出类路由(CSV)。宿主能力由 configureExportRoutes() 注入,避免反向 import index.js。 */
|
|
6
|
+
export function configureExportRoutes(services) {
|
|
7
|
+
host = { ...host, ...(services ?? {}) }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function handleExportRoute({ method, route, url, res, store, sendJson }) {
|
|
11
|
+
if (method === 'GET' && route === '/export/tasks.csv') {
|
|
12
|
+
const snapshot = store.snapshot()
|
|
13
|
+
const rows = [['编号', '标题', '类型', '状态', '负责人', '需求', '项目', '迭代', '版本', '阻塞', '截止', '用例通过', '用例总数', '交付物', '更新时间']]
|
|
14
|
+
for (const task of snapshot.tasks) {
|
|
15
|
+
const requirement = snapshot.requirements.find(item => item.id === task.requirementId)
|
|
16
|
+
const project = snapshot.config.projects.find(item => item.id === task.project)
|
|
17
|
+
const iteration = project?.iterations.find(item => item.id === task.iteration)
|
|
18
|
+
const release = snapshot.releases.find(item => item.id === task.releaseId)
|
|
19
|
+
const cases = task.cases ?? []
|
|
20
|
+
rows.push([
|
|
21
|
+
task.code || task.id,
|
|
22
|
+
task.title,
|
|
23
|
+
task.type,
|
|
24
|
+
STATUSES.find(item => item.id === task.status)?.label ?? task.status,
|
|
25
|
+
task.owner ?? '',
|
|
26
|
+
requirement === undefined ? '' : `${requirement.code} ${requirement.title}`.trim(),
|
|
27
|
+
project?.name ?? '',
|
|
28
|
+
iteration?.name ?? '',
|
|
29
|
+
release?.version ?? '',
|
|
30
|
+
task.blocked === null || task.blocked === undefined ? '' : `是:${String(task.blocked.reason ?? '').slice(0, 60)}`,
|
|
31
|
+
task.dueAt > 0 ? new Date(task.dueAt).toISOString().slice(0, 10) : '',
|
|
32
|
+
cases.filter(item => item.result === 'pass').length,
|
|
33
|
+
cases.length,
|
|
34
|
+
(task.artifacts ?? []).length,
|
|
35
|
+
new Date(task.updatedAt).toISOString().slice(0, 19).replace('T', ' '),
|
|
36
|
+
])
|
|
37
|
+
}
|
|
38
|
+
host.sendCsv(res, rows, 'tasks.csv')
|
|
39
|
+
return true
|
|
40
|
+
}
|
|
41
|
+
if (method === 'GET' && route === '/export/requirements.csv') {
|
|
42
|
+
const snapshot = store.snapshot()
|
|
43
|
+
const rows = [['需求编号', '标题', '状态', '负责人', '项目', '功能点', '已覆盖', '卡数', '已完成', '用例通过', '用例总数', '交付物', '签字', '更新时间']]
|
|
44
|
+
for (const requirement of snapshot.requirements) {
|
|
45
|
+
const progress = host.requirementProgress(snapshot, requirement)
|
|
46
|
+
rows.push([
|
|
47
|
+
requirement.code,
|
|
48
|
+
requirement.title,
|
|
49
|
+
REQ_STATUS_LABEL[requirement.status] ?? requirement.status,
|
|
50
|
+
requirement.owner ?? '',
|
|
51
|
+
snapshot.config.projects.find(item => item.id === requirement.project)?.name ?? '',
|
|
52
|
+
progress.gate.stats.points,
|
|
53
|
+
progress.gate.stats.coveredPoints,
|
|
54
|
+
progress.cards.total,
|
|
55
|
+
progress.cards.done,
|
|
56
|
+
progress.gate.stats.pass,
|
|
57
|
+
progress.gate.stats.cases,
|
|
58
|
+
progress.gate.stats.artifacts,
|
|
59
|
+
requirement.signoff === null ? '' : `${requirement.signoff.by} ${new Date(requirement.signoff.at).toISOString().slice(0, 10)}`,
|
|
60
|
+
new Date(requirement.updatedAt).toISOString().slice(0, 19).replace('T', ' '),
|
|
61
|
+
])
|
|
62
|
+
}
|
|
63
|
+
host.sendCsv(res, rows, 'requirements.csv')
|
|
64
|
+
return true
|
|
65
|
+
}
|
|
66
|
+
if (method === 'GET' && route === '/audit/export.csv') {
|
|
67
|
+
const snapshot = store.snapshot()
|
|
68
|
+
const cfg = snapshot.config
|
|
69
|
+
if (cfg?.ldap?.enabled === true && cfg?.ldap?.requireLogin === true) {
|
|
70
|
+
const token = String(url.searchParams.get('token') ?? '')
|
|
71
|
+
const session = token === '' ? null : host.sessions.get(token)
|
|
72
|
+
if (session === null || (Number(session.expiresAt) || 0) <= Date.now()) {
|
|
73
|
+
sendJson(res, 401, { ok: false, error: '导出审计日志要先登录' })
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const by = String(url.searchParams.get('by') ?? '').trim()
|
|
78
|
+
const keyword = String(url.searchParams.get('q') ?? '').trim().toLowerCase()
|
|
79
|
+
const onlyFailed = url.searchParams.get('failed') === '1'
|
|
80
|
+
const list = (snapshot.audit ?? []).filter(item => (by === '' || item.by === by)
|
|
81
|
+
&& (onlyFailed === false || item.ok === false)
|
|
82
|
+
&& (keyword === '' || `${item.route} ${item.by} ${item.summary}`.toLowerCase().includes(keyword)))
|
|
83
|
+
const csvRows = [['时间', '操作人', '动作', '详情', '结果']]
|
|
84
|
+
for (const item of list.slice(0, 5000)) {
|
|
85
|
+
csvRows.push([
|
|
86
|
+
new Date(item.at).toISOString().slice(0, 19).replace('T', ' '),
|
|
87
|
+
item.by,
|
|
88
|
+
item.route,
|
|
89
|
+
item.summary,
|
|
90
|
+
item.ok ? '成功' : `失败 ${item.status}`,
|
|
91
|
+
])
|
|
92
|
+
}
|
|
93
|
+
host.sendCsv(res, csvRows, `audit-${new Date().toISOString().slice(0, 10)}.csv`)
|
|
94
|
+
return true
|
|
95
|
+
}
|
|
96
|
+
if (method === 'GET' && route === '/export/dispatch.csv') {
|
|
97
|
+
const snapshot = store.snapshot()
|
|
98
|
+
const cfg = snapshot.config
|
|
99
|
+
if (cfg?.ldap?.enabled === true && cfg?.ldap?.requireLogin === true) {
|
|
100
|
+
const token = String(url.searchParams.get('token') ?? '')
|
|
101
|
+
const session = token === '' ? null : host.sessions.get(token)
|
|
102
|
+
if (session === null || (Number(session.expiresAt) || 0) <= Date.now()) {
|
|
103
|
+
sendJson(res, 401, { ok: false, error: '导出派单日志要先登录' })
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const keyword = String(url.searchParams.get('q') ?? '').trim().toLowerCase()
|
|
108
|
+
const onlyFailed = url.searchParams.get('failed') === '1'
|
|
109
|
+
const list = (snapshot.agents?.log ?? []).filter(entry => {
|
|
110
|
+
const line = String(entry?.line ?? '')
|
|
111
|
+
if (onlyFailed && !/失败|错误|异常|拦|拒绝/.test(line)) return false
|
|
112
|
+
return keyword === '' || line.toLowerCase().includes(keyword)
|
|
113
|
+
})
|
|
114
|
+
const csvRows = [['时间', '内容']]
|
|
115
|
+
for (const entry of list.slice(0, 2000)) {
|
|
116
|
+
csvRows.push([new Date(Number(entry.at) || 0).toISOString().slice(0, 19).replace('T', ' '), String(entry.line ?? '')])
|
|
117
|
+
}
|
|
118
|
+
host.sendCsv(res, csvRows, `dispatch-${new Date().toISOString().slice(0, 10)}.csv`)
|
|
119
|
+
return true
|
|
120
|
+
}
|
|
121
|
+
return false
|
|
122
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildDashboard,
|
|
3
|
+
} from '../executor.js'
|
|
4
|
+
import { runSummary } from '../dispatcher.js'
|
|
5
|
+
const INJECTED = ['getDispatcher', "buildAnalytics","buildMetrics","burndownReport","sendJson","workloadReport"]
|
|
6
|
+
let host = {
|
|
7
|
+
getDispatcher: () => undefined,
|
|
8
|
+
buildAnalytics: () => {},
|
|
9
|
+
buildMetrics: () => {},
|
|
10
|
+
burndownReport: () => {},
|
|
11
|
+
sendJson: () => {},
|
|
12
|
+
workloadReport: () => {},
|
|
13
|
+
}
|
|
14
|
+
/** 度量与工作台(指标、分析、工作量、燃尽、成本)相关路由。宿主能力由 configureInsightsRoutes() 注入。 */
|
|
15
|
+
import { buildQuality } from '../quality.js'
|
|
16
|
+
|
|
17
|
+
export function configureInsightsRoutes(services) {
|
|
18
|
+
host = { ...host, ...(services ?? {}) }
|
|
19
|
+
}
|
|
20
|
+
export async function handleInsightsRoute({ method, route, req, res, store, url }) {
|
|
21
|
+
if (method === 'GET' && route === '/metrics') {
|
|
22
|
+
host.sendJson(res, 200, { ok: true, metrics: host.buildMetrics(store.snapshot()) })
|
|
23
|
+
return true
|
|
24
|
+
}
|
|
25
|
+
if (method === 'GET' && route === '/analytics') {
|
|
26
|
+
const snapshot = store.snapshot()
|
|
27
|
+
host.sendJson(res, 200, {
|
|
28
|
+
ok: true,
|
|
29
|
+
analytics: host.buildAnalytics(snapshot, {
|
|
30
|
+
days: url.searchParams.get('days') ?? '',
|
|
31
|
+
project: url.searchParams.get('project') ?? '',
|
|
32
|
+
}),
|
|
33
|
+
})
|
|
34
|
+
return true
|
|
35
|
+
}
|
|
36
|
+
if (method === 'GET' && route === '/dashboard') {
|
|
37
|
+
// 最近一次回归(定时或人跑的)也一起给工作台,早上打开就知道昨晚跑成什么样。
|
|
38
|
+
const regressions = await store.readSide('regressions').catch(() => null)
|
|
39
|
+
host.sendJson(res, 200, {
|
|
40
|
+
ok: true,
|
|
41
|
+
dashboard: buildDashboard(store.snapshot()),
|
|
42
|
+
regression: Array.isArray(regressions) && regressions.length > 0 ? regressions[0] : null,
|
|
43
|
+
})
|
|
44
|
+
return true
|
|
45
|
+
}
|
|
46
|
+
if (method === 'GET' && route === '/quality') {
|
|
47
|
+
// AI 质量:派单量 / 一次通过率 / 返工率 / 平均耗时。成本未接入(见 README 口径)。
|
|
48
|
+
host.sendJson(res, 200, { ok: true, quality: buildQuality(store.snapshot(), runSummary()) })
|
|
49
|
+
return true
|
|
50
|
+
}
|
|
51
|
+
if (method === 'GET' && route === '/workload') {
|
|
52
|
+
const dispatcher = host.getDispatcher()
|
|
53
|
+
const running = dispatcher === undefined ? [] : [...dispatcher.running.values()]
|
|
54
|
+
host.sendJson(res, 200, { ok: true, workload: host.workloadReport(store.snapshot(), running) })
|
|
55
|
+
return true
|
|
56
|
+
}
|
|
57
|
+
if (method === 'GET' && route === '/burndown') {
|
|
58
|
+
host.sendJson(res, 200, {
|
|
59
|
+
ok: true,
|
|
60
|
+
burndown: host.burndownReport(store.snapshot(), {
|
|
61
|
+
days: url.searchParams.get('days') ?? '',
|
|
62
|
+
project: url.searchParams.get('project') ?? '',
|
|
63
|
+
}),
|
|
64
|
+
})
|
|
65
|
+
return true
|
|
66
|
+
}
|
|
67
|
+
if (method === 'GET' && route === '/costs') {
|
|
68
|
+
const summary = runSummary()
|
|
69
|
+
const budget = store.snapshot().config.costs
|
|
70
|
+
const runs = summary.total
|
|
71
|
+
const hours = Math.round(((summary.avgMs * summary.finished) / 3600_000) * 10) / 10
|
|
72
|
+
const runRate = budget.budgetRuns > 0 ? Math.min(100, Math.round((runs / budget.budgetRuns) * 100)) : 0
|
|
73
|
+
const hourRate = budget.budgetHours > 0 ? Math.min(100, Math.round((hours / budget.budgetHours) * 100)) : 0
|
|
74
|
+
const level = Math.max(runRate, hourRate) >= 100
|
|
75
|
+
? 'over'
|
|
76
|
+
: Math.max(runRate, hourRate) >= budget.warnAt ? 'warn' : 'ok'
|
|
77
|
+
host.sendJson(res, 200, {
|
|
78
|
+
ok: true,
|
|
79
|
+
costs: summary,
|
|
80
|
+
budget: {
|
|
81
|
+
...budget,
|
|
82
|
+
runs,
|
|
83
|
+
hours,
|
|
84
|
+
runRate,
|
|
85
|
+
hourRate,
|
|
86
|
+
level,
|
|
87
|
+
hint: level === 'over'
|
|
88
|
+
? '已经超出预算:要么调高预算,要么停下来看看是不是有卡在空转'
|
|
89
|
+
: level === 'warn'
|
|
90
|
+
? `已用到 ${Math.max(runRate, hourRate)}%,超过 ${budget.warnAt}% 会在这里标黄`
|
|
91
|
+
: '预算内',
|
|
92
|
+
},
|
|
93
|
+
})
|
|
94
|
+
return true
|
|
95
|
+
}
|
|
96
|
+
return false
|
|
97
|
+
}
|