dsh-api-runner 0.2.0 → 0.2.1
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 +14 -2
- package/client/client.js +18 -2
- package/package.json +1 -1
- package/src/ops.js +1268 -1257
- package/src/project-store.js +350 -334
- package/test/smoke.mjs +114 -0
package/src/ops.js
CHANGED
|
@@ -1,1257 +1,1268 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 业务编排层:工具与 HTTP API 共用同一套操作实现。
|
|
3
|
-
* 所有函数返回可直接 JSON 化的普通对象,错误以 {ok:false,...} 形式返回而非抛出。
|
|
4
|
-
*/
|
|
5
|
-
import fs from 'node:fs';
|
|
6
|
-
import path from 'node:path';
|
|
7
|
-
import { ConfigStore } from './config.js';
|
|
8
|
-
import { callApi, pickPath } from './api.js';
|
|
9
|
-
import { JavaRunner } from './java-runner.js';
|
|
10
|
-
import { discoverLogs, listJavaProcesses, readFileChunk, readFileTail } from './java.js';
|
|
11
|
-
import { LogReader } from './log.js';
|
|
12
|
-
import { javaKeyOf } from './project-store.js';
|
|
13
|
-
import { ServiceManager } from './service.js';
|
|
14
|
-
import { expandDeep, isPlainObject, maskSecret, normalizeProjectKey, redactHeaders, truncateText, tryJson } from './util.js';
|
|
15
|
-
|
|
16
|
-
export class ApiRunnerService {
|
|
17
|
-
/**
|
|
18
|
-
* @param {{homeDir:string, configPath?:string, projectStore?:import('./project-store.js').ProjectStore}} options
|
|
19
|
-
*/
|
|
20
|
-
constructor(options = {}) {
|
|
21
|
-
this.homeDir = options.homeDir;
|
|
22
|
-
this.statePath = path.join(this.homeDir, 'api-runner-state.json');
|
|
23
|
-
this.configStore = new ConfigStore({ homeDir: this.homeDir, configPath: options.configPath });
|
|
24
|
-
this.logReader = new LogReader({ statePath: this.statePath });
|
|
25
|
-
this.serviceManager = new ServiceManager({ statePath: this.statePath });
|
|
26
|
-
/** 侧边栏面板维护的配置(存在时作为最高优先覆盖层) */
|
|
27
|
-
this.projectStore = options.projectStore;
|
|
28
|
-
/** 由插件接管的 Java 服务(IDEA 起的进程关闭后由这里重新拉起并记账) */
|
|
29
|
-
this.settingsStore = options.settingsStore;
|
|
30
|
-
this.javaRunner = options.javaRunner || new JavaRunner({ homeDir: this.homeDir, settingsStore: this.settingsStore });
|
|
31
|
-
/** 记录最近一次错误,便于排查 */
|
|
32
|
-
this.lastError = '';
|
|
33
|
-
/**
|
|
34
|
-
* 一键启动的后台批次:`{ [batchId]: { id, cwd, startedAt, done, total, items, finished, result } }`。
|
|
35
|
-
*
|
|
36
|
-
* 为什么放后台跑:一批服务要逐个 mvn 编译,几个服务就是几分钟,
|
|
37
|
-
* 同步 HTTP 请求会一直挂着(浏览器和 fetch 都可能超时),面板也没法显示进度。
|
|
38
|
-
* 批次只存内存:DSH 重启后丢批次记录没关系,进程本身已经起来了。
|
|
39
|
-
*/
|
|
40
|
-
this.startBatches = new Map();
|
|
41
|
-
this._batchSeq = 0;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** 面板配置转成 YAML 同构的覆盖层;没有面板存储时返回 undefined */
|
|
45
|
-
uiOverlay(cwd) {
|
|
46
|
-
if (!this.projectStore) return undefined;
|
|
47
|
-
const project = this.projectStore.project(cwd);
|
|
48
|
-
if (project.apis.length === 0) return undefined;
|
|
49
|
-
// 面板里没填 baseUrl 的接口,统一回落到项目级 baseUrl(面板顶部那一个输入框)
|
|
50
|
-
const apis = project.apis.map((api) => ({
|
|
51
|
-
...api,
|
|
52
|
-
baseUrl: api.baseUrl || project.baseUrl || '',
|
|
53
|
-
source: 'ui',
|
|
54
|
-
}));
|
|
55
|
-
return { apis };
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** 读取配置(每次重读,支持热改) */
|
|
59
|
-
config(cwd = '') {
|
|
60
|
-
return this.configStore.load(cwd, this.uiOverlay(cwd));
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** 配置概览:只给模型必要信息,不泄漏 headers 具体值 */
|
|
64
|
-
describe(cwd = '') {
|
|
65
|
-
const { config, sources, warnings } = this.config(cwd);
|
|
66
|
-
const tokens = this.projectStore ? this.projectStore.tokenStatus(cwd) : {};
|
|
67
|
-
return {
|
|
68
|
-
ok: true,
|
|
69
|
-
sources,
|
|
70
|
-
warnings,
|
|
71
|
-
apis: config.apis.map((api) => {
|
|
72
|
-
const tokenId = api.login ? '' : api.useToken || (Object.keys(tokens).length === 1 ? Object.keys(tokens)[0] : '');
|
|
73
|
-
return {
|
|
74
|
-
id: api.id,
|
|
75
|
-
name: api.name,
|
|
76
|
-
method: api.method,
|
|
77
|
-
url: displayUrl(api),
|
|
78
|
-
description: api.description,
|
|
79
|
-
isLogin: Boolean(api.login),
|
|
80
|
-
/** 调用时会自动带的 token 来源;空表示不带 */
|
|
81
|
-
tokenFrom: tokenId,
|
|
82
|
-
params: collectParams(api),
|
|
83
|
-
};
|
|
84
|
-
}),
|
|
85
|
-
tokens,
|
|
86
|
-
services: config.services.map((service) => ({
|
|
87
|
-
id: service.id,
|
|
88
|
-
name: service.name,
|
|
89
|
-
mode: service.mode,
|
|
90
|
-
cwd: service.cwd,
|
|
91
|
-
command: [service.command, ...service.args].filter(Boolean).join(' '),
|
|
92
|
-
health: service.health.url || (service.health.ports.length > 0 ? `tcp:${service.health.ports.join(',')}` : ''),
|
|
93
|
-
})),
|
|
94
|
-
logs: config.logs.map((log) => ({
|
|
95
|
-
id: log.id,
|
|
96
|
-
name: log.name,
|
|
97
|
-
path: log.path,
|
|
98
|
-
exists: fileExists(log.path),
|
|
99
|
-
size: fileSize(log.path),
|
|
100
|
-
})),
|
|
101
|
-
};
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/** 调用接口 */
|
|
105
|
-
async call(cwd, apiId, params = {}, options = {}) {
|
|
106
|
-
const { config } = this.config(cwd);
|
|
107
|
-
const api = this.configStore.findApi(config, apiId);
|
|
108
|
-
if (!api) {
|
|
109
|
-
return { ok: false, code: 'API_NOT_FOUND', message: `未找到接口 ${apiId};已配置:${config.apis.map((item) => item.id).join(', ') || '(空)'}` };
|
|
110
|
-
}
|
|
111
|
-
const resolved = this.resolveApi(cwd, api, options);
|
|
112
|
-
// 模型没传参数时,用接口上配置的示例参数兜底(面板里填的那份)
|
|
113
|
-
const merged = { ...(api.sampleParams || {}), ...(isPlainObject(params) ? params : {}) };
|
|
114
|
-
const result = await callApi(resolved.api, merged, options);
|
|
115
|
-
return {
|
|
116
|
-
...result,
|
|
117
|
-
request: {
|
|
118
|
-
method: resolved.api.method,
|
|
119
|
-
url: result.url,
|
|
120
|
-
headers: redactHeaders(resolved.api.headers),
|
|
121
|
-
},
|
|
122
|
-
token: resolved.token
|
|
123
|
-
? { from: resolved.token.loginId, header: resolved.token.header, masked: maskSecret(resolved.token.value) }
|
|
124
|
-
: undefined,
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* 解析「这次调用要不要带 token」:
|
|
130
|
-
* 1. options.tokenFrom 显式指定;
|
|
131
|
-
* 2. 接口自己声明 useToken;
|
|
132
|
-
* 3. 项目里只有一个已保存 token 时自动带上(登录一次,后续接口不用配);
|
|
133
|
-
* 命中后把 token 值注入到 Authorization(或登录定义里指定的头)。
|
|
134
|
-
*/
|
|
135
|
-
resolveApi(cwd, api, options = {}) {
|
|
136
|
-
const store = this.projectStore;
|
|
137
|
-
if (!store) return { api, token: undefined };
|
|
138
|
-
const { config } = this.config(cwd);
|
|
139
|
-
|
|
140
|
-
let loginId = String(options.tokenFrom || api.useToken || '');
|
|
141
|
-
if (!loginId) {
|
|
142
|
-
const saved = store.tokenIds(cwd);
|
|
143
|
-
// 只有一个 token 时自动使用;有多个则要求接口显式声明,避免串号
|
|
144
|
-
if (saved.length === 1) loginId = saved[0];
|
|
145
|
-
}
|
|
146
|
-
if (!loginId) return { api, token: undefined };
|
|
147
|
-
|
|
148
|
-
const value = store.tokenValue(cwd, loginId);
|
|
149
|
-
if (!value) return { api, token: undefined };
|
|
150
|
-
|
|
151
|
-
// 登录定义优先取 logins 里的,其次取同 id 的接口上声明的 login
|
|
152
|
-
const loginDef = config.logins?.find?.((item) => item.id === loginId) ?? config.apis.find((item) => item.id === loginId)?.login;
|
|
153
|
-
const header = String(options.tokenHeader || loginDef?.header || 'Authorization');
|
|
154
|
-
const prefix = String(options.tokenPrefix ?? loginDef?.prefix ?? 'Bearer ');
|
|
155
|
-
const headers = { ...(api.headers || {}) };
|
|
156
|
-
// 已有同名头(大小写不敏感)时不覆盖用户手写的值
|
|
157
|
-
const existing = Object.keys(headers).find((key) => key.toLowerCase() === header.toLowerCase());
|
|
158
|
-
if (existing === undefined) headers[header] = `${prefix}${value}`;
|
|
159
|
-
|
|
160
|
-
return { api: { ...api, headers }, token: { loginId, header, value } };
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/**
|
|
164
|
-
* 登录:调接口 → 从响应里取 token → 保存。
|
|
165
|
-
* 这是「调用登录接口直接保存 token」的实现,工具和面板都走这里。
|
|
166
|
-
*/
|
|
167
|
-
async login(cwd, loginId, params = {}, options = {}) {
|
|
168
|
-
const { config } = this.config(cwd);
|
|
169
|
-
const api = this.configStore.findApi(config, loginId);
|
|
170
|
-
if (!api) return { ok: false, code: 'API_NOT_FOUND', message: `未找到登录接口 ${loginId}` };
|
|
171
|
-
const login = api.login || { tokenPath: 'token', header: 'Authorization', prefix: 'Bearer ' };
|
|
172
|
-
|
|
173
|
-
// 登录请求本身不能带旧 token,避免死循环/串号
|
|
174
|
-
const merged = { ...(api.sampleParams || {}), ...(isPlainObject(params) ? params : {}) };
|
|
175
|
-
const result = await callApi(api, merged, options);
|
|
176
|
-
if (!result.ok) {
|
|
177
|
-
return {
|
|
178
|
-
ok: false,
|
|
179
|
-
code: 'LOGIN_FAILED',
|
|
180
|
-
message: result.error || `登录接口返回 HTTP ${result.status}`,
|
|
181
|
-
status: result.status,
|
|
182
|
-
body: result.body,
|
|
183
|
-
};
|
|
184
|
-
}
|
|
185
|
-
const found = pickPath(result.json, login.tokenPath);
|
|
186
|
-
let token = typeof found === 'string' ? found : found === undefined || found === null ? '' : String(found);
|
|
187
|
-
let usedPath = login.tokenPath;
|
|
188
|
-
let autoDetected = false;
|
|
189
|
-
if (token === '') {
|
|
190
|
-
// tokenPath 没命中时,若响应里只有一个像 token 的字段,就自动用它。
|
|
191
|
-
// 这能把「配置了登录接口但不知道 token 在响应哪一层」的常见情况直接救活;
|
|
192
|
-
// 有多个候选则不猜,列给用户/模型自己选。
|
|
193
|
-
const candidates = tokenCandidates(result.json);
|
|
194
|
-
if (candidates.length === 1) {
|
|
195
|
-
const auto = pickPath(result.json, candidates[0]);
|
|
196
|
-
if (typeof auto === 'string' && auto !== '') {
|
|
197
|
-
token = auto;
|
|
198
|
-
usedPath = candidates[0];
|
|
199
|
-
autoDetected = true;
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
if (token === '') {
|
|
203
|
-
return {
|
|
204
|
-
ok: false,
|
|
205
|
-
code: 'TOKEN_NOT_FOUND',
|
|
206
|
-
message: candidates.length
|
|
207
|
-
? `登录成功(HTTP ${result.status})但响应里没有 ${login.tokenPath},且有多个候选:${candidates.join(', ')};请把 login.tokenPath 改成其中一个`
|
|
208
|
-
: `登录成功(HTTP ${result.status})但响应里没有 ${login.tokenPath};检查 login.tokenPath 配置`,
|
|
209
|
-
tokenPath: login.tokenPath,
|
|
210
|
-
candidates,
|
|
211
|
-
body: result.body,
|
|
212
|
-
};
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
if (!this.projectStore) return { ok: false, code: 'NO_STORE', message: '面板配置存储不可用' };
|
|
216
|
-
// 自动探测到正确路径时写回接口定义,避免每次登录都重复警告;
|
|
217
|
-
// 只对面板创建的接口生效(YAML 接口在文件里,插件不改用户的配置文件)。
|
|
218
|
-
if (autoDetected) {
|
|
219
|
-
const project = this.projectStore.project(cwd);
|
|
220
|
-
if (project.apis.some((item) => String(item.id) === String(api.id))) {
|
|
221
|
-
this.projectStore.setApiTokenPath(cwd, api.id, usedPath);
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
// 把登录定义一起存下来,后续调用才知道该用哪个头
|
|
225
|
-
this.projectStore.saveLogin(cwd, {
|
|
226
|
-
id: api.id,
|
|
227
|
-
name: api.name,
|
|
228
|
-
apiId: api.id,
|
|
229
|
-
tokenPath: usedPath,
|
|
230
|
-
header: login.header,
|
|
231
|
-
prefix: login.prefix,
|
|
232
|
-
});
|
|
233
|
-
this.projectStore.saveToken(cwd, api.id, token);
|
|
234
|
-
return {
|
|
235
|
-
ok: true,
|
|
236
|
-
loginId: api.id,
|
|
237
|
-
status: result.status,
|
|
238
|
-
masked: maskSecret(token),
|
|
239
|
-
tokenPath: usedPath,
|
|
240
|
-
header: login.header,
|
|
241
|
-
message: autoDetected
|
|
242
|
-
? `已保存 token(${login.header}: ${login.prefix}${maskSecret(token)})。注意:配置的 tokenPath 是 ${login.tokenPath} 但没取到值,已自动改用响应里的 ${usedPath} 并写回配置`
|
|
243
|
-
: `已保存 token(${login.header}: ${login.prefix}${maskSecret(token)}),后续调用会自动带上`,
|
|
244
|
-
};
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
/** 服务状态(单个或全部) */
|
|
248
|
-
async serviceStatus(cwd, serviceId) {
|
|
249
|
-
const { config } = this.config(cwd);
|
|
250
|
-
if (serviceId) {
|
|
251
|
-
const service = this.configStore.findService(config, serviceId);
|
|
252
|
-
if (!service) return { ok: false, code: 'SERVICE_NOT_FOUND', message: `未找到服务 ${serviceId}` };
|
|
253
|
-
const health = await this.serviceManager.check(service);
|
|
254
|
-
return { ok: true, service: service.id, mode: service.mode, healthy: health.ok, checks: health.checks };
|
|
255
|
-
}
|
|
256
|
-
const list = await this.serviceManager.statusAll(config.services);
|
|
257
|
-
return { ok: true, services: list };
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
async serviceAction(cwd, serviceId, action, options = {}) {
|
|
261
|
-
const { config } = this.config(cwd);
|
|
262
|
-
const service = this.configStore.findService(config, serviceId);
|
|
263
|
-
if (!service) return { ok: false, code: 'SERVICE_NOT_FOUND', message: `未找到服务 ${serviceId}` };
|
|
264
|
-
switch (action) {
|
|
265
|
-
case 'start':
|
|
266
|
-
return { ok: true, service: service.id, ...(await this.serviceManager.start(service, options)) };
|
|
267
|
-
case 'stop':
|
|
268
|
-
return { ok: true, service: service.id, ...(await this.serviceManager.stop(service)) };
|
|
269
|
-
case 'restart':
|
|
270
|
-
return { ok: true, service: service.id, ...(await this.serviceManager.restart(service, options)) };
|
|
271
|
-
case 'wait': {
|
|
272
|
-
const health = await this.serviceManager.waitHealthy(service, Number(options.timeoutMs || service.startupTimeoutMs));
|
|
273
|
-
return { ok: health.ok, service: service.id, waitedMs: health.waitedMs, checks: health.checks };
|
|
274
|
-
}
|
|
275
|
-
default:
|
|
276
|
-
return { ok: false, code: 'BAD_ACTION', message: `不支持的服务动作 ${action}` };
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
/** 读日志 */
|
|
281
|
-
readLog(cwd, logId, options = {}) {
|
|
282
|
-
const { config } = this.config(cwd);
|
|
283
|
-
const log = logId
|
|
284
|
-
? this.configStore.findLog(config, logId)
|
|
285
|
-
: config.logs[0];
|
|
286
|
-
if (!log) {
|
|
287
|
-
return { ok: false, code: 'LOG_NOT_FOUND', message: logId ? `未找到日志 ${logId}` : '未配置任何日志,请在 api-runner.yml 的 logs 里添加' };
|
|
288
|
-
}
|
|
289
|
-
try {
|
|
290
|
-
return this.logReader.read(log, options);
|
|
291
|
-
} catch (error) {
|
|
292
|
-
return { ok: false, code: 'LOG_READ_FAILED', message: error.message };
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
async waitLog(cwd, logId, options = {}) {
|
|
297
|
-
const { config } = this.config(cwd);
|
|
298
|
-
const log = logId ? this.configStore.findLog(config, logId) : config.logs[0];
|
|
299
|
-
if (!log) return { ok: false, code: 'LOG_NOT_FOUND', message: `未找到日志 ${logId || '(默认)'}` };
|
|
300
|
-
try {
|
|
301
|
-
return await this.logReader.waitFor(log, options);
|
|
302
|
-
} catch (error) {
|
|
303
|
-
return { ok: false, code: 'LOG_READ_FAILED', message: error.message };
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
resetLog(cwd, logId) {
|
|
308
|
-
const { config } = this.config(cwd);
|
|
309
|
-
const log = logId ? this.configStore.findLog(config, logId) : config.logs[0];
|
|
310
|
-
if (!log) return { ok: false, code: 'LOG_NOT_FOUND', message: `未找到日志 ${logId || '(默认)'}` };
|
|
311
|
-
this.logReader.resetCursor(log.id);
|
|
312
|
-
this.logReader.save();
|
|
313
|
-
return { ok: true, logId: log.id, message: '游标已重置,下次读取从文件末尾开始' };
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
/**
|
|
317
|
-
* 一键验证流程:重启服务 → 等启动日志 → 调用接口 → 读错误日志。
|
|
318
|
-
* 让模型一条命令完成"改完自测",无需手工串多个步骤。
|
|
319
|
-
*/
|
|
320
|
-
async verify(cwd, options = {}) {
|
|
321
|
-
const { config } = this.config(cwd);
|
|
322
|
-
const steps = [];
|
|
323
|
-
const service = options.service ? this.configStore.findService(config, options.service) : config.services[0];
|
|
324
|
-
|
|
325
|
-
if (service) {
|
|
326
|
-
if (options.restart !== false) {
|
|
327
|
-
const restart = await this.serviceManager.restart(service, { timeoutMs: options.startupTimeoutMs });
|
|
328
|
-
// 注意:restart 结果里自带 stage,必须先展开再覆盖,否则 stage 会变成 'done'
|
|
329
|
-
steps.push({ ...restart, stage: 'restart', service: service.id });
|
|
330
|
-
if (!restart.ok && restart.reason === 'OBSERVE_MODE') {
|
|
331
|
-
// observe 模式:进程归 IDE 管,这里只能等它自己就绪,不算失败
|
|
332
|
-
const health = await this.serviceManager.waitHealthy(service, Number(options.startupTimeoutMs || service.startupTimeoutMs));
|
|
333
|
-
steps.push({ stage: 'wait', ok: health.ok, waitedMs: health.waitedMs, checks: health.checks, note: '服务为 observe 模式,未自动重启;等待健康检查通过(如未通过请在 IDEA 里重启)' });
|
|
334
|
-
} else if (!restart.ok) {
|
|
335
|
-
return { ok: false, code: 'RESTART_FAILED', message: restart.message, steps };
|
|
336
|
-
}
|
|
337
|
-
} else {
|
|
338
|
-
steps.push({ stage: 'restart', skipped: true });
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
if (options.logId) {
|
|
343
|
-
const logOptions = {
|
|
344
|
-
pattern: options.pattern || '',
|
|
345
|
-
ignoreCase: Boolean(options.ignoreCase),
|
|
346
|
-
maxLines: options.maxLines || 200,
|
|
347
|
-
};
|
|
348
|
-
// 有 pattern 时等它出现(重启后日志可能还没刷盘),否则直接增量读一次
|
|
349
|
-
const read = options.pattern
|
|
350
|
-
? await this.waitLog(cwd, options.logId, { ...logOptions, timeoutMs: options.logWaitMs || 10_000 })
|
|
351
|
-
: this.readLog(cwd, options.logId, logOptions);
|
|
352
|
-
steps.push({ ...read, stage: 'log' });
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
if (options.apiId) {
|
|
356
|
-
const call = await this.call(cwd, options.apiId, options.params || {}, {});
|
|
357
|
-
steps.push({ stage: 'call', ...call });
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
// 顶层 ok 取所有步骤的综合结论:任一实质性步骤失败即失败。
|
|
361
|
-
// observe 模式的 restart 失败是「预期内不支持」(进程由 IDE 管),不算失败。
|
|
362
|
-
const failed = steps.filter((step) => step.ok === false && step.reason !== 'OBSERVE_MODE');
|
|
363
|
-
if (failed.length > 0) {
|
|
364
|
-
const first = failed[0];
|
|
365
|
-
return { ok: false, code: `STEP_FAILED:${first.stage}`, message: describeFailure(first), steps };
|
|
366
|
-
}
|
|
367
|
-
return { ok: true, steps };
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
// ---- 侧边栏面板用:配置读写 ----
|
|
371
|
-
/**
|
|
372
|
-
* 面板初始数据:YAML 配置 + 面板配置 + token 状态 + 项目 key
|
|
373
|
-
*
|
|
374
|
-
* options.project 是「接口」页二级页签里选中的项目(ProjectStore 的 key)。
|
|
375
|
-
* 给了它就整个切到那个项目:接口、token、baseUrl 都取自它,保存/调用也写它。
|
|
376
|
-
* 不给就是会话当前工作区。
|
|
377
|
-
*/
|
|
378
|
-
async uiState(cwd = '', options = {}) {
|
|
379
|
-
const selected = options && typeof options.project === 'string' ? options.project : '';
|
|
380
|
-
const target = selected || cwd;
|
|
381
|
-
// 会话工作区单独传(切了项目之后 cwd 已经是目标项目,不能再用它推「当前工作区」)
|
|
382
|
-
const sessionCwd = options && typeof options.sessionCwd === 'string' ? options.sessionCwd : cwd;
|
|
383
|
-
const { config, sources, warnings } = this.config(target);
|
|
384
|
-
const project = this.projectStore ? this.projectStore.project(target) : { key: 'default', apis: [], logins: [], tokens: {}, bindings: [] };
|
|
385
|
-
const rawById = new Map(project.apis.map((item) => [String(item.id), item]));
|
|
386
|
-
return {
|
|
387
|
-
ok: true,
|
|
388
|
-
projectKey: project.key,
|
|
389
|
-
/** 会话工作区自己的 key:面板用它把那一档标成「当前工作区」,而不是靠猜 */
|
|
390
|
-
cwdProjectKey: this.projectStore ? this.projectStore.keyOf(sessionCwd) : 'default',
|
|
391
|
-
sources,
|
|
392
|
-
warnings,
|
|
393
|
-
baseUrl: project.baseUrl || '',
|
|
394
|
-
apis: config.apis.map((api) => {
|
|
395
|
-
const raw = rawById.get(api.id);
|
|
396
|
-
const tokenId = api.login ? '' : api.useToken || (Object.keys(project.tokens).length === 1 ? Object.keys(project.tokens)[0] : '');
|
|
397
|
-
return {
|
|
398
|
-
id: api.id,
|
|
399
|
-
name: api.name,
|
|
400
|
-
method: api.method,
|
|
401
|
-
url: displayUrl(api),
|
|
402
|
-
baseUrl: raw?.baseUrl || project.baseUrl || api.baseUrl || '',
|
|
403
|
-
description: api.description,
|
|
404
|
-
isLogin: Boolean(api.login),
|
|
405
|
-
tokenPath: api.login?.tokenPath || '',
|
|
406
|
-
header: api.login?.header || '',
|
|
407
|
-
prefix: api.login?.prefix ?? '',
|
|
408
|
-
useToken: api.useToken || '',
|
|
409
|
-
/** 面板表单字段:面板自己存的原文优先,YAML 接口则从规整后的值反推 */
|
|
410
|
-
headersText: raw?.headersText ?? (Object.keys(api.headers || {}).length > 0 ? JSON.stringify(api.headers, null, 2) : ''),
|
|
411
|
-
bodyText: raw?.bodyText ?? (api.body === undefined ? '' : JSON.stringify(api.body, null, 2)),
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
this.projectStore
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
if (!this.projectStore) return { ok: false, code: 'NO_STORE', message: '面板配置存储不可用' };
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
*
|
|
740
|
-
*
|
|
741
|
-
*
|
|
742
|
-
*
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
compile:
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
});
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
if (
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
}
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
})
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
};
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
return { ok:
|
|
938
|
-
}
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
}
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
}
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
)
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
const
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
if (run) {
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
//
|
|
1086
|
-
|
|
1087
|
-
const
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
this.projectStore
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
}
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
}
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
}
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
}
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
}
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
}
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
}
|
|
1246
|
-
|
|
1247
|
-
/**
|
|
1248
|
-
export function
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1
|
+
/**
|
|
2
|
+
* 业务编排层:工具与 HTTP API 共用同一套操作实现。
|
|
3
|
+
* 所有函数返回可直接 JSON 化的普通对象,错误以 {ok:false,...} 形式返回而非抛出。
|
|
4
|
+
*/
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { ConfigStore } from './config.js';
|
|
8
|
+
import { callApi, pickPath } from './api.js';
|
|
9
|
+
import { JavaRunner } from './java-runner.js';
|
|
10
|
+
import { discoverLogs, listJavaProcesses, readFileChunk, readFileTail } from './java.js';
|
|
11
|
+
import { LogReader } from './log.js';
|
|
12
|
+
import { javaKeyOf } from './project-store.js';
|
|
13
|
+
import { ServiceManager } from './service.js';
|
|
14
|
+
import { expandDeep, isPlainObject, maskSecret, normalizeProjectKey, redactHeaders, truncateText, tryJson } from './util.js';
|
|
15
|
+
|
|
16
|
+
export class ApiRunnerService {
|
|
17
|
+
/**
|
|
18
|
+
* @param {{homeDir:string, configPath?:string, projectStore?:import('./project-store.js').ProjectStore}} options
|
|
19
|
+
*/
|
|
20
|
+
constructor(options = {}) {
|
|
21
|
+
this.homeDir = options.homeDir;
|
|
22
|
+
this.statePath = path.join(this.homeDir, 'api-runner-state.json');
|
|
23
|
+
this.configStore = new ConfigStore({ homeDir: this.homeDir, configPath: options.configPath });
|
|
24
|
+
this.logReader = new LogReader({ statePath: this.statePath });
|
|
25
|
+
this.serviceManager = new ServiceManager({ statePath: this.statePath });
|
|
26
|
+
/** 侧边栏面板维护的配置(存在时作为最高优先覆盖层) */
|
|
27
|
+
this.projectStore = options.projectStore;
|
|
28
|
+
/** 由插件接管的 Java 服务(IDEA 起的进程关闭后由这里重新拉起并记账) */
|
|
29
|
+
this.settingsStore = options.settingsStore;
|
|
30
|
+
this.javaRunner = options.javaRunner || new JavaRunner({ homeDir: this.homeDir, settingsStore: this.settingsStore });
|
|
31
|
+
/** 记录最近一次错误,便于排查 */
|
|
32
|
+
this.lastError = '';
|
|
33
|
+
/**
|
|
34
|
+
* 一键启动的后台批次:`{ [batchId]: { id, cwd, startedAt, done, total, items, finished, result } }`。
|
|
35
|
+
*
|
|
36
|
+
* 为什么放后台跑:一批服务要逐个 mvn 编译,几个服务就是几分钟,
|
|
37
|
+
* 同步 HTTP 请求会一直挂着(浏览器和 fetch 都可能超时),面板也没法显示进度。
|
|
38
|
+
* 批次只存内存:DSH 重启后丢批次记录没关系,进程本身已经起来了。
|
|
39
|
+
*/
|
|
40
|
+
this.startBatches = new Map();
|
|
41
|
+
this._batchSeq = 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 面板配置转成 YAML 同构的覆盖层;没有面板存储时返回 undefined */
|
|
45
|
+
uiOverlay(cwd) {
|
|
46
|
+
if (!this.projectStore) return undefined;
|
|
47
|
+
const project = this.projectStore.project(cwd);
|
|
48
|
+
if (project.apis.length === 0) return undefined;
|
|
49
|
+
// 面板里没填 baseUrl 的接口,统一回落到项目级 baseUrl(面板顶部那一个输入框)
|
|
50
|
+
const apis = project.apis.map((api) => ({
|
|
51
|
+
...api,
|
|
52
|
+
baseUrl: api.baseUrl || project.baseUrl || '',
|
|
53
|
+
source: 'ui',
|
|
54
|
+
}));
|
|
55
|
+
return { apis };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 读取配置(每次重读,支持热改) */
|
|
59
|
+
config(cwd = '') {
|
|
60
|
+
return this.configStore.load(cwd, this.uiOverlay(cwd));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 配置概览:只给模型必要信息,不泄漏 headers 具体值 */
|
|
64
|
+
describe(cwd = '') {
|
|
65
|
+
const { config, sources, warnings } = this.config(cwd);
|
|
66
|
+
const tokens = this.projectStore ? this.projectStore.tokenStatus(cwd) : {};
|
|
67
|
+
return {
|
|
68
|
+
ok: true,
|
|
69
|
+
sources,
|
|
70
|
+
warnings,
|
|
71
|
+
apis: config.apis.map((api) => {
|
|
72
|
+
const tokenId = api.login ? '' : api.useToken || (Object.keys(tokens).length === 1 ? Object.keys(tokens)[0] : '');
|
|
73
|
+
return {
|
|
74
|
+
id: api.id,
|
|
75
|
+
name: api.name,
|
|
76
|
+
method: api.method,
|
|
77
|
+
url: displayUrl(api),
|
|
78
|
+
description: api.description,
|
|
79
|
+
isLogin: Boolean(api.login),
|
|
80
|
+
/** 调用时会自动带的 token 来源;空表示不带 */
|
|
81
|
+
tokenFrom: tokenId,
|
|
82
|
+
params: collectParams(api),
|
|
83
|
+
};
|
|
84
|
+
}),
|
|
85
|
+
tokens,
|
|
86
|
+
services: config.services.map((service) => ({
|
|
87
|
+
id: service.id,
|
|
88
|
+
name: service.name,
|
|
89
|
+
mode: service.mode,
|
|
90
|
+
cwd: service.cwd,
|
|
91
|
+
command: [service.command, ...service.args].filter(Boolean).join(' '),
|
|
92
|
+
health: service.health.url || (service.health.ports.length > 0 ? `tcp:${service.health.ports.join(',')}` : ''),
|
|
93
|
+
})),
|
|
94
|
+
logs: config.logs.map((log) => ({
|
|
95
|
+
id: log.id,
|
|
96
|
+
name: log.name,
|
|
97
|
+
path: log.path,
|
|
98
|
+
exists: fileExists(log.path),
|
|
99
|
+
size: fileSize(log.path),
|
|
100
|
+
})),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 调用接口 */
|
|
105
|
+
async call(cwd, apiId, params = {}, options = {}) {
|
|
106
|
+
const { config } = this.config(cwd);
|
|
107
|
+
const api = this.configStore.findApi(config, apiId);
|
|
108
|
+
if (!api) {
|
|
109
|
+
return { ok: false, code: 'API_NOT_FOUND', message: `未找到接口 ${apiId};已配置:${config.apis.map((item) => item.id).join(', ') || '(空)'}` };
|
|
110
|
+
}
|
|
111
|
+
const resolved = this.resolveApi(cwd, api, options);
|
|
112
|
+
// 模型没传参数时,用接口上配置的示例参数兜底(面板里填的那份)
|
|
113
|
+
const merged = { ...(api.sampleParams || {}), ...(isPlainObject(params) ? params : {}) };
|
|
114
|
+
const result = await callApi(resolved.api, merged, options);
|
|
115
|
+
return {
|
|
116
|
+
...result,
|
|
117
|
+
request: {
|
|
118
|
+
method: resolved.api.method,
|
|
119
|
+
url: result.url,
|
|
120
|
+
headers: redactHeaders(resolved.api.headers),
|
|
121
|
+
},
|
|
122
|
+
token: resolved.token
|
|
123
|
+
? { from: resolved.token.loginId, header: resolved.token.header, masked: maskSecret(resolved.token.value) }
|
|
124
|
+
: undefined,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 解析「这次调用要不要带 token」:
|
|
130
|
+
* 1. options.tokenFrom 显式指定;
|
|
131
|
+
* 2. 接口自己声明 useToken;
|
|
132
|
+
* 3. 项目里只有一个已保存 token 时自动带上(登录一次,后续接口不用配);
|
|
133
|
+
* 命中后把 token 值注入到 Authorization(或登录定义里指定的头)。
|
|
134
|
+
*/
|
|
135
|
+
resolveApi(cwd, api, options = {}) {
|
|
136
|
+
const store = this.projectStore;
|
|
137
|
+
if (!store) return { api, token: undefined };
|
|
138
|
+
const { config } = this.config(cwd);
|
|
139
|
+
|
|
140
|
+
let loginId = String(options.tokenFrom || api.useToken || '');
|
|
141
|
+
if (!loginId) {
|
|
142
|
+
const saved = store.tokenIds(cwd);
|
|
143
|
+
// 只有一个 token 时自动使用;有多个则要求接口显式声明,避免串号
|
|
144
|
+
if (saved.length === 1) loginId = saved[0];
|
|
145
|
+
}
|
|
146
|
+
if (!loginId) return { api, token: undefined };
|
|
147
|
+
|
|
148
|
+
const value = store.tokenValue(cwd, loginId);
|
|
149
|
+
if (!value) return { api, token: undefined };
|
|
150
|
+
|
|
151
|
+
// 登录定义优先取 logins 里的,其次取同 id 的接口上声明的 login
|
|
152
|
+
const loginDef = config.logins?.find?.((item) => item.id === loginId) ?? config.apis.find((item) => item.id === loginId)?.login;
|
|
153
|
+
const header = String(options.tokenHeader || loginDef?.header || 'Authorization');
|
|
154
|
+
const prefix = String(options.tokenPrefix ?? loginDef?.prefix ?? 'Bearer ');
|
|
155
|
+
const headers = { ...(api.headers || {}) };
|
|
156
|
+
// 已有同名头(大小写不敏感)时不覆盖用户手写的值
|
|
157
|
+
const existing = Object.keys(headers).find((key) => key.toLowerCase() === header.toLowerCase());
|
|
158
|
+
if (existing === undefined) headers[header] = `${prefix}${value}`;
|
|
159
|
+
|
|
160
|
+
return { api: { ...api, headers }, token: { loginId, header, value } };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* 登录:调接口 → 从响应里取 token → 保存。
|
|
165
|
+
* 这是「调用登录接口直接保存 token」的实现,工具和面板都走这里。
|
|
166
|
+
*/
|
|
167
|
+
async login(cwd, loginId, params = {}, options = {}) {
|
|
168
|
+
const { config } = this.config(cwd);
|
|
169
|
+
const api = this.configStore.findApi(config, loginId);
|
|
170
|
+
if (!api) return { ok: false, code: 'API_NOT_FOUND', message: `未找到登录接口 ${loginId}` };
|
|
171
|
+
const login = api.login || { tokenPath: 'token', header: 'Authorization', prefix: 'Bearer ' };
|
|
172
|
+
|
|
173
|
+
// 登录请求本身不能带旧 token,避免死循环/串号
|
|
174
|
+
const merged = { ...(api.sampleParams || {}), ...(isPlainObject(params) ? params : {}) };
|
|
175
|
+
const result = await callApi(api, merged, options);
|
|
176
|
+
if (!result.ok) {
|
|
177
|
+
return {
|
|
178
|
+
ok: false,
|
|
179
|
+
code: 'LOGIN_FAILED',
|
|
180
|
+
message: result.error || `登录接口返回 HTTP ${result.status}`,
|
|
181
|
+
status: result.status,
|
|
182
|
+
body: result.body,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
const found = pickPath(result.json, login.tokenPath);
|
|
186
|
+
let token = typeof found === 'string' ? found : found === undefined || found === null ? '' : String(found);
|
|
187
|
+
let usedPath = login.tokenPath;
|
|
188
|
+
let autoDetected = false;
|
|
189
|
+
if (token === '') {
|
|
190
|
+
// tokenPath 没命中时,若响应里只有一个像 token 的字段,就自动用它。
|
|
191
|
+
// 这能把「配置了登录接口但不知道 token 在响应哪一层」的常见情况直接救活;
|
|
192
|
+
// 有多个候选则不猜,列给用户/模型自己选。
|
|
193
|
+
const candidates = tokenCandidates(result.json);
|
|
194
|
+
if (candidates.length === 1) {
|
|
195
|
+
const auto = pickPath(result.json, candidates[0]);
|
|
196
|
+
if (typeof auto === 'string' && auto !== '') {
|
|
197
|
+
token = auto;
|
|
198
|
+
usedPath = candidates[0];
|
|
199
|
+
autoDetected = true;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (token === '') {
|
|
203
|
+
return {
|
|
204
|
+
ok: false,
|
|
205
|
+
code: 'TOKEN_NOT_FOUND',
|
|
206
|
+
message: candidates.length
|
|
207
|
+
? `登录成功(HTTP ${result.status})但响应里没有 ${login.tokenPath},且有多个候选:${candidates.join(', ')};请把 login.tokenPath 改成其中一个`
|
|
208
|
+
: `登录成功(HTTP ${result.status})但响应里没有 ${login.tokenPath};检查 login.tokenPath 配置`,
|
|
209
|
+
tokenPath: login.tokenPath,
|
|
210
|
+
candidates,
|
|
211
|
+
body: result.body,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (!this.projectStore) return { ok: false, code: 'NO_STORE', message: '面板配置存储不可用' };
|
|
216
|
+
// 自动探测到正确路径时写回接口定义,避免每次登录都重复警告;
|
|
217
|
+
// 只对面板创建的接口生效(YAML 接口在文件里,插件不改用户的配置文件)。
|
|
218
|
+
if (autoDetected) {
|
|
219
|
+
const project = this.projectStore.project(cwd);
|
|
220
|
+
if (project.apis.some((item) => String(item.id) === String(api.id))) {
|
|
221
|
+
this.projectStore.setApiTokenPath(cwd, api.id, usedPath);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
// 把登录定义一起存下来,后续调用才知道该用哪个头
|
|
225
|
+
this.projectStore.saveLogin(cwd, {
|
|
226
|
+
id: api.id,
|
|
227
|
+
name: api.name,
|
|
228
|
+
apiId: api.id,
|
|
229
|
+
tokenPath: usedPath,
|
|
230
|
+
header: login.header,
|
|
231
|
+
prefix: login.prefix,
|
|
232
|
+
});
|
|
233
|
+
this.projectStore.saveToken(cwd, api.id, token);
|
|
234
|
+
return {
|
|
235
|
+
ok: true,
|
|
236
|
+
loginId: api.id,
|
|
237
|
+
status: result.status,
|
|
238
|
+
masked: maskSecret(token),
|
|
239
|
+
tokenPath: usedPath,
|
|
240
|
+
header: login.header,
|
|
241
|
+
message: autoDetected
|
|
242
|
+
? `已保存 token(${login.header}: ${login.prefix}${maskSecret(token)})。注意:配置的 tokenPath 是 ${login.tokenPath} 但没取到值,已自动改用响应里的 ${usedPath} 并写回配置`
|
|
243
|
+
: `已保存 token(${login.header}: ${login.prefix}${maskSecret(token)}),后续调用会自动带上`,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** 服务状态(单个或全部) */
|
|
248
|
+
async serviceStatus(cwd, serviceId) {
|
|
249
|
+
const { config } = this.config(cwd);
|
|
250
|
+
if (serviceId) {
|
|
251
|
+
const service = this.configStore.findService(config, serviceId);
|
|
252
|
+
if (!service) return { ok: false, code: 'SERVICE_NOT_FOUND', message: `未找到服务 ${serviceId}` };
|
|
253
|
+
const health = await this.serviceManager.check(service);
|
|
254
|
+
return { ok: true, service: service.id, mode: service.mode, healthy: health.ok, checks: health.checks };
|
|
255
|
+
}
|
|
256
|
+
const list = await this.serviceManager.statusAll(config.services);
|
|
257
|
+
return { ok: true, services: list };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async serviceAction(cwd, serviceId, action, options = {}) {
|
|
261
|
+
const { config } = this.config(cwd);
|
|
262
|
+
const service = this.configStore.findService(config, serviceId);
|
|
263
|
+
if (!service) return { ok: false, code: 'SERVICE_NOT_FOUND', message: `未找到服务 ${serviceId}` };
|
|
264
|
+
switch (action) {
|
|
265
|
+
case 'start':
|
|
266
|
+
return { ok: true, service: service.id, ...(await this.serviceManager.start(service, options)) };
|
|
267
|
+
case 'stop':
|
|
268
|
+
return { ok: true, service: service.id, ...(await this.serviceManager.stop(service)) };
|
|
269
|
+
case 'restart':
|
|
270
|
+
return { ok: true, service: service.id, ...(await this.serviceManager.restart(service, options)) };
|
|
271
|
+
case 'wait': {
|
|
272
|
+
const health = await this.serviceManager.waitHealthy(service, Number(options.timeoutMs || service.startupTimeoutMs));
|
|
273
|
+
return { ok: health.ok, service: service.id, waitedMs: health.waitedMs, checks: health.checks };
|
|
274
|
+
}
|
|
275
|
+
default:
|
|
276
|
+
return { ok: false, code: 'BAD_ACTION', message: `不支持的服务动作 ${action}` };
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** 读日志 */
|
|
281
|
+
readLog(cwd, logId, options = {}) {
|
|
282
|
+
const { config } = this.config(cwd);
|
|
283
|
+
const log = logId
|
|
284
|
+
? this.configStore.findLog(config, logId)
|
|
285
|
+
: config.logs[0];
|
|
286
|
+
if (!log) {
|
|
287
|
+
return { ok: false, code: 'LOG_NOT_FOUND', message: logId ? `未找到日志 ${logId}` : '未配置任何日志,请在 api-runner.yml 的 logs 里添加' };
|
|
288
|
+
}
|
|
289
|
+
try {
|
|
290
|
+
return this.logReader.read(log, options);
|
|
291
|
+
} catch (error) {
|
|
292
|
+
return { ok: false, code: 'LOG_READ_FAILED', message: error.message };
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async waitLog(cwd, logId, options = {}) {
|
|
297
|
+
const { config } = this.config(cwd);
|
|
298
|
+
const log = logId ? this.configStore.findLog(config, logId) : config.logs[0];
|
|
299
|
+
if (!log) return { ok: false, code: 'LOG_NOT_FOUND', message: `未找到日志 ${logId || '(默认)'}` };
|
|
300
|
+
try {
|
|
301
|
+
return await this.logReader.waitFor(log, options);
|
|
302
|
+
} catch (error) {
|
|
303
|
+
return { ok: false, code: 'LOG_READ_FAILED', message: error.message };
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
resetLog(cwd, logId) {
|
|
308
|
+
const { config } = this.config(cwd);
|
|
309
|
+
const log = logId ? this.configStore.findLog(config, logId) : config.logs[0];
|
|
310
|
+
if (!log) return { ok: false, code: 'LOG_NOT_FOUND', message: `未找到日志 ${logId || '(默认)'}` };
|
|
311
|
+
this.logReader.resetCursor(log.id);
|
|
312
|
+
this.logReader.save();
|
|
313
|
+
return { ok: true, logId: log.id, message: '游标已重置,下次读取从文件末尾开始' };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* 一键验证流程:重启服务 → 等启动日志 → 调用接口 → 读错误日志。
|
|
318
|
+
* 让模型一条命令完成"改完自测",无需手工串多个步骤。
|
|
319
|
+
*/
|
|
320
|
+
async verify(cwd, options = {}) {
|
|
321
|
+
const { config } = this.config(cwd);
|
|
322
|
+
const steps = [];
|
|
323
|
+
const service = options.service ? this.configStore.findService(config, options.service) : config.services[0];
|
|
324
|
+
|
|
325
|
+
if (service) {
|
|
326
|
+
if (options.restart !== false) {
|
|
327
|
+
const restart = await this.serviceManager.restart(service, { timeoutMs: options.startupTimeoutMs });
|
|
328
|
+
// 注意:restart 结果里自带 stage,必须先展开再覆盖,否则 stage 会变成 'done'
|
|
329
|
+
steps.push({ ...restart, stage: 'restart', service: service.id });
|
|
330
|
+
if (!restart.ok && restart.reason === 'OBSERVE_MODE') {
|
|
331
|
+
// observe 模式:进程归 IDE 管,这里只能等它自己就绪,不算失败
|
|
332
|
+
const health = await this.serviceManager.waitHealthy(service, Number(options.startupTimeoutMs || service.startupTimeoutMs));
|
|
333
|
+
steps.push({ stage: 'wait', ok: health.ok, waitedMs: health.waitedMs, checks: health.checks, note: '服务为 observe 模式,未自动重启;等待健康检查通过(如未通过请在 IDEA 里重启)' });
|
|
334
|
+
} else if (!restart.ok) {
|
|
335
|
+
return { ok: false, code: 'RESTART_FAILED', message: restart.message, steps };
|
|
336
|
+
}
|
|
337
|
+
} else {
|
|
338
|
+
steps.push({ stage: 'restart', skipped: true });
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (options.logId) {
|
|
343
|
+
const logOptions = {
|
|
344
|
+
pattern: options.pattern || '',
|
|
345
|
+
ignoreCase: Boolean(options.ignoreCase),
|
|
346
|
+
maxLines: options.maxLines || 200,
|
|
347
|
+
};
|
|
348
|
+
// 有 pattern 时等它出现(重启后日志可能还没刷盘),否则直接增量读一次
|
|
349
|
+
const read = options.pattern
|
|
350
|
+
? await this.waitLog(cwd, options.logId, { ...logOptions, timeoutMs: options.logWaitMs || 10_000 })
|
|
351
|
+
: this.readLog(cwd, options.logId, logOptions);
|
|
352
|
+
steps.push({ ...read, stage: 'log' });
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (options.apiId) {
|
|
356
|
+
const call = await this.call(cwd, options.apiId, options.params || {}, {});
|
|
357
|
+
steps.push({ stage: 'call', ...call });
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// 顶层 ok 取所有步骤的综合结论:任一实质性步骤失败即失败。
|
|
361
|
+
// observe 模式的 restart 失败是「预期内不支持」(进程由 IDE 管),不算失败。
|
|
362
|
+
const failed = steps.filter((step) => step.ok === false && step.reason !== 'OBSERVE_MODE');
|
|
363
|
+
if (failed.length > 0) {
|
|
364
|
+
const first = failed[0];
|
|
365
|
+
return { ok: false, code: `STEP_FAILED:${first.stage}`, message: describeFailure(first), steps };
|
|
366
|
+
}
|
|
367
|
+
return { ok: true, steps };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ---- 侧边栏面板用:配置读写 ----
|
|
371
|
+
/**
|
|
372
|
+
* 面板初始数据:YAML 配置 + 面板配置 + token 状态 + 项目 key
|
|
373
|
+
*
|
|
374
|
+
* options.project 是「接口」页二级页签里选中的项目(ProjectStore 的 key)。
|
|
375
|
+
* 给了它就整个切到那个项目:接口、token、baseUrl 都取自它,保存/调用也写它。
|
|
376
|
+
* 不给就是会话当前工作区。
|
|
377
|
+
*/
|
|
378
|
+
async uiState(cwd = '', options = {}) {
|
|
379
|
+
const selected = options && typeof options.project === 'string' ? options.project : '';
|
|
380
|
+
const target = selected || cwd;
|
|
381
|
+
// 会话工作区单独传(切了项目之后 cwd 已经是目标项目,不能再用它推「当前工作区」)
|
|
382
|
+
const sessionCwd = options && typeof options.sessionCwd === 'string' ? options.sessionCwd : cwd;
|
|
383
|
+
const { config, sources, warnings } = this.config(target);
|
|
384
|
+
const project = this.projectStore ? this.projectStore.project(target) : { key: 'default', apis: [], logins: [], tokens: {}, bindings: [] };
|
|
385
|
+
const rawById = new Map(project.apis.map((item) => [String(item.id), item]));
|
|
386
|
+
return {
|
|
387
|
+
ok: true,
|
|
388
|
+
projectKey: project.key,
|
|
389
|
+
/** 会话工作区自己的 key:面板用它把那一档标成「当前工作区」,而不是靠猜 */
|
|
390
|
+
cwdProjectKey: this.projectStore ? this.projectStore.keyOf(sessionCwd) : 'default',
|
|
391
|
+
sources,
|
|
392
|
+
warnings,
|
|
393
|
+
baseUrl: project.baseUrl || '',
|
|
394
|
+
apis: config.apis.map((api) => {
|
|
395
|
+
const raw = rawById.get(api.id);
|
|
396
|
+
const tokenId = api.login ? '' : api.useToken || (Object.keys(project.tokens).length === 1 ? Object.keys(project.tokens)[0] : '');
|
|
397
|
+
return {
|
|
398
|
+
id: api.id,
|
|
399
|
+
name: api.name,
|
|
400
|
+
method: api.method,
|
|
401
|
+
url: displayUrl(api),
|
|
402
|
+
baseUrl: raw?.baseUrl || project.baseUrl || api.baseUrl || '',
|
|
403
|
+
description: api.description,
|
|
404
|
+
isLogin: Boolean(api.login),
|
|
405
|
+
tokenPath: api.login?.tokenPath || '',
|
|
406
|
+
header: api.login?.header || '',
|
|
407
|
+
prefix: api.login?.prefix ?? '',
|
|
408
|
+
useToken: api.useToken || '',
|
|
409
|
+
/** 面板表单字段:面板自己存的原文优先,YAML 接口则从规整后的值反推 */
|
|
410
|
+
headersText: raw?.headersText ?? (Object.keys(api.headers || {}).length > 0 ? JSON.stringify(api.headers, null, 2) : ''),
|
|
411
|
+
bodyText: raw?.bodyText ?? (api.body === undefined ? '' : JSON.stringify(api.body, null, 2)),
|
|
412
|
+
/**
|
|
413
|
+
* 示例参数:面板存的是 sampleParams(解析后的对象),没有 sampleText 原文。
|
|
414
|
+
* 所以取不到原文时必须从 sampleParams 反推,否则面板保存过一次后
|
|
415
|
+
* 再点「编辑」,这个输入框永远是空的(用户填的内容看起来被吞了)。
|
|
416
|
+
*/
|
|
417
|
+
sampleText: raw?.sampleText ?? (isPlainObject(api.sampleParams) && Object.keys(api.sampleParams).length > 0
|
|
418
|
+
? JSON.stringify(api.sampleParams, null, 2)
|
|
419
|
+
: ''),
|
|
420
|
+
/** query 原文:面板要能回显,否则保存时会把它丢掉(见 saveApi 的整体替换) */
|
|
421
|
+
queryText: raw?.queryText ?? (isPlainObject(api.query) && Object.keys(api.query).length > 0
|
|
422
|
+
? JSON.stringify(api.query, null, 2)
|
|
423
|
+
: ''),
|
|
424
|
+
params: collectParams(api),
|
|
425
|
+
/** 来自面板还是 YAML,面板里只允许删自己创建的 */
|
|
426
|
+
source: raw ? 'ui' : 'file',
|
|
427
|
+
tokenFrom: tokenId,
|
|
428
|
+
timeoutMs: api.timeoutMs,
|
|
429
|
+
};
|
|
430
|
+
}),
|
|
431
|
+
logins: project.logins,
|
|
432
|
+
tokens: this.projectStore ? this.projectStore.tokenStatus(target) : {},
|
|
433
|
+
bindings: project.bindings,
|
|
434
|
+
/** 「接口」页按项目分类的页签清单(含当前工作区,见 projectList) */
|
|
435
|
+
projects: this.projectList(project.key, sessionCwd),
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* 已知项目清单,供「接口」页的项目页签用。
|
|
441
|
+
*
|
|
442
|
+
* 来源三处:会话当前工作区、设置里的默认工作区、面板配过东西的项目桶。
|
|
443
|
+
* 一个项目即使还没配接口也要出现——用户得先能切过去,才能在那儿新增接口;
|
|
444
|
+
* 只有「配过接口的项目」够不成可选项,这正是之前页签整排不显示的原因。
|
|
445
|
+
*/
|
|
446
|
+
projectList(currentKey, cwd) {
|
|
447
|
+
const store = this.projectStore;
|
|
448
|
+
if (!store) return [];
|
|
449
|
+
const items = new Map();
|
|
450
|
+
const add = (dir, origin) => {
|
|
451
|
+
if (!dir) return;
|
|
452
|
+
let project;
|
|
453
|
+
try {
|
|
454
|
+
project = store.project(dir);
|
|
455
|
+
} catch {
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
const existing = items.get(project.key);
|
|
459
|
+
const origins = existing ? existing.origins.slice() : [];
|
|
460
|
+
if (!origins.includes(origin)) origins.push(origin);
|
|
461
|
+
items.set(project.key, {
|
|
462
|
+
key: project.key,
|
|
463
|
+
updatedAt: project.updatedAt,
|
|
464
|
+
baseUrl: project.baseUrl,
|
|
465
|
+
apiCount: project.apis.length,
|
|
466
|
+
savedCount: project.saved.length,
|
|
467
|
+
origins,
|
|
468
|
+
});
|
|
469
|
+
};
|
|
470
|
+
add(cwd, 'cwd');
|
|
471
|
+
let workspace = '';
|
|
472
|
+
try {
|
|
473
|
+
const current = this.settingsStore && typeof this.settingsStore.read === 'function' ? this.settingsStore.read() : null;
|
|
474
|
+
workspace = current && current.workspace ? String(current.workspace) : '';
|
|
475
|
+
} catch {
|
|
476
|
+
workspace = '';
|
|
477
|
+
}
|
|
478
|
+
add(workspace, 'settings');
|
|
479
|
+
for (const item of store.allProjects()) {
|
|
480
|
+
if (items.has(item.key)) continue;
|
|
481
|
+
items.set(item.key, {
|
|
482
|
+
key: item.key,
|
|
483
|
+
updatedAt: item.updatedAt,
|
|
484
|
+
baseUrl: item.baseUrl,
|
|
485
|
+
apiCount: item.apis.length,
|
|
486
|
+
savedCount: Array.isArray(item.saved) ? item.saved.length : 0,
|
|
487
|
+
origins: ['store'],
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
// 会话工作区永远排第一(页签顺序不能随着点击而跳),其余按最近改过的时间倒序
|
|
491
|
+
const cwdKey = store.keyOf(cwd);
|
|
492
|
+
return [...items.values()].sort((a, b) => {
|
|
493
|
+
if (a.key === cwdKey) return -1;
|
|
494
|
+
if (b.key === cwdKey) return 1;
|
|
495
|
+
return String(b.updatedAt).localeCompare(String(a.updatedAt));
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
saveApiUi(cwd, api) {
|
|
500
|
+
if (!this.projectStore) return { ok: false, code: 'NO_STORE', message: '面板配置存储不可用' };
|
|
501
|
+
try {
|
|
502
|
+
this.projectStore.saveApi(cwd, api);
|
|
503
|
+
return { ok: true, message: `已保存接口 ${api.id}` };
|
|
504
|
+
} catch (error) {
|
|
505
|
+
return { ok: false, code: 'SAVE_FAILED', message: error.message };
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
deleteApiUi(cwd, id) {
|
|
510
|
+
if (!this.projectStore) return { ok: false, code: 'NO_STORE', message: '面板配置存储不可用' };
|
|
511
|
+
const project = this.projectStore.project(cwd);
|
|
512
|
+
if (!project.apis.some((item) => String(item.id) === String(id))) {
|
|
513
|
+
return { ok: false, code: 'NOT_FROM_UI', message: `${id} 不是面板创建的接口;请直接改 YAML 配置` };
|
|
514
|
+
}
|
|
515
|
+
this.projectStore.deleteApi(cwd, id);
|
|
516
|
+
return { ok: true, message: `已删除接口 ${id}` };
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
clearTokenUi(cwd, loginId) {
|
|
520
|
+
if (!this.projectStore) return { ok: false, code: 'NO_STORE', message: '面板配置存储不可用' };
|
|
521
|
+
this.projectStore.clearToken(cwd, loginId);
|
|
522
|
+
return { ok: true, message: `已清除 ${loginId} 的 token` };
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// ---- 本机 Java 服务 ----
|
|
526
|
+
/**
|
|
527
|
+
* 列表要同时给出两类进程,并让面板一眼看出区别:
|
|
528
|
+
* owner='idea' —— IDEA 起的(插件只能「接管」,不能直接重启)
|
|
529
|
+
* owner='plugin' —— 插件接管的(可以直接停 / 重启 / 看日志)
|
|
530
|
+
* 同名的两边都出现时,以插件那份为准(IDEA 的进程已经被杀掉了,jps 里自然也就没了)。
|
|
531
|
+
*/
|
|
532
|
+
async javaProcesses(cwd = '') {
|
|
533
|
+
const result = await listJavaProcesses();
|
|
534
|
+
const runs = this.javaRunner ? this.javaRunner.runs(cwd) : [];
|
|
535
|
+
if (!result.ok) {
|
|
536
|
+
/**
|
|
537
|
+
* 没有 jps(没装 JDK)时也必须给全字段。
|
|
538
|
+
*
|
|
539
|
+
* 面板会直接读 saved / ignored / maven 并 .map 它们;早期这里只返回
|
|
540
|
+
* processes,前端拿 undefined 就崩(「已保存」页签整个白掉)。
|
|
541
|
+
* 而且用户没 JDK 时更需要看到「已保存的配方」和「怎么装 JDK」。
|
|
542
|
+
*/
|
|
543
|
+
const missingProject = this.projectStore ? this.projectStore.project(cwd) : { bindings: [], ignored: [], saved: [] };
|
|
544
|
+
return {
|
|
545
|
+
ok: false,
|
|
546
|
+
code: 'JPS_MISSING',
|
|
547
|
+
message: result.note,
|
|
548
|
+
processes: runs.map((run) => this._runView(run)),
|
|
549
|
+
runs: runs.map((run) => this._runView(run)),
|
|
550
|
+
saved: (missingProject.saved || []).map((item) => this._savedView(item)),
|
|
551
|
+
bindings: missingProject.bindings || [],
|
|
552
|
+
ignored: missingProject.ignored || [],
|
|
553
|
+
maven: await this._mavenHint(),
|
|
554
|
+
note: result.note,
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
const project = this.projectStore ? this.projectStore.project(cwd) : { bindings: [], ignored: [] };
|
|
558
|
+
const boundPids = new Set(project.bindings.map((item) => Number(item.pid)));
|
|
559
|
+
const ignoredKeys = new Set((project.ignored || []).map((item) => javaKeyOf(item)));
|
|
560
|
+
const runByPid = new Map(runs.filter((run) => run.alive).map((run) => [Number(run.pid), run]));
|
|
561
|
+
const runByName = new Map(runs.map((run) => [String(run.id), run]));
|
|
562
|
+
|
|
563
|
+
const processes = result.processes.map((item) => {
|
|
564
|
+
// 优先按 pid 认;状态文件丢过(换机器、清过 DSH_HOME)时按启动标记认,
|
|
565
|
+
// 否则自己起的进程会被误标成 IDEA 的,用户就没法在这里停它了
|
|
566
|
+
const marker = item.systemProperties?.['dshApiRunner.runId'];
|
|
567
|
+
const run = runByPid.get(item.pid) || (marker ? runByName.get(String(marker)) : undefined);
|
|
568
|
+
return {
|
|
569
|
+
...item,
|
|
570
|
+
owner: run ? 'plugin' : item.fromIdea ? 'idea' : 'other',
|
|
571
|
+
/** 插件托管时带上自己的记账信息(runId / 日志 / 能否停止) */
|
|
572
|
+
runId: run ? run.id : '',
|
|
573
|
+
managedLogPath: run ? run.logPath : '',
|
|
574
|
+
restartable: Boolean(run),
|
|
575
|
+
stoppable: Boolean(run),
|
|
576
|
+
/**
|
|
577
|
+
* 这个字段必须有:合并进来的 jps 记录本身没有 alive,
|
|
578
|
+
* 前端据此判断「插件启动」还是「已停止」。以前漏了它,
|
|
579
|
+
* 结果所有插件托管的进程都被显示成「已停止」。
|
|
580
|
+
*/
|
|
581
|
+
alive: run ? Boolean(run.alive) : true,
|
|
582
|
+
bound: boundPids.has(item.pid),
|
|
583
|
+
ignored: ignoredKeys.has(javaKeyOf(item)),
|
|
584
|
+
/** 面板上可直接拿它当 baseUrl 用:只认探测到「真的讲 HTTP」的端口 */
|
|
585
|
+
baseUrl: item.httpPort ? `http://127.0.0.1:${item.httpPort}` : '',
|
|
586
|
+
};
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
// 插件起的进程如果已经退出(或 jps 没抓到),补一行,保证面板上看得到、能再启动
|
|
590
|
+
const seen = new Set(processes.map((item) => Number(item.pid)));
|
|
591
|
+
for (const run of runs) {
|
|
592
|
+
if (run.alive && seen.has(Number(run.pid))) continue;
|
|
593
|
+
if (processes.some((item) => item.runId === run.id)) continue;
|
|
594
|
+
const view = this._runView(run);
|
|
595
|
+
// 同名的 IDEA 进程还在(用户没接管成功,或又在 IDEA 里重跑了)时,注明冲突
|
|
596
|
+
const ideaTwin = processes.find((item) => item.name === run.id && item.owner !== 'plugin');
|
|
597
|
+
if (ideaTwin) {
|
|
598
|
+
ideaTwin.managedTwinId = run.id;
|
|
599
|
+
// 记录已退出但同名进程还在跑:不再补一条「已停止」,否则同一个服务
|
|
600
|
+
// 在列表里既显示「正在运行」又显示「已停止」,很容易误判(用户反馈过)
|
|
601
|
+
if (!run.alive) continue;
|
|
602
|
+
}
|
|
603
|
+
processes.push(view);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
return {
|
|
607
|
+
ok: true,
|
|
608
|
+
jps: result.jps,
|
|
609
|
+
total: processes.length,
|
|
610
|
+
processes,
|
|
611
|
+
runs: runs.map((run) => this._runView(run)),
|
|
612
|
+
saved: (project.saved || []).map((item) => this._savedView(item)),
|
|
613
|
+
bindings: project.bindings,
|
|
614
|
+
ignored: project.ignored || [],
|
|
615
|
+
maven: await this._mavenHint(),
|
|
616
|
+
note: runByName.size > 0 ? `${runByName.size} 个服务由插件托管(可直接停止/重启/看日志)` : '',
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* 已保存配方的对外视图:classPath 动辄几万字符,列表里只给长度,
|
|
622
|
+
* 否则返回体小则几十 KB、大则上百 KB,模型和面板都用不上。
|
|
623
|
+
*/
|
|
624
|
+
_savedView(item) {
|
|
625
|
+
return {
|
|
626
|
+
id: item.id,
|
|
627
|
+
name: item.name,
|
|
628
|
+
mainClass: item.mainClass,
|
|
629
|
+
port: item.port,
|
|
630
|
+
debugPort: item.debugPort,
|
|
631
|
+
javaExe: item.javaExe,
|
|
632
|
+
javaHome: item.javaHome,
|
|
633
|
+
cwd: item.cwd,
|
|
634
|
+
module: item.module,
|
|
635
|
+
classPathLength: String(item.classPath || '').length,
|
|
636
|
+
jvmArgs: Array.isArray(item.jvmArgs) ? item.jvmArgs.length : 0,
|
|
637
|
+
programArgs: Array.isArray(item.programArgs) ? item.programArgs.length : 0,
|
|
638
|
+
savedAt: item.savedAt,
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* 保存一个服务配方,之后不依赖 IDEA 也能由插件直接拉起。
|
|
644
|
+
* 来源可以是正在跑的 IDEA 进程(pid),也可以是已经托管的服务(id)。
|
|
645
|
+
*/
|
|
646
|
+
async saveJava(cwd, target = {}) {
|
|
647
|
+
if (!this.projectStore) return { ok: false, code: 'NO_STORE', message: '面板配置存储不可用' };
|
|
648
|
+
const idOrPid = String(target.id || target.pid || '');
|
|
649
|
+
if (!idOrPid) return { ok: false, code: 'MISSING_ARGUMENT', message: '需要 id 或 pid' };
|
|
650
|
+
|
|
651
|
+
const runs = this.javaRunner ? this.javaRunner.runs(cwd) : [];
|
|
652
|
+
const run = runs.find((item) => String(item.id) === idOrPid || String(item.pid) === idOrPid
|
|
653
|
+
|| (Number(target.pid) > 0 && Number(item.pid) === Number(target.pid)));
|
|
654
|
+
let recipe;
|
|
655
|
+
if (run) {
|
|
656
|
+
// 托管记录里本来就存了完整 argv/classpath 快照
|
|
657
|
+
recipe = {
|
|
658
|
+
id: run.id,
|
|
659
|
+
name: run.id,
|
|
660
|
+
mainClass: run.mainClass,
|
|
661
|
+
classPath: run.classPath || this._classPathFromArgv(run.argv),
|
|
662
|
+
jvmArgs: run.jvmArgs || [],
|
|
663
|
+
programArgs: run.programArgs || [],
|
|
664
|
+
javaExe: run.javaExe || '',
|
|
665
|
+
javaHome: run.javaHome || '',
|
|
666
|
+
cwd: run.cwd || '',
|
|
667
|
+
port: run.port || 0,
|
|
668
|
+
debugPort: run.debugPort || 0,
|
|
669
|
+
module: run.module || null,
|
|
670
|
+
};
|
|
671
|
+
} else {
|
|
672
|
+
// java_id 可能是服务名(如 UserService),也可能是 pid;名字转不成数字时用显式传的 pid
|
|
673
|
+
const numericId = Number(idOrPid);
|
|
674
|
+
const pid = Number.isInteger(numericId) && numericId > 0 ? numericId : Number(target.pid) || 0;
|
|
675
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
676
|
+
return { ok: false, code: 'NOT_FOUND', message: `找不到服务 ${idOrPid}(要保存的服务必须正在运行)` };
|
|
677
|
+
}
|
|
678
|
+
const { getJavaSnapshot } = await import('./restart.js');
|
|
679
|
+
const snapshot = await getJavaSnapshot(pid);
|
|
680
|
+
if (!snapshot.ok) return { ok: false, code: 'SNAPSHOT_FAILED', message: snapshot.message };
|
|
681
|
+
const { inferMavenModule } = await import('./restart.js');
|
|
682
|
+
const list = await this.javaProcesses(cwd);
|
|
683
|
+
const process = (list.processes || []).find((item) => Number(item.pid) === pid);
|
|
684
|
+
recipe = {
|
|
685
|
+
id: String(process?.name || ''),
|
|
686
|
+
name: String(process?.name || ''),
|
|
687
|
+
mainClass: snapshot.mainClass,
|
|
688
|
+
classPath: snapshot.classPath,
|
|
689
|
+
jvmArgs: snapshot.jvmArgs,
|
|
690
|
+
programArgs: snapshot.programArgs,
|
|
691
|
+
javaExe: snapshot.javaExe,
|
|
692
|
+
javaHome: snapshot.javaHome,
|
|
693
|
+
cwd: snapshot.cwd,
|
|
694
|
+
port: Number(process?.httpPort) || Number(snapshot.systemProperties?.['server.port']) || 0,
|
|
695
|
+
debugPort: snapshot.systemProperties?.['jdwp'] ? Number(snapshot.systemProperties.jdwp) : 0,
|
|
696
|
+
module: inferMavenModule(snapshot.classPath),
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
try {
|
|
700
|
+
const project = this.projectStore.saveJava(cwd, recipe);
|
|
701
|
+
return {
|
|
702
|
+
ok: true,
|
|
703
|
+
id: recipe.id,
|
|
704
|
+
message: `已保存 ${recipe.id}(${recipe.module?.artifactId || '未识别模块'})。以后不用先在 IDEA 里启动,直接在「已保存」里点启动。`.replace('(未识别模块)', ''),
|
|
705
|
+
saved: (project.saved || []).map((item) => this._savedView(item)),
|
|
706
|
+
};
|
|
707
|
+
} catch (error) {
|
|
708
|
+
return { ok: false, code: 'SAVE_FAILED', message: error.message };
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/** 从 argv 里取 -classpath 的下一个元素(老记录没单独存 classPath 时兜底) */
|
|
713
|
+
_classPathFromArgv(argv) {
|
|
714
|
+
if (!Array.isArray(argv)) return '';
|
|
715
|
+
const index = argv.findIndex((item) => item === '-classpath' || item === '-cp' || item === '--class-path');
|
|
716
|
+
return index >= 0 ? String(argv[index + 1] || '') : '';
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
deleteSavedJava(cwd, id) {
|
|
720
|
+
if (!this.projectStore) return { ok: false, code: 'NO_STORE', message: '面板配置存储不可用' };
|
|
721
|
+
try {
|
|
722
|
+
const project = this.projectStore.deleteJava(cwd, String(id || ''));
|
|
723
|
+
return { ok: true, message: `已删除已保存的服务 ${id}`, saved: (project.saved || []).map((item) => this._savedView(item)) };
|
|
724
|
+
} catch (error) {
|
|
725
|
+
return { ok: false, code: 'UNSAVE_FAILED', message: error.message };
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/** 用保存的配方启动(不需要现有进程) */
|
|
730
|
+
async startSavedJava(cwd, id, options = {}) {
|
|
731
|
+
if (!this.projectStore) return { ok: false, code: 'NO_STORE', message: '面板配置存储不可用' };
|
|
732
|
+
if (!this.javaRunner) return { ok: false, code: 'NO_RUNNER', message: 'Java 托管不可用' };
|
|
733
|
+
const saved = this.projectStore.savedJava(cwd, id);
|
|
734
|
+
if (!saved) return { ok: false, code: 'NOT_SAVED', message: `没有保存的服务 ${id}` };
|
|
735
|
+
return this.javaRunner.startSaved(cwd, saved, options);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* 一键启动:把「已保存」里的服务批量拉起来。
|
|
740
|
+
*
|
|
741
|
+
* 为什么要串行而不是 Promise.all:
|
|
742
|
+
* 1. Maven 编译在同一个模块上是互斥的(都写 target/),并发跑会互相覆盖,
|
|
743
|
+
* 出现「编译成功但产物是半成品」这种最难查的错;
|
|
744
|
+
* 2. 一个服务起不来通常意味着环境有问题(端口、依赖、编译),
|
|
745
|
+
* 这时候继续把剩下的全拉起来,用户看到的是满屏报错而不是一个明确原因。
|
|
746
|
+
* 所以默认**逐个启动、遇错继续**:每个失败单独记账,最后一起汇报,
|
|
747
|
+
* 用户既能看到哪个成功,也能看到哪个为什么失败。
|
|
748
|
+
*
|
|
749
|
+
* 已经在跑的跳过(除非 options.restart 为 true),避免把正在调试的进程踢掉。
|
|
750
|
+
*
|
|
751
|
+
* @param {string} cwd - 项目目录
|
|
752
|
+
* @param {string[]} ids - 要启动的服务 id;空数组表示「已保存里的全部」
|
|
753
|
+
* @param {{compile?:boolean, restart?:boolean, timeoutMs?:number, compileTimeoutMs?:number, onProgress?:Function}} options
|
|
754
|
+
*/
|
|
755
|
+
async startAllSavedJava(cwd, ids = [], options = {}) {
|
|
756
|
+
if (!this.projectStore) return { ok: false, code: 'NO_STORE', message: '面板配置存储不可用' };
|
|
757
|
+
if (!this.javaRunner) return { ok: false, code: 'NO_RUNNER', message: 'Java 托管不可用' };
|
|
758
|
+
|
|
759
|
+
const all = this.projectStore.savedJava(cwd) || [];
|
|
760
|
+
const wanted = Array.isArray(ids) && ids.length > 0
|
|
761
|
+
? ids.map((id) => String(id))
|
|
762
|
+
: all.map((item) => String(item.id));
|
|
763
|
+
if (wanted.length === 0) {
|
|
764
|
+
return { ok: false, code: 'NOTHING_SAVED', message: '还没有保存的服务。先在「运行中」里对一个服务点「保存」,之后才能一键启动。' };
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
const aliveIds = new Set((this.javaRunner.runs(cwd) || []).filter((run) => run.alive).map((run) => String(run.id)));
|
|
768
|
+
const items = [];
|
|
769
|
+
let started = 0;
|
|
770
|
+
let skipped = 0;
|
|
771
|
+
let failed = 0;
|
|
772
|
+
|
|
773
|
+
for (const id of wanted) {
|
|
774
|
+
const saved = all.find((item) => String(item.id) === id);
|
|
775
|
+
if (!saved) {
|
|
776
|
+
items.push({ id, ok: false, code: 'NOT_SAVED', message: `没有保存的服务 ${id}` });
|
|
777
|
+
failed += 1;
|
|
778
|
+
if (typeof options.onProgress === 'function') {
|
|
779
|
+
try {
|
|
780
|
+
options.onProgress(items[items.length - 1], items.length, wanted.length);
|
|
781
|
+
} catch {
|
|
782
|
+
/* 进度回调出错不影响启动 */
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
continue;
|
|
786
|
+
}
|
|
787
|
+
if (aliveIds.has(id) && options.restart !== true) {
|
|
788
|
+
items.push({ id, ok: true, skipped: true, note: `${id} 已在运行,跳过` });
|
|
789
|
+
skipped += 1;
|
|
790
|
+
if (typeof options.onProgress === 'function') {
|
|
791
|
+
try {
|
|
792
|
+
options.onProgress(items[items.length - 1], items.length, wanted.length);
|
|
793
|
+
} catch {
|
|
794
|
+
/* 进度回调出错不影响启动 */
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
continue;
|
|
798
|
+
}
|
|
799
|
+
try {
|
|
800
|
+
const result = await this.javaRunner.startSaved(cwd, saved, {
|
|
801
|
+
compile: options.compile !== false,
|
|
802
|
+
timeoutMs: options.timeoutMs,
|
|
803
|
+
compileTimeoutMs: options.compileTimeoutMs,
|
|
804
|
+
});
|
|
805
|
+
items.push({
|
|
806
|
+
id,
|
|
807
|
+
ok: result.ok === true,
|
|
808
|
+
code: result.code,
|
|
809
|
+
pid: result.pid || 0,
|
|
810
|
+
port: result.port || 0,
|
|
811
|
+
logPath: result.logPath || '',
|
|
812
|
+
compile: result.compile,
|
|
813
|
+
message: result.message,
|
|
814
|
+
note: result.note,
|
|
815
|
+
});
|
|
816
|
+
if (result.ok) {
|
|
817
|
+
started += 1;
|
|
818
|
+
aliveIds.add(id);
|
|
819
|
+
} else {
|
|
820
|
+
failed += 1;
|
|
821
|
+
}
|
|
822
|
+
} catch (error) {
|
|
823
|
+
items.push({ id, ok: false, code: 'UNEXPECTED', message: String(error?.message || error) });
|
|
824
|
+
failed += 1;
|
|
825
|
+
}
|
|
826
|
+
if (typeof options.onProgress === 'function') {
|
|
827
|
+
try {
|
|
828
|
+
options.onProgress(items[items.length - 1], items.length, wanted.length);
|
|
829
|
+
} catch {
|
|
830
|
+
/* 进度回调出错不影响启动 */
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
const lines = items.map((item) => {
|
|
836
|
+
if (item.skipped) return `- ${item.id}:已在运行,跳过`;
|
|
837
|
+
if (item.ok) return `- ${item.id}:已启动${item.port ? `(端口 ${item.port})` : ''}`;
|
|
838
|
+
return `- ${item.id}:失败 —— ${item.message || item.code || '未知原因'}`;
|
|
839
|
+
});
|
|
840
|
+
return {
|
|
841
|
+
ok: failed === 0,
|
|
842
|
+
code: failed === 0 ? undefined : 'PARTIAL',
|
|
843
|
+
started,
|
|
844
|
+
skipped,
|
|
845
|
+
failed,
|
|
846
|
+
total: wanted.length,
|
|
847
|
+
items,
|
|
848
|
+
message: `一键启动完成:成功 ${started} 个,跳过 ${skipped} 个,失败 ${failed} 个。\n${lines.join('\n')}`,
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* 在后台跑一次一键启动,立刻返回批次 id。
|
|
854
|
+
*
|
|
855
|
+
* 面板拿到 id 后每 1 秒拉一次 `startBatchStatus`,直到 `finished`。
|
|
856
|
+
* 这样编译期间的几分钟里页面是可交互的,也能看到「3/8 正在编译」这类进度。
|
|
857
|
+
*/
|
|
858
|
+
startSavedJavaBatch(cwd, ids = [], options = {}) {
|
|
859
|
+
if (!this.projectStore) return { ok: false, code: 'NO_STORE', message: '面板配置存储不可用' };
|
|
860
|
+
if (!this.javaRunner) return { ok: false, code: 'NO_RUNNER', message: 'Java 托管不可用' };
|
|
861
|
+
const all = this.projectStore.savedJava(cwd) || [];
|
|
862
|
+
const wanted = Array.isArray(ids) && ids.length > 0 ? ids.map((id) => String(id)) : all.map((item) => String(item.id));
|
|
863
|
+
if (wanted.length === 0) {
|
|
864
|
+
return { ok: false, code: 'NOTHING_SAVED', message: '还没有保存的服务。先在「运行中」里对一个服务点「保存」,之后才能一键启动。' };
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
this._batchSeq += 1;
|
|
868
|
+
const batchId = `batch-${Date.now()}-${this._batchSeq}`;
|
|
869
|
+
const batch = {
|
|
870
|
+
id: batchId,
|
|
871
|
+
cwd,
|
|
872
|
+
startedAt: new Date().toISOString(),
|
|
873
|
+
done: 0,
|
|
874
|
+
total: wanted.length,
|
|
875
|
+
items: [],
|
|
876
|
+
finished: false,
|
|
877
|
+
result: null,
|
|
878
|
+
current: '',
|
|
879
|
+
};
|
|
880
|
+
this.startBatches.set(batchId, batch);
|
|
881
|
+
|
|
882
|
+
// 只保留最近 10 个批次,避免长时间运行的进程内存里堆一堆历史
|
|
883
|
+
if (this.startBatches.size > 10) {
|
|
884
|
+
const oldest = [...this.startBatches.values()].sort((a, b) => String(a.startedAt).localeCompare(String(b.startedAt)));
|
|
885
|
+
for (const item of oldest.slice(0, this.startBatches.size - 10)) this.startBatches.delete(item.id);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// 不 await:立刻把批次 id 还给面板
|
|
889
|
+
Promise.resolve()
|
|
890
|
+
.then(() => this.startAllSavedJava(cwd, wanted, {
|
|
891
|
+
...options,
|
|
892
|
+
onProgress: (item, done, total) => {
|
|
893
|
+
batch.done = done;
|
|
894
|
+
batch.total = total;
|
|
895
|
+
batch.current = item.id;
|
|
896
|
+
batch.items.push(item);
|
|
897
|
+
},
|
|
898
|
+
}))
|
|
899
|
+
.then((result) => {
|
|
900
|
+
batch.result = result;
|
|
901
|
+
batch.finished = true;
|
|
902
|
+
batch.done = result.total;
|
|
903
|
+
batch.current = '';
|
|
904
|
+
})
|
|
905
|
+
.catch((error) => {
|
|
906
|
+
batch.finished = true;
|
|
907
|
+
batch.current = '';
|
|
908
|
+
batch.result = { ok: false, code: 'UNEXPECTED', message: String(error?.message || error) };
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
return { ok: true, batchId, total: wanted.length, message: `已开始一键启动 ${wanted.length} 个服务` };
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/** 读一键启动批次进度;id 省略时返回最近一个批次 */
|
|
915
|
+
startBatchStatus(batchId = '') {
|
|
916
|
+
const key = String(batchId || '');
|
|
917
|
+
const batch = key
|
|
918
|
+
? this.startBatches.get(key)
|
|
919
|
+
: [...this.startBatches.values()].sort((a, b) => String(b.startedAt).localeCompare(String(a.startedAt))).pop();
|
|
920
|
+
if (!batch) return { ok: false, code: 'NO_BATCH', message: '没有这个启动批次(可能已经过期,或 DSH 重启过)' };
|
|
921
|
+
return {
|
|
922
|
+
ok: true,
|
|
923
|
+
batchId: batch.id,
|
|
924
|
+
finished: batch.finished,
|
|
925
|
+
done: batch.done,
|
|
926
|
+
total: batch.total,
|
|
927
|
+
current: batch.current,
|
|
928
|
+
startedAt: batch.startedAt,
|
|
929
|
+
items: batch.items,
|
|
930
|
+
result: batch.result,
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/** 插件级设置(数据目录 / 默认工作区 / Maven 主路径) */
|
|
935
|
+
settings() {
|
|
936
|
+
if (!this.settingsStore) {
|
|
937
|
+
return { ok: false, code: 'NO_SETTINGS', message: '设置存储不可用' };
|
|
938
|
+
}
|
|
939
|
+
return { ok: true, ...this.settingsStore.read() };
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
saveSettings(patch = {}) {
|
|
943
|
+
if (!this.settingsStore) return { ok: false, code: 'NO_SETTINGS', message: '设置存储不可用' };
|
|
944
|
+
try {
|
|
945
|
+
const next = this.settingsStore.set(patch);
|
|
946
|
+
// 数据目录变了要立刻生效,否则日志还写到老位置
|
|
947
|
+
if (this.javaRunner) this.javaRunner.refreshDataDir();
|
|
948
|
+
return { ok: true, ...next };
|
|
949
|
+
} catch (error) {
|
|
950
|
+
return { ok: false, code: 'SETTINGS_FAILED', message: error.message };
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/** 托管记录的统一视图(面板与工具共用) */
|
|
955
|
+
_runView(run) {
|
|
956
|
+
// 回填过的记录有 httpPort/ports;没回填时退回 port,保证面板至少有一个端口可看
|
|
957
|
+
const httpPort = Number(run.httpPort) || Number(run.port) || 0;
|
|
958
|
+
const ports = Array.isArray(run.ports) && run.ports.length > 0 ? run.ports : httpPort ? [httpPort] : [];
|
|
959
|
+
return {
|
|
960
|
+
pid: run.pid,
|
|
961
|
+
mainClass: run.mainClass,
|
|
962
|
+
name: run.id,
|
|
963
|
+
kind: 'spring',
|
|
964
|
+
owner: 'plugin',
|
|
965
|
+
fromIdea: false,
|
|
966
|
+
profiles: [],
|
|
967
|
+
debugPort: run.debugPort || 0,
|
|
968
|
+
ideaProject: '',
|
|
969
|
+
serverPort: httpPort,
|
|
970
|
+
ports,
|
|
971
|
+
primaryPort: Number(run.primaryPort) || httpPort,
|
|
972
|
+
httpPort,
|
|
973
|
+
sentinelPort: Number(run.sentinelPort) || 0,
|
|
974
|
+
systemProperties: {},
|
|
975
|
+
bound: false,
|
|
976
|
+
ignored: false,
|
|
977
|
+
baseUrl: httpPort ? `http://127.0.0.1:${httpPort}` : '',
|
|
978
|
+
runId: run.id,
|
|
979
|
+
managedLogPath: run.logPath,
|
|
980
|
+
module: run.module ? run.module.artifactId || '' : '',
|
|
981
|
+
restartable: true,
|
|
982
|
+
stoppable: run.alive,
|
|
983
|
+
alive: run.alive,
|
|
984
|
+
startedAt: run.startedAt || '',
|
|
985
|
+
stoppedAt: run.stoppedAt || '',
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
async _mavenHint() {
|
|
990
|
+
try {
|
|
991
|
+
const { resolveMaven } = await import('./restart.js');
|
|
992
|
+
// 与编译时保持一致:设置里配了 Maven 主路径就以它为准,
|
|
993
|
+
// 否则面板会显示「IDEA 自带 Maven」,与实际编译用的不是同一个,容易误导。
|
|
994
|
+
const preferred = this.javaRunner ? this.javaRunner.configuredMaven('') : '';
|
|
995
|
+
const mvn = await resolveMaven(preferred);
|
|
996
|
+
return { available: Boolean(mvn), path: mvn, source: preferred ? 'settings' : 'auto' };
|
|
997
|
+
} catch {
|
|
998
|
+
return { available: false, path: '', source: 'auto' };
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/**
|
|
1003
|
+
* 接管一个 IDEA 起的进程:按快照编译 → 杀旧进程 → 插件拉起新进程。
|
|
1004
|
+
* 这是「写完代码后重启」的入口;成功后该服务 owner 变成 plugin。
|
|
1005
|
+
*/
|
|
1006
|
+
async startJava(cwd, options = {}) {
|
|
1007
|
+
if (!this.javaRunner) return { ok: false, code: 'NO_RUNNER', message: 'Java 托管不可用' };
|
|
1008
|
+
const result = await this.javaRunner.start(cwd, options);
|
|
1009
|
+
if (result.ok) this.javaProcesses(cwd).catch(() => {});
|
|
1010
|
+
return result;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
async stopJava(cwd, idOrPid) {
|
|
1014
|
+
if (!this.javaRunner) return { ok: false, code: 'NO_RUNNER', message: 'Java 托管不可用' };
|
|
1015
|
+
return this.javaRunner.stop(cwd, idOrPid);
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
async restartJava(cwd, idOrPid, options = {}) {
|
|
1019
|
+
if (!this.javaRunner) return { ok: false, code: 'NO_RUNNER', message: 'Java 托管不可用' };
|
|
1020
|
+
return this.javaRunner.restart(cwd, idOrPid, options);
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
javaLogs(cwd, id, options = {}) {
|
|
1024
|
+
if (!this.javaRunner) return { ok: false, code: 'NO_RUNNER', message: 'Java 托管不可用' };
|
|
1025
|
+
return this.javaRunner.logs(cwd, id, options);
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/**
|
|
1029
|
+
* 服务详情:进程信息 + 可读日志文件清单 + 首屏日志。
|
|
1030
|
+
*
|
|
1031
|
+
* 日志来源分两种:
|
|
1032
|
+
* - 插件托管的进程:`${DSH_HOME}/java-logs/<id>.log`(我们自己的 stdout 落盘)
|
|
1033
|
+
* - IDEA 起的进程:读它自己的 logback 文件 `<user.dir>/logs/<服务名>/info.log`
|
|
1034
|
+
* (IDEA 控制台不是文件,拿不到;但应用日志内容是一样的)
|
|
1035
|
+
*/
|
|
1036
|
+
async javaDetail(cwd, target, options = {}) {
|
|
1037
|
+
const idOrPid = String(target || '');
|
|
1038
|
+
if (!idOrPid) return { ok: false, code: 'MISSING_ARGUMENT', message: '需要 id 或 pid' };
|
|
1039
|
+
|
|
1040
|
+
const runs = this.javaRunner ? this.javaRunner.runs(cwd) : [];
|
|
1041
|
+
const run = runs.find((item) => String(item.id) === idOrPid || String(item.pid) === idOrPid);
|
|
1042
|
+
const list = await this.javaProcesses(cwd);
|
|
1043
|
+
const numeric = Number(idOrPid);
|
|
1044
|
+
// pid 之外也允许用服务名(UserService)或主类定位,模型和人都更顺手
|
|
1045
|
+
const process = (list.processes || []).find((item) => (
|
|
1046
|
+
(Number.isFinite(numeric) && numeric > 0 && Number(item.pid) === numeric)
|
|
1047
|
+
|| (!run && String(item.name) === idOrPid)
|
|
1048
|
+
|| (!run && String(item.mainClass) === idOrPid)
|
|
1049
|
+
)) || null;
|
|
1050
|
+
const pid = run ? Number(run.pid) : (process ? Number(process.pid) : numeric);
|
|
1051
|
+
|
|
1052
|
+
if (!run && !process) {
|
|
1053
|
+
return { ok: false, code: 'NOT_FOUND', message: `找不到服务 ${idOrPid}(可能已经退出,或 pid 不对)` };
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
const name = process?.name || run?.id || idOrPid;
|
|
1057
|
+
const mainClass = process?.mainClass || run?.mainClass || '';
|
|
1058
|
+
// 有托管记录就以记录的 alive 为准(进程已退出时 jps 里也没有它)
|
|
1059
|
+
const alive = run ? Boolean(run.alive) : Boolean(process);
|
|
1060
|
+
|
|
1061
|
+
// 候选日志文件:插件日志优先(顺序更接近启动过程),再是应用日志
|
|
1062
|
+
const sources = [];
|
|
1063
|
+
if (run) {
|
|
1064
|
+
// 每次启动一个日志文件,默认给最新的,历史启动可以切过去对比
|
|
1065
|
+
const segments = this.javaRunner ? this.javaRunner.logSegmentsOf(run.id) : [];
|
|
1066
|
+
for (const segment of segments.slice(0, 8)) {
|
|
1067
|
+
const current = run.logPath === segment.path;
|
|
1068
|
+
sources.push({
|
|
1069
|
+
key: `plugin:${segment.path}`,
|
|
1070
|
+
label: `插件输出 ${segment.name.replace(/\.log$/i, '')}${current ? '(当前)' : ''}`,
|
|
1071
|
+
path: segment.path,
|
|
1072
|
+
kind: 'plugin',
|
|
1073
|
+
size: segment.size,
|
|
1074
|
+
updatedAt: segment.updatedAt,
|
|
1075
|
+
});
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
let discovered = { ok: false, cwd: '', files: [], note: '' };
|
|
1079
|
+
if (!run) {
|
|
1080
|
+
discovered = await discoverLogs(pid, name);
|
|
1081
|
+
for (const file of discovered.files || []) {
|
|
1082
|
+
sources.push({ key: `app:${file.path}`, label: `应用日志 ${file.name}`, path: file.path, kind: 'app', size: file.size, updatedAt: file.updatedAt });
|
|
1083
|
+
}
|
|
1084
|
+
} else {
|
|
1085
|
+
// 托管进程也能看应用日志(logback 照样写文件),只是默认展示插件输出
|
|
1086
|
+
discovered = await discoverLogs(pid, name);
|
|
1087
|
+
for (const file of (discovered.files || []).slice(0, 3)) {
|
|
1088
|
+
sources.push({ key: `app:${file.path}`, label: `应用日志 ${file.name}`, path: file.path, kind: 'app', size: file.size, updatedAt: file.updatedAt });
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
const wanted = options.source ? sources.find((item) => item.key === options.source || item.path === options.source) : undefined;
|
|
1093
|
+
const chosen = wanted || sources[0] || undefined;
|
|
1094
|
+
let log = { ok: true, logPath: '', size: 0, fromOffset: 0, nextOffset: 0, text: '', note: '没有可读的日志文件' };
|
|
1095
|
+
if (chosen) {
|
|
1096
|
+
// 没给偏移 = 打开页面看首屏:按行取末尾(和 IDEA 控制台一样先看最近的),
|
|
1097
|
+
// 给了偏移 = 轮询增量,继续按字节读。
|
|
1098
|
+
const hasFrom = Number.isFinite(Number(options.fromOffset)) && Number(options.fromOffset) >= 0;
|
|
1099
|
+
log = hasFrom
|
|
1100
|
+
? readFileChunk(chosen.path, { fromOffset: Number(options.fromOffset), maxBytes: options.maxBytes })
|
|
1101
|
+
: readFileTail(chosen.path, { lines: options.lines });
|
|
1102
|
+
log.source = chosen.key;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
return {
|
|
1106
|
+
ok: true,
|
|
1107
|
+
id: process?.runId || run?.id || name,
|
|
1108
|
+
pid,
|
|
1109
|
+
name,
|
|
1110
|
+
mainClass,
|
|
1111
|
+
owner: process?.owner || 'plugin',
|
|
1112
|
+
alive,
|
|
1113
|
+
port: process?.httpPort || run?.port || 0,
|
|
1114
|
+
ports: process?.portList || [],
|
|
1115
|
+
debugPort: process?.debugPort || run?.debugPort || 0,
|
|
1116
|
+
profiles: process?.profiles || [],
|
|
1117
|
+
module: process?.module || (run?.module ? run.module.artifactId || '' : ''),
|
|
1118
|
+
startedAt: run?.startedAt || '',
|
|
1119
|
+
stoppedAt: run?.stoppedAt || '',
|
|
1120
|
+
cwd: discovered.cwd || '',
|
|
1121
|
+
sources,
|
|
1122
|
+
source: chosen?.key || '',
|
|
1123
|
+
log,
|
|
1124
|
+
note: sources.length === 0
|
|
1125
|
+
? (discovered.note || '没有找到日志文件(IDEA 控制台输出不落盘,需要应用自己写 logback 文件)')
|
|
1126
|
+
: '',
|
|
1127
|
+
};
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
ignoreJava(cwd, target) {
|
|
1131
|
+
if (!this.projectStore) return { ok: false, code: 'NO_STORE', message: '面板配置存储不可用' };
|
|
1132
|
+
const key = javaKeyOf(target);
|
|
1133
|
+
if (!key) return { ok: false, code: 'MISSING_ARGUMENT', message: '缺少 mainClass 或 name' };
|
|
1134
|
+
try {
|
|
1135
|
+
this.projectStore.ignoreJava(cwd, target);
|
|
1136
|
+
return { ok: true, message: `已忽略 ${target.name || key}` };
|
|
1137
|
+
} catch (error) {
|
|
1138
|
+
return { ok: false, code: 'IGNORE_FAILED', message: error.message };
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
unignoreJava(cwd, target) {
|
|
1143
|
+
if (!this.projectStore) return { ok: false, code: 'NO_STORE', message: '面板配置存储不可用' };
|
|
1144
|
+
this.projectStore.unignoreJava(cwd, target);
|
|
1145
|
+
return { ok: true, message: `已取消忽略 ${target.name || javaKeyOf(target)}` };
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
bindJava(cwd, binding) {
|
|
1149
|
+
if (!this.projectStore) return { ok: false, code: 'NO_STORE', message: '面板配置存储不可用' };
|
|
1150
|
+
try {
|
|
1151
|
+
this.projectStore.bindJava(cwd, binding);
|
|
1152
|
+
return { ok: true, message: `已关联 pid ${binding.pid}` };
|
|
1153
|
+
} catch (error) {
|
|
1154
|
+
return { ok: false, code: 'BIND_FAILED', message: error.message };
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
unbindJava(cwd, pid) {
|
|
1159
|
+
if (!this.projectStore) return { ok: false, code: 'NO_STORE', message: '面板配置存储不可用' };
|
|
1160
|
+
this.projectStore.unbindJava(cwd, pid);
|
|
1161
|
+
return { ok: true, message: `已取消关联 pid ${pid}` };
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
dispose() {
|
|
1165
|
+
this.logReader.dispose();
|
|
1166
|
+
this.serviceManager.dispose();
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
/** 步骤失败时给出可读原因,而不是只回一个阶段名 */
|
|
1171
|
+
function describeFailure(step) {
|
|
1172
|
+
if (step.stage === 'log') {
|
|
1173
|
+
if (step.pattern) return `等待日志特征 ${step.pattern} 超时(${step.waitedMs}ms):${step.path}`;
|
|
1174
|
+
return `读取日志失败:${step.path}`;
|
|
1175
|
+
}
|
|
1176
|
+
if (step.stage === 'call') {
|
|
1177
|
+
return step.error ? `接口调用失败:${step.error}` : `接口返回 HTTP ${step.status}`;
|
|
1178
|
+
}
|
|
1179
|
+
if (step.stage === 'wait') {
|
|
1180
|
+
return `服务在超时内未通过健康检查:${(step.checks || []).map((item) => `${item.kind}=${item.ok ? 'ok' : item.detail}`).join('; ')}`;
|
|
1181
|
+
}
|
|
1182
|
+
return step.message || `${step.stage} 步骤失败`;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
/** 展示用的完整地址:baseUrl + url,保留 {占位符} 让模型知道路径形状 */
|
|
1186
|
+
function displayUrl(api) {
|
|
1187
|
+
const raw = String(api.url || '');
|
|
1188
|
+
if (!api.baseUrl) return raw;
|
|
1189
|
+
if (/^https?:\/\//i.test(raw)) return raw;
|
|
1190
|
+
const base = api.baseUrl.endsWith('/') ? api.baseUrl.slice(0, -1) : api.baseUrl;
|
|
1191
|
+
return `${base}/${raw.replace(/^\//, '')}`;
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
/** 从 api 定义里推断可用占位符,帮助模型知道要传什么参数 */
|
|
1195
|
+
function collectParams(api) {
|
|
1196
|
+
const names = new Set();
|
|
1197
|
+
const scan = (text) => {
|
|
1198
|
+
if (typeof text !== 'string') return;
|
|
1199
|
+
for (const match of text.matchAll(/\{([A-Za-z_][A-Za-z0-9_.-]*)\}/g)) names.add(match[1]);
|
|
1200
|
+
};
|
|
1201
|
+
scan(api.url);
|
|
1202
|
+
const walk = (value) => {
|
|
1203
|
+
if (typeof value === 'string') scan(value);
|
|
1204
|
+
else if (Array.isArray(value)) value.forEach(walk);
|
|
1205
|
+
else if (isPlainObject(value)) Object.values(value).forEach(walk);
|
|
1206
|
+
};
|
|
1207
|
+
walk(api.query);
|
|
1208
|
+
walk(api.body);
|
|
1209
|
+
Object.keys(api.query || {}).forEach((key) => names.add(key));
|
|
1210
|
+
return [...names];
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
/**
|
|
1214
|
+
* 在登录响应里找像 token 的字段路径,用于 tokenPath 配错时给出可操作提示。
|
|
1215
|
+
* 只递归三层,避免大响应里遍历过多。
|
|
1216
|
+
*/
|
|
1217
|
+
function tokenCandidates(value, prefix = '', depth = 0) {
|
|
1218
|
+
if (depth > 3 || !isPlainObject(value)) return [];
|
|
1219
|
+
const out = [];
|
|
1220
|
+
for (const [key, item] of Object.entries(value)) {
|
|
1221
|
+
const pathText = prefix ? `${prefix}.${key}` : key;
|
|
1222
|
+
if (typeof item === 'string' && /token/i.test(key) && item.length >= 8) {
|
|
1223
|
+
out.push(pathText);
|
|
1224
|
+
} else if (isPlainObject(item)) {
|
|
1225
|
+
out.push(...tokenCandidates(item, pathText, depth + 1));
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
return out;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
function fileExists(file) {
|
|
1232
|
+
try {
|
|
1233
|
+
return fs.statSync(file).isFile();
|
|
1234
|
+
} catch {
|
|
1235
|
+
return false;
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
function fileSize(file) {
|
|
1240
|
+
try {
|
|
1241
|
+
return fs.statSync(file).size;
|
|
1242
|
+
} catch {
|
|
1243
|
+
return 0;
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
/** 工具输出统一渲染成文本:优先 JSON,过长则截断 */
|
|
1248
|
+
export function toText(payload, maxBytes = 24 * 1024) {
|
|
1249
|
+
let text;
|
|
1250
|
+
try {
|
|
1251
|
+
text = JSON.stringify(payload, null, 2);
|
|
1252
|
+
} catch {
|
|
1253
|
+
text = String(payload);
|
|
1254
|
+
}
|
|
1255
|
+
return truncateText(text, maxBytes);
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
/** 供 HTTP API 与调试使用:把未知输入规整成对象 */
|
|
1259
|
+
export function asObject(value) {
|
|
1260
|
+
if (isPlainObject(value)) return value;
|
|
1261
|
+
if (typeof value === 'string') {
|
|
1262
|
+
const parsed = tryJson(value);
|
|
1263
|
+
return isPlainObject(parsed) ? parsed : {};
|
|
1264
|
+
}
|
|
1265
|
+
return {};
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
export { expandDeep, normalizeProjectKey };
|