dsh-d365-kit 0.34.1 → 0.34.3
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/CHANGELOG.md +318 -308
- package/CHANGELOG.zh-CN.md +317 -307
- package/INSTALL.md +94 -94
- package/INSTALL.zh-CN.md +93 -93
- package/LICENSE +21 -21
- package/README.md +336 -327
- package/README.zh-CN.md +336 -327
- package/client.js +1818 -1818
- package/cordis.patch.yml +44 -44
- package/docs/en/architecture.md +172 -172
- package/docs/en/checks.md +184 -184
- package/docs/en/installation.md +151 -151
- package/docs/en/permissions.md +161 -161
- package/docs/en/scenarios.md +202 -202
- package/docs/en/tools.md +665 -665
- package/docs/en/troubleshooting.md +131 -131
- package/docs/zh/architecture.md +172 -172
- package/docs/zh/checks.md +184 -184
- package/docs/zh/installation.md +151 -151
- package/docs/zh/permissions.md +161 -161
- package/docs/zh/scenarios.md +202 -202
- package/docs/zh/tools.md +665 -665
- package/docs/zh/troubleshooting.md +131 -131
- package/examples/prompts.en.md +206 -206
- package/examples/prompts.zh-CN.md +206 -206
- package/examples/waivers.example.json +20 -20
- package/icon.svg +6 -6
- package/index.js +139 -139
- package/lib/auth.js +181 -181
- package/lib/client.js +496 -496
- package/lib/code-check.js +549 -549
- package/lib/config.js +323 -323
- package/lib/contract-history.js +49 -49
- package/lib/contract.js +583 -583
- package/lib/csharp-check.js +493 -493
- package/lib/data-migrate.js +1318 -1318
- package/lib/data-policy.js +78 -78
- package/lib/error-codes.js +80 -80
- package/lib/gitlab.js +161 -161
- package/lib/health.js +205 -205
- package/lib/migrate.js +732 -732
- package/lib/preflight.js +116 -116
- package/lib/refs.js +191 -191
- package/lib/resources.js +320 -320
- package/lib/retention-defaults.js +17 -17
- package/lib/retention.js +384 -384
- package/lib/runtime.js +232 -232
- package/lib/settings-http.js +512 -512
- package/lib/settings-store.js +371 -371
- package/lib/snapshot.js +149 -149
- package/lib/tool-code-check.js +133 -133
- package/lib/tool-configure.js +169 -169
- package/lib/tool-contract.js +376 -376
- package/lib/tool-data-sync.js +559 -559
- package/lib/tool-data-write.js +203 -203
- package/lib/tool-data.js +282 -282
- package/lib/tool-diff.js +484 -484
- package/lib/tool-environments.js +106 -106
- package/lib/tool-flows.js +540 -540
- package/lib/tool-health.js +127 -127
- package/lib/tool-layers.js +497 -497
- package/lib/tool-metadata.js +417 -417
- package/lib/tool-migrate.js +102 -102
- package/lib/tool-refs.js +206 -206
- package/lib/tool-reports.js +299 -299
- package/lib/tool-sync.js +430 -430
- package/lib/tool-write.js +563 -563
- package/lib/waivers.js +83 -83
- package/lib/write-actions.js +1374 -1374
- package/lib/write-policy.js +199 -199
- package/lib/write-store.js +167 -167
- package/locale/en.json +68 -68
- package/locale/zh.json +68 -68
- package/package.json +2 -2
- package/skills/d365-troubleshooting/SKILL.md +115 -115
package/lib/health.js
CHANGED
|
@@ -1,205 +1,205 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Environment health: what is running right now, what failed recently, and how
|
|
3
|
-
* busy the org looks. Meant to answer "is it safe to publish?" before a release.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { D365Error } from './errors.js'
|
|
7
|
-
import { makeClient, readOrganization } from './runtime.js'
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* `asyncoperation.statecode`: 0 Ready (queued), 1 Suspended, 2 Locked,
|
|
11
|
-
* 3 Completed. Completed is *not* failure: `statuscode` splits it into
|
|
12
|
-
* 30 Succeeded / 31 Failed / 32 Canceled, so "failed" needs both codes.
|
|
13
|
-
*/
|
|
14
|
-
const READY_STATE = 0
|
|
15
|
-
const SUSPENDED_STATE = 1
|
|
16
|
-
const COMPLETED_STATE = 3
|
|
17
|
-
const FAILED_STATUS = 31
|
|
18
|
-
|
|
19
|
-
const FAILED_FILTER = `statecode eq ${COMPLETED_STATE} and statuscode eq ${FAILED_STATUS}`
|
|
20
|
-
|
|
21
|
-
async function count(client, path, filter, signal) {
|
|
22
|
-
// `$count=true` is capped at 5000 by Dataverse; aggregation is exact.
|
|
23
|
-
try {
|
|
24
|
-
const url = client.buildUrl(path, { $filter: filter, $apply: 'aggregate($count as count)' })
|
|
25
|
-
const page = await client.requestJson(url, { signal })
|
|
26
|
-
const value = page?.value?.[0]?.count
|
|
27
|
-
if (Number.isFinite(value)) return { count: value }
|
|
28
|
-
} catch {
|
|
29
|
-
// fall through to the simpler read
|
|
30
|
-
}
|
|
31
|
-
const url = client.buildUrl(path, { $filter: filter, $select: 'asyncoperationid', $top: 1, $count: 'true' })
|
|
32
|
-
const page = await client.requestJson(url, { signal })
|
|
33
|
-
return { count: page?.['@odata.count'] ?? page?.value?.length ?? 0, rows: page?.value ?? [] }
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
async function listFailed(client, sinceIso, signal) {
|
|
37
|
-
const rows = []
|
|
38
|
-
let next = client.buildUrl('asyncoperations', {
|
|
39
|
-
$select: 'asyncoperationid,name,operationtype,errorcode,message,modifiedon',
|
|
40
|
-
$filter: `${FAILED_FILTER} and modifiedon ge ${sinceIso}`,
|
|
41
|
-
$orderby: 'modifiedon desc',
|
|
42
|
-
$top: 50,
|
|
43
|
-
})
|
|
44
|
-
let pages = 0
|
|
45
|
-
while (next && pages < 3) {
|
|
46
|
-
const page = await client.requestJson(next, { signal })
|
|
47
|
-
rows.push(...(page?.value ?? []))
|
|
48
|
-
next = page?.['@odata.nextLink'] ?? null
|
|
49
|
-
pages += 1
|
|
50
|
-
}
|
|
51
|
-
return rows
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
async function listRunningSolutions(client, signal) {
|
|
55
|
-
const url = client.buildUrl('msdyn_solutionhistories', {
|
|
56
|
-
$select: 'msdyn_name,msdyn_operation,msdyn_starttime,msdyn_endtime,msdyn_ismanaged',
|
|
57
|
-
$filter: 'msdyn_endtime eq null',
|
|
58
|
-
$orderby: 'msdyn_starttime desc',
|
|
59
|
-
$top: 10,
|
|
60
|
-
})
|
|
61
|
-
const page = await client.requestJson(url, { signal })
|
|
62
|
-
return page?.value ?? []
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
async function recentSolutions(client, signal) {
|
|
66
|
-
const url = client.buildUrl('solutions', {
|
|
67
|
-
$select: 'uniquename,friendlyname,version,ismanaged,installedon',
|
|
68
|
-
$orderby: 'installedon desc',
|
|
69
|
-
$top: 5,
|
|
70
|
-
})
|
|
71
|
-
const page = await client.requestJson(url, { signal })
|
|
72
|
-
return page?.value ?? []
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** Collect one environment's health snapshot. */
|
|
76
|
-
export async function readHealth(config, envKey, options = {}) {
|
|
77
|
-
const env = config.environments[envKey]
|
|
78
|
-
if (!env) throw new D365Error(`未知环境:${envKey}`, { hint: `可用:${Object.keys(config.environments).join(', ')}` })
|
|
79
|
-
const hours = Number.isFinite(options.hours) ? options.hours : 24
|
|
80
|
-
const sinceIso = new Date(Date.now() - hours * 3600 * 1000).toISOString()
|
|
81
|
-
const client = makeClient(env, config)
|
|
82
|
-
const signal = options.signal
|
|
83
|
-
|
|
84
|
-
const version = await client.readVersion().catch(() => null)
|
|
85
|
-
const organization = await readOrganization(client, signal).catch(() => null)
|
|
86
|
-
// A swallowed permission error must not read as "0 failures": this tool is
|
|
87
|
-
// asked precisely when something is wrong, and a false all-clear is worse
|
|
88
|
-
// than a visible gap. Track whether the async-job reads were refused.
|
|
89
|
-
const refused = (error) => error?.code === 'data-read-forbidden' || error?.status === 401 || error?.status === 403
|
|
90
|
-
const jobErrors = []
|
|
91
|
-
const jobCount = (filter) => count(client, 'asyncoperations', filter, signal)
|
|
92
|
-
.then((value) => ({ ...value, error: null }))
|
|
93
|
-
.catch((error) => {
|
|
94
|
-
jobErrors.push(error)
|
|
95
|
-
return { count: null, error }
|
|
96
|
-
})
|
|
97
|
-
const queued = await jobCount(`statecode eq ${READY_STATE}`)
|
|
98
|
-
const suspended = await jobCount(`statecode eq ${SUSPENDED_STATE}`)
|
|
99
|
-
const failed = await jobCount(`${FAILED_FILTER} and modifiedon ge ${sinceIso}`)
|
|
100
|
-
const failures = await listFailed(client, sinceIso, signal).catch((error) => {
|
|
101
|
-
jobErrors.push(error)
|
|
102
|
-
return []
|
|
103
|
-
})
|
|
104
|
-
const denied = jobErrors.some(refused)
|
|
105
|
-
const warnings = denied
|
|
106
|
-
? ['读取异步作业需要「读取业务数据」(data.read);本环境的排队 / 挂起 / 失败统计不可用,显示为 —。']
|
|
107
|
-
: []
|
|
108
|
-
const failureGroups = new Map()
|
|
109
|
-
for (const row of failures) {
|
|
110
|
-
const name = String(row.name ?? '')
|
|
111
|
-
const key = `${name}|${row.errorcode ?? ''}`
|
|
112
|
-
const group = failureGroups.get(key) ?? {
|
|
113
|
-
name,
|
|
114
|
-
message: String(row.message ?? ''),
|
|
115
|
-
operationtype: Number(row.operationtype ?? 0),
|
|
116
|
-
errorcode: Number(row.errorcode ?? 0),
|
|
117
|
-
count: 0,
|
|
118
|
-
modifiedon: String(row.modifiedon ?? ''),
|
|
119
|
-
}
|
|
120
|
-
group.count += 1
|
|
121
|
-
failureGroups.set(key, group)
|
|
122
|
-
}
|
|
123
|
-
const running = await listRunningSolutions(client, signal).catch(() => [])
|
|
124
|
-
const recent = await recentSolutions(client, signal).catch(() => [])
|
|
125
|
-
// Without this the most common reason a trace table is empty ("nobody turned
|
|
126
|
-
// plug-in tracing on") is invisible: the tool reports "0 logs" and stops.
|
|
127
|
-
const traceSetting = Number(organization?.plugintracelogsetting)
|
|
128
|
-
|
|
129
|
-
return {
|
|
130
|
-
env: envKey,
|
|
131
|
-
url: env.url ?? '',
|
|
132
|
-
version: version ?? '',
|
|
133
|
-
organization: organization?.name ?? '',
|
|
134
|
-
traceLevel: Number.isFinite(traceSetting) ? traceSetting : -1,
|
|
135
|
-
queued: queued.count ?? 0,
|
|
136
|
-
suspended: suspended.count ?? 0,
|
|
137
|
-
failed: failed.count ?? 0,
|
|
138
|
-
denied,
|
|
139
|
-
warnings,
|
|
140
|
-
hours,
|
|
141
|
-
failures: [...failureGroups.values()].map((row) => ({
|
|
142
|
-
name: row.name,
|
|
143
|
-
message: row.message,
|
|
144
|
-
operationtype: row.operationtype,
|
|
145
|
-
modifiedon: row.modifiedon,
|
|
146
|
-
count: row.count,
|
|
147
|
-
errorcode: row.errorcode,
|
|
148
|
-
})),
|
|
149
|
-
runningSolutions: running.map((row) => ({
|
|
150
|
-
name: String(row.msdyn_name ?? ''),
|
|
151
|
-
operation: Number(row.msdyn_operation ?? 0),
|
|
152
|
-
startedAt: String(row.msdyn_starttime ?? ''),
|
|
153
|
-
managed: row.msdyn_ismanaged === true,
|
|
154
|
-
})),
|
|
155
|
-
recentSolutions: recent.map((row) => ({
|
|
156
|
-
uniquename: String(row.uniquename ?? ''),
|
|
157
|
-
version: String(row.version ?? ''),
|
|
158
|
-
managed: row.ismanaged === true,
|
|
159
|
-
installedon: String(row.installedon ?? ''),
|
|
160
|
-
})),
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
const TRACE_LEVELS = { 0: '关闭', 1: '仅异常', 2: '全部' }
|
|
165
|
-
|
|
166
|
-
/** @returns a readable label for the organization's plug-in trace setting. */
|
|
167
|
-
export function traceLabel(level) {
|
|
168
|
-
if (level === null || level === undefined || level < 0) return '—'
|
|
169
|
-
return TRACE_LEVELS[level] ?? `未知(${level})`
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
export function renderHealth(rows) {
|
|
173
|
-
const lines = []
|
|
174
|
-
lines.push('环境健康检查')
|
|
175
|
-
lines.push('')
|
|
176
|
-
lines.push('| 环境 | 版本 | 组织 | 排队中 | 挂起作业 | 近期失败 | 正在导入/发布 | 插件跟踪 |')
|
|
177
|
-
lines.push('| --- | --- | --- | --- | --- | --- | --- | --- |')
|
|
178
|
-
for (const row of rows) {
|
|
179
|
-
const show = (value) => (row.denied ? '—' : (value ?? '—'))
|
|
180
|
-
lines.push(`| ${row.env} | ${row.version ?? '—'} | ${row.organization ?? '—'} | ${show(row.queued)} | ${show(row.suspended)} | ${show(row.failed)} | ${row.runningSolutions.length} | ${traceLabel(row.traceLevel)} |`)
|
|
181
|
-
}
|
|
182
|
-
for (const row of rows) {
|
|
183
|
-
for (const warning of row.warnings ?? []) lines.push(`- ${row.env}:${warning}`)
|
|
184
|
-
}
|
|
185
|
-
lines.push('')
|
|
186
|
-
lines.push('排队中 = Ready(statecode 0);挂起 = Suspended(statecode 1);近期失败 = Completed 且 Failed(statuscode 31),Succeeded/Canceled 不计。')
|
|
187
|
-
lines.push('插件跟踪 = 组织的 Plug-in Trace Log 设置:关闭时插件异常只写系统作业、不写跟踪日志,查不到日志先看这一列。')
|
|
188
|
-
for (const row of rows) {
|
|
189
|
-
if (!row.runningSolutions.length && !row.failures.length) continue
|
|
190
|
-
lines.push(`### ${row.env}`)
|
|
191
|
-
if (row.runningSolutions.length) {
|
|
192
|
-
lines.push(`- 正在运行:${row.runningSolutions.map((item) => `${item.name}(${item.operation})`).join('、')}`)
|
|
193
|
-
}
|
|
194
|
-
for (const failure of row.failures.slice(0, 5)) {
|
|
195
|
-
const times = failure.count > 1 ? ` ×${failure.count}` : ''
|
|
196
|
-
const code = failure.errorcode ? `(code ${failure.errorcode})` : ''
|
|
197
|
-
// `message` is often empty on async operations; a trailing dash reads as a
|
|
198
|
-
// rendering bug, so only add the separator when there is text to show.
|
|
199
|
-
const detail = String(failure.message ?? '').trim()
|
|
200
|
-
lines.push(`- 失败${times}:${failure.name}${code}${detail ? ` — ${detail.slice(0, 160)}` : ''}`)
|
|
201
|
-
}
|
|
202
|
-
lines.push('')
|
|
203
|
-
}
|
|
204
|
-
return [{ type: 'text', text: lines.join('\n') }]
|
|
205
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Environment health: what is running right now, what failed recently, and how
|
|
3
|
+
* busy the org looks. Meant to answer "is it safe to publish?" before a release.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { D365Error } from './errors.js'
|
|
7
|
+
import { makeClient, readOrganization } from './runtime.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `asyncoperation.statecode`: 0 Ready (queued), 1 Suspended, 2 Locked,
|
|
11
|
+
* 3 Completed. Completed is *not* failure: `statuscode` splits it into
|
|
12
|
+
* 30 Succeeded / 31 Failed / 32 Canceled, so "failed" needs both codes.
|
|
13
|
+
*/
|
|
14
|
+
const READY_STATE = 0
|
|
15
|
+
const SUSPENDED_STATE = 1
|
|
16
|
+
const COMPLETED_STATE = 3
|
|
17
|
+
const FAILED_STATUS = 31
|
|
18
|
+
|
|
19
|
+
const FAILED_FILTER = `statecode eq ${COMPLETED_STATE} and statuscode eq ${FAILED_STATUS}`
|
|
20
|
+
|
|
21
|
+
async function count(client, path, filter, signal) {
|
|
22
|
+
// `$count=true` is capped at 5000 by Dataverse; aggregation is exact.
|
|
23
|
+
try {
|
|
24
|
+
const url = client.buildUrl(path, { $filter: filter, $apply: 'aggregate($count as count)' })
|
|
25
|
+
const page = await client.requestJson(url, { signal })
|
|
26
|
+
const value = page?.value?.[0]?.count
|
|
27
|
+
if (Number.isFinite(value)) return { count: value }
|
|
28
|
+
} catch {
|
|
29
|
+
// fall through to the simpler read
|
|
30
|
+
}
|
|
31
|
+
const url = client.buildUrl(path, { $filter: filter, $select: 'asyncoperationid', $top: 1, $count: 'true' })
|
|
32
|
+
const page = await client.requestJson(url, { signal })
|
|
33
|
+
return { count: page?.['@odata.count'] ?? page?.value?.length ?? 0, rows: page?.value ?? [] }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function listFailed(client, sinceIso, signal) {
|
|
37
|
+
const rows = []
|
|
38
|
+
let next = client.buildUrl('asyncoperations', {
|
|
39
|
+
$select: 'asyncoperationid,name,operationtype,errorcode,message,modifiedon',
|
|
40
|
+
$filter: `${FAILED_FILTER} and modifiedon ge ${sinceIso}`,
|
|
41
|
+
$orderby: 'modifiedon desc',
|
|
42
|
+
$top: 50,
|
|
43
|
+
})
|
|
44
|
+
let pages = 0
|
|
45
|
+
while (next && pages < 3) {
|
|
46
|
+
const page = await client.requestJson(next, { signal })
|
|
47
|
+
rows.push(...(page?.value ?? []))
|
|
48
|
+
next = page?.['@odata.nextLink'] ?? null
|
|
49
|
+
pages += 1
|
|
50
|
+
}
|
|
51
|
+
return rows
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function listRunningSolutions(client, signal) {
|
|
55
|
+
const url = client.buildUrl('msdyn_solutionhistories', {
|
|
56
|
+
$select: 'msdyn_name,msdyn_operation,msdyn_starttime,msdyn_endtime,msdyn_ismanaged',
|
|
57
|
+
$filter: 'msdyn_endtime eq null',
|
|
58
|
+
$orderby: 'msdyn_starttime desc',
|
|
59
|
+
$top: 10,
|
|
60
|
+
})
|
|
61
|
+
const page = await client.requestJson(url, { signal })
|
|
62
|
+
return page?.value ?? []
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function recentSolutions(client, signal) {
|
|
66
|
+
const url = client.buildUrl('solutions', {
|
|
67
|
+
$select: 'uniquename,friendlyname,version,ismanaged,installedon',
|
|
68
|
+
$orderby: 'installedon desc',
|
|
69
|
+
$top: 5,
|
|
70
|
+
})
|
|
71
|
+
const page = await client.requestJson(url, { signal })
|
|
72
|
+
return page?.value ?? []
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Collect one environment's health snapshot. */
|
|
76
|
+
export async function readHealth(config, envKey, options = {}) {
|
|
77
|
+
const env = config.environments[envKey]
|
|
78
|
+
if (!env) throw new D365Error(`未知环境:${envKey}`, { hint: `可用:${Object.keys(config.environments).join(', ')}` })
|
|
79
|
+
const hours = Number.isFinite(options.hours) ? options.hours : 24
|
|
80
|
+
const sinceIso = new Date(Date.now() - hours * 3600 * 1000).toISOString()
|
|
81
|
+
const client = makeClient(env, config)
|
|
82
|
+
const signal = options.signal
|
|
83
|
+
|
|
84
|
+
const version = await client.readVersion().catch(() => null)
|
|
85
|
+
const organization = await readOrganization(client, signal).catch(() => null)
|
|
86
|
+
// A swallowed permission error must not read as "0 failures": this tool is
|
|
87
|
+
// asked precisely when something is wrong, and a false all-clear is worse
|
|
88
|
+
// than a visible gap. Track whether the async-job reads were refused.
|
|
89
|
+
const refused = (error) => error?.code === 'data-read-forbidden' || error?.status === 401 || error?.status === 403
|
|
90
|
+
const jobErrors = []
|
|
91
|
+
const jobCount = (filter) => count(client, 'asyncoperations', filter, signal)
|
|
92
|
+
.then((value) => ({ ...value, error: null }))
|
|
93
|
+
.catch((error) => {
|
|
94
|
+
jobErrors.push(error)
|
|
95
|
+
return { count: null, error }
|
|
96
|
+
})
|
|
97
|
+
const queued = await jobCount(`statecode eq ${READY_STATE}`)
|
|
98
|
+
const suspended = await jobCount(`statecode eq ${SUSPENDED_STATE}`)
|
|
99
|
+
const failed = await jobCount(`${FAILED_FILTER} and modifiedon ge ${sinceIso}`)
|
|
100
|
+
const failures = await listFailed(client, sinceIso, signal).catch((error) => {
|
|
101
|
+
jobErrors.push(error)
|
|
102
|
+
return []
|
|
103
|
+
})
|
|
104
|
+
const denied = jobErrors.some(refused)
|
|
105
|
+
const warnings = denied
|
|
106
|
+
? ['读取异步作业需要「读取业务数据」(data.read);本环境的排队 / 挂起 / 失败统计不可用,显示为 —。']
|
|
107
|
+
: []
|
|
108
|
+
const failureGroups = new Map()
|
|
109
|
+
for (const row of failures) {
|
|
110
|
+
const name = String(row.name ?? '')
|
|
111
|
+
const key = `${name}|${row.errorcode ?? ''}`
|
|
112
|
+
const group = failureGroups.get(key) ?? {
|
|
113
|
+
name,
|
|
114
|
+
message: String(row.message ?? ''),
|
|
115
|
+
operationtype: Number(row.operationtype ?? 0),
|
|
116
|
+
errorcode: Number(row.errorcode ?? 0),
|
|
117
|
+
count: 0,
|
|
118
|
+
modifiedon: String(row.modifiedon ?? ''),
|
|
119
|
+
}
|
|
120
|
+
group.count += 1
|
|
121
|
+
failureGroups.set(key, group)
|
|
122
|
+
}
|
|
123
|
+
const running = await listRunningSolutions(client, signal).catch(() => [])
|
|
124
|
+
const recent = await recentSolutions(client, signal).catch(() => [])
|
|
125
|
+
// Without this the most common reason a trace table is empty ("nobody turned
|
|
126
|
+
// plug-in tracing on") is invisible: the tool reports "0 logs" and stops.
|
|
127
|
+
const traceSetting = Number(organization?.plugintracelogsetting)
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
env: envKey,
|
|
131
|
+
url: env.url ?? '',
|
|
132
|
+
version: version ?? '',
|
|
133
|
+
organization: organization?.name ?? '',
|
|
134
|
+
traceLevel: Number.isFinite(traceSetting) ? traceSetting : -1,
|
|
135
|
+
queued: queued.count ?? 0,
|
|
136
|
+
suspended: suspended.count ?? 0,
|
|
137
|
+
failed: failed.count ?? 0,
|
|
138
|
+
denied,
|
|
139
|
+
warnings,
|
|
140
|
+
hours,
|
|
141
|
+
failures: [...failureGroups.values()].map((row) => ({
|
|
142
|
+
name: row.name,
|
|
143
|
+
message: row.message,
|
|
144
|
+
operationtype: row.operationtype,
|
|
145
|
+
modifiedon: row.modifiedon,
|
|
146
|
+
count: row.count,
|
|
147
|
+
errorcode: row.errorcode,
|
|
148
|
+
})),
|
|
149
|
+
runningSolutions: running.map((row) => ({
|
|
150
|
+
name: String(row.msdyn_name ?? ''),
|
|
151
|
+
operation: Number(row.msdyn_operation ?? 0),
|
|
152
|
+
startedAt: String(row.msdyn_starttime ?? ''),
|
|
153
|
+
managed: row.msdyn_ismanaged === true,
|
|
154
|
+
})),
|
|
155
|
+
recentSolutions: recent.map((row) => ({
|
|
156
|
+
uniquename: String(row.uniquename ?? ''),
|
|
157
|
+
version: String(row.version ?? ''),
|
|
158
|
+
managed: row.ismanaged === true,
|
|
159
|
+
installedon: String(row.installedon ?? ''),
|
|
160
|
+
})),
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const TRACE_LEVELS = { 0: '关闭', 1: '仅异常', 2: '全部' }
|
|
165
|
+
|
|
166
|
+
/** @returns a readable label for the organization's plug-in trace setting. */
|
|
167
|
+
export function traceLabel(level) {
|
|
168
|
+
if (level === null || level === undefined || level < 0) return '—'
|
|
169
|
+
return TRACE_LEVELS[level] ?? `未知(${level})`
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function renderHealth(rows) {
|
|
173
|
+
const lines = []
|
|
174
|
+
lines.push('环境健康检查')
|
|
175
|
+
lines.push('')
|
|
176
|
+
lines.push('| 环境 | 版本 | 组织 | 排队中 | 挂起作业 | 近期失败 | 正在导入/发布 | 插件跟踪 |')
|
|
177
|
+
lines.push('| --- | --- | --- | --- | --- | --- | --- | --- |')
|
|
178
|
+
for (const row of rows) {
|
|
179
|
+
const show = (value) => (row.denied ? '—' : (value ?? '—'))
|
|
180
|
+
lines.push(`| ${row.env} | ${row.version ?? '—'} | ${row.organization ?? '—'} | ${show(row.queued)} | ${show(row.suspended)} | ${show(row.failed)} | ${row.runningSolutions.length} | ${traceLabel(row.traceLevel)} |`)
|
|
181
|
+
}
|
|
182
|
+
for (const row of rows) {
|
|
183
|
+
for (const warning of row.warnings ?? []) lines.push(`- ${row.env}:${warning}`)
|
|
184
|
+
}
|
|
185
|
+
lines.push('')
|
|
186
|
+
lines.push('排队中 = Ready(statecode 0);挂起 = Suspended(statecode 1);近期失败 = Completed 且 Failed(statuscode 31),Succeeded/Canceled 不计。')
|
|
187
|
+
lines.push('插件跟踪 = 组织的 Plug-in Trace Log 设置:关闭时插件异常只写系统作业、不写跟踪日志,查不到日志先看这一列。')
|
|
188
|
+
for (const row of rows) {
|
|
189
|
+
if (!row.runningSolutions.length && !row.failures.length) continue
|
|
190
|
+
lines.push(`### ${row.env}`)
|
|
191
|
+
if (row.runningSolutions.length) {
|
|
192
|
+
lines.push(`- 正在运行:${row.runningSolutions.map((item) => `${item.name}(${item.operation})`).join('、')}`)
|
|
193
|
+
}
|
|
194
|
+
for (const failure of row.failures.slice(0, 5)) {
|
|
195
|
+
const times = failure.count > 1 ? ` ×${failure.count}` : ''
|
|
196
|
+
const code = failure.errorcode ? `(code ${failure.errorcode})` : ''
|
|
197
|
+
// `message` is often empty on async operations; a trailing dash reads as a
|
|
198
|
+
// rendering bug, so only add the separator when there is text to show.
|
|
199
|
+
const detail = String(failure.message ?? '').trim()
|
|
200
|
+
lines.push(`- 失败${times}:${failure.name}${code}${detail ? ` — ${detail.slice(0, 160)}` : ''}`)
|
|
201
|
+
}
|
|
202
|
+
lines.push('')
|
|
203
|
+
}
|
|
204
|
+
return [{ type: 'text', text: lines.join('\n') }]
|
|
205
|
+
}
|