ellm-proxy 0.0.2 → 0.0.6
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 +60 -60
- package/bin/commands/autostart.js +37 -213
- package/bin/commands/config.js +8 -8
- package/bin/commands/pm2.js +28 -28
- package/bin/commands/service.js +107 -107
- package/bin/ellm-proxy.js +61 -61
- package/dist/server.js +33 -2
- package/dist/web-ui/assets/DashboardView-BHG4xIdN.css +1 -0
- package/dist/web-ui/assets/{DashboardView-CQdlgirS.js → DashboardView-C3Hw-HpG.js} +1 -1
- package/dist/web-ui/assets/LayoutView-BUA8-IzK.css +1 -0
- package/dist/web-ui/assets/LayoutView-C9phf7in.js +1 -0
- package/dist/web-ui/assets/LoginView-DrhfslIY.css +1 -0
- package/dist/web-ui/assets/LoginView-es6pSPgt.js +1 -0
- package/dist/web-ui/assets/LogsView-BlXITEJH.js +9 -0
- package/dist/web-ui/assets/LogsView-Dl50XQhG.css +1 -0
- package/dist/web-ui/assets/{ProcessView-0x_ShSwV.js → ProcessView-Bgf7-MLQ.js} +1 -1
- package/dist/web-ui/assets/ProcessView-DK0TvDpR.css +1 -0
- package/dist/web-ui/assets/ProvidersView-Dpr8MI-p.css +1 -0
- package/dist/web-ui/assets/ProvidersView-f8aZkMdw.js +31 -0
- package/dist/web-ui/assets/SettingsView-BkwyoOUb.css +1 -0
- package/dist/web-ui/assets/SettingsView-GvAA2vLp.js +1 -0
- package/dist/web-ui/assets/config-Cs3_z63C.js +1 -0
- package/dist/web-ui/assets/{index-DFlUx18F.js → index-GohB4JDa.js} +2 -2
- package/dist/web-ui/assets/{providers-CGEmOxne.js → providers-Djtdy2tv.js} +1 -1
- package/dist/web-ui/assets/{useApi-BRPinb_2.js → useApi-D_iJ2Mca.js} +1 -1
- package/dist/web-ui/assets/useManagerApi-C1_COxKg.js +1 -0
- package/dist/web-ui/assets/{useToast-D7xlwSg6.js → useToast-C0UMTbkx.js} +1 -1
- package/dist/web-ui/config.js +10 -10
- package/dist/web-ui/index.html +13 -13
- package/docs/DESIGN.md +99 -99
- package/manager/api/harness/backup.js +20 -20
- package/manager/api/harness/claude-code.js +54 -54
- package/manager/api/harness/codex.js +123 -123
- package/manager/api/harness/dsh.js +175 -175
- package/manager/api/harness/index.js +118 -118
- package/manager/api/service-settings.js +98 -0
- package/manager/api/service.js +3 -13
- package/manager/index.js +182 -170
- package/package.json +44 -40
- package/utils/autostart.js +219 -0
- package/utils/interpreter.js +14 -14
- package/utils/paths.js +45 -44
- package/utils/pm2.js +130 -130
- package/utils/service.js +205 -189
- package/utils/updater.js +210 -0
- package/dist/web-ui/assets/DashboardView-qrQrkgLA.css +0 -1
- package/dist/web-ui/assets/LayoutView-BaY4Pz9W.js +0 -1
- package/dist/web-ui/assets/LoginView-CPvz1Ylx.js +0 -1
- package/dist/web-ui/assets/LoginView-Dg0mB6ok.css +0 -1
- package/dist/web-ui/assets/LogsView-B-bIj36j.js +0 -9
- package/dist/web-ui/assets/LogsView-Wl3_adHm.css +0 -1
- package/dist/web-ui/assets/ProcessView-DzMqBJmd.css +0 -1
- package/dist/web-ui/assets/ProvidersView-BxAxDRFH.css +0 -1
- package/dist/web-ui/assets/ProvidersView-GQENcvJS.js +0 -31
- package/dist/web-ui/assets/SettingsView-BDkymsrS.css +0 -1
- package/dist/web-ui/assets/SettingsView-BrAiaskZ.js +0 -1
- package/dist/web-ui/assets/config-DNn5cjYY.js +0 -1
- package/dist/web-ui/assets/useManagerApi-BDWJz5o6.js +0 -1
package/utils/service.js
CHANGED
|
@@ -1,189 +1,205 @@
|
|
|
1
|
-
// 服务进程管理核心:start/stop(restart = stop + start 由调用方组合,保证最新参数生效)。
|
|
2
|
-
// 被 CLI(bin/commands/service.js)与管理服务(manager/index.js)复用。
|
|
3
|
-
// 本模块只做进程编排并返回数据,人读文案/摘要由调用方输出;错误以 throw 抛出(文案是契约的一部分)。
|
|
4
|
-
// start 流程:配置自愈 → env 注入(含端口预算:主服务 ELLM_PORT / 管理服务 ELLM_MANAGER_PORT = 主端口+1,
|
|
5
|
-
// 占用时自动顺延空闲端口)→ 两个服务一并提交 pm2(数组形式)→ 并行轮询探活 → dump;
|
|
6
|
-
// 管理服务已在线(部分恢复场景)则只补启主服务。
|
|
7
|
-
// 全程分步输出进度(「→ 启动 ...」+ 探活进度点),等待期间不留静默空窗。
|
|
8
|
-
import { readFileSync, mkdirSync } from "node:fs";
|
|
9
|
-
import { join } from "node:path";
|
|
10
|
-
import { APP_NAME, MANAGER_APP_NAME, pkgRoot, defaultAppDir, ensureConfig, configPath } from "./paths.js";
|
|
11
|
-
import { withPm2, pm2cb, findApp, waitOnline, safeDump, sanitizePm2Env } from "./pm2.js";
|
|
12
|
-
import { resolveInterpreter } from "./interpreter.js";
|
|
13
|
-
import { isPortFree, findFreePort } from "./port.js";
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
*
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
process.env.
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
//
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
{
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
if (proc)
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
1
|
+
// 服务进程管理核心:start/stop(restart = stop + start 由调用方组合,保证最新参数生效)。
|
|
2
|
+
// 被 CLI(bin/commands/service.js)与管理服务(manager/index.js)复用。
|
|
3
|
+
// 本模块只做进程编排并返回数据,人读文案/摘要由调用方输出;错误以 throw 抛出(文案是契约的一部分)。
|
|
4
|
+
// start 流程:配置自愈 → env 注入(含端口预算:主服务 ELLM_PORT / 管理服务 ELLM_MANAGER_PORT = 主端口+1,
|
|
5
|
+
// 占用时自动顺延空闲端口)→ 两个服务一并提交 pm2(数组形式)→ 并行轮询探活 → dump;
|
|
6
|
+
// 管理服务已在线(部分恢复场景)则只补启主服务。
|
|
7
|
+
// 全程分步输出进度(「→ 启动 ...」+ 探活进度点),等待期间不留静默空窗。
|
|
8
|
+
import { readFileSync, mkdirSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { APP_NAME, MANAGER_APP_NAME, pkgRoot, defaultAppDir, ensureConfig, configPath } from "./paths.js";
|
|
11
|
+
import { withPm2, pm2cb, findApp, waitOnline, safeDump, sanitizePm2Env } from "./pm2.js";
|
|
12
|
+
import { resolveInterpreter } from "./interpreter.js";
|
|
13
|
+
import { isPortFree, findFreePort } from "./port.js";
|
|
14
|
+
import { ensureAutostartOnStart } from "./autostart.js";
|
|
15
|
+
import { ICONS, say, warn, begin, endOk, endFail } from "./ui.js";
|
|
16
|
+
|
|
17
|
+
/** 管理服务脚本(随包分发,start 时随主服务一并 pm2 托管) */
|
|
18
|
+
const MANAGER_INDEX_JS = join(pkgRoot, "manager", "index.js");
|
|
19
|
+
const DEFAULT_MANAGER_PORT = 29771; // 主端口无法解析时 manager 的最终兜底(默认网关 29770 + 1,与 manager/index.js 缺省一致)
|
|
20
|
+
|
|
21
|
+
/** 端口解析口径:portEnv(ELLM_PORT env)> 配置文件 port 字段 */
|
|
22
|
+
export function resolvePort(portEnv, configFile) {
|
|
23
|
+
let port = Number(portEnv) || 0;
|
|
24
|
+
if (!port) {
|
|
25
|
+
try {
|
|
26
|
+
port = JSON.parse(readFileSync(configFile, "utf-8")).port || 0;
|
|
27
|
+
} catch {
|
|
28
|
+
port = 0;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return port;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 网关地址与配置文件定位(harness 代理指向目标):ELLM_PORT env / 配置文件 port,与 start 命令端口口径一致 */
|
|
35
|
+
export function resolveGatewayBase(configOverride) {
|
|
36
|
+
const configFile = configPath(configOverride);
|
|
37
|
+
const port = resolvePort(process.env.ELLM_PORT, configFile);
|
|
38
|
+
return { base: `http://127.0.0.1:${port}`, configFile };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 启动服务(CLI restart 与管理服务 restart 复用):
|
|
42
|
+
* 配置自愈 → env 注入 → 两服务一并提交 pm2(数组形式)→ 并行轮询探活 → dump,
|
|
43
|
+
* 管理服务已在线则只补启主服务。全程分步进度输出。返回 { proc, managerProc, appDir, config } 供调用方输出摘要。 */
|
|
44
|
+
export async function doStart(opts) {
|
|
45
|
+
say(`正在启动 ${APP_NAME}(pm2 后台守护)...`, ICONS.rocket);
|
|
46
|
+
const config = ensureConfig(opts.config);
|
|
47
|
+
const appDir = opts.workingDir || defaultAppDir;
|
|
48
|
+
mkdirSync(join(appDir, "logs"), { recursive: true });
|
|
49
|
+
|
|
50
|
+
// server 端读取的路径基变量:显式值优先(CLI 参数 > 会话已有 > 默认推导)
|
|
51
|
+
process.env.ELLM_APP_DIR = process.env.ELLM_APP_DIR ?? appDir;
|
|
52
|
+
process.env.ELLM_CONFIG = config;
|
|
53
|
+
if (opts.dataDir) process.env.ELLM_DATA_DIR = opts.dataDir;
|
|
54
|
+
|
|
55
|
+
// 端口基准预算:主服务 = --port > ELLM_PORT env > 配置文件 port 字段(与 server 端读取口径一致)。
|
|
56
|
+
// 占用探测与注入在 withPm2 内做(管理服务是否在线影响其端口预算,findApp 需要 pm2 连接)。
|
|
57
|
+
const port = resolvePort(opts.port ?? process.env.ELLM_PORT, config);
|
|
58
|
+
|
|
59
|
+
const interpreter = await resolveInterpreter();
|
|
60
|
+
const script = opts.serverJs || join(pkgRoot, "dist", "server.js");
|
|
61
|
+
// ELLM_SERVER_JS:管理服务 restart 主服务时按同一路径重启,保证 --server-js 等参数不漂移
|
|
62
|
+
process.env.ELLM_SERVER_JS = script;
|
|
63
|
+
|
|
64
|
+
return withPm2(async () => {
|
|
65
|
+
if (await findApp()) {
|
|
66
|
+
throw new Error(`${APP_NAME} 已在运行。请先停止: ellm-proxy stop`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 主服务端口:基准被占(其他应用 / 孤儿进程)则自动顺延到空闲端口(默认 0.0.0.0 + 127.0.0.1 双维度,
|
|
70
|
+
// 见 utils/port.js),避免把注定冲突的端口交给 pm2 后陷入崩溃循环;顺延仅本次运行生效,不改配置文件。
|
|
71
|
+
let mainPort = port;
|
|
72
|
+
if (mainPort && !(await isPortFree(mainPort))) {
|
|
73
|
+
const wanted = mainPort;
|
|
74
|
+
mainPort = (await findFreePort(mainPort + 1)) || mainPort;
|
|
75
|
+
warn(
|
|
76
|
+
mainPort === wanted
|
|
77
|
+
? `端口 ${wanted} 已被占用且未找到空闲端口,仍按原端口启动(可能失败,请释放端口或修改配置)`
|
|
78
|
+
: `端口 ${wanted} 已被占用,主服务自动改用空闲端口 ${mainPort}(仅本次运行生效,不改配置文件)`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
if (mainPort) process.env.ELLM_PORT = String(mainPort);
|
|
82
|
+
|
|
83
|
+
// 管理服务随主服务一并托管(活在外面:主服务被 stop/重启后由它执行拉起;resurrect 时一并恢复)。
|
|
84
|
+
// 已在线则跳过启动(部分恢复场景幂等),并保持其现有端口(重新预算换端口会造成访问地址漂移)。
|
|
85
|
+
const existingManager = await findApp(MANAGER_APP_NAME);
|
|
86
|
+
let managerPort = 0;
|
|
87
|
+
if (existingManager) {
|
|
88
|
+
const runningPort = Number(existingManager.pm2_env?.env?.ELLM_MANAGER_PORT);
|
|
89
|
+
if (runningPort) process.env.ELLM_MANAGER_PORT = String(runningPort);
|
|
90
|
+
managerPort = Number(process.env.ELLM_MANAGER_PORT) || 0;
|
|
91
|
+
say(`管理服务 ${MANAGER_APP_NAME} 已在线(端口 ${managerPort || "?"}),跳过启动`, ICONS.skip);
|
|
92
|
+
} else {
|
|
93
|
+
// 管理服务端口 = 主服务端口 + 1(用户显式设置过 ELLM_MANAGER_PORT 则尊重);
|
|
94
|
+
// 被占则自动顺延(固定监听 127.0.0.1,单维度探测)。探测注入必须在 pm2 start 前,env 随之透传。
|
|
95
|
+
managerPort = Number(process.env.ELLM_MANAGER_PORT) || (mainPort ? mainPort + 1 : 0);
|
|
96
|
+
if (managerPort && !(await isPortFree(managerPort, ["127.0.0.1"]))) {
|
|
97
|
+
const wanted = managerPort;
|
|
98
|
+
managerPort = (await findFreePort(managerPort + 1, ["127.0.0.1"])) || managerPort;
|
|
99
|
+
warn(
|
|
100
|
+
managerPort === wanted
|
|
101
|
+
? `管理服务端口 ${wanted} 已被占用且未找到空闲端口,仍按原端口启动(可能失败,可用 ELLM_MANAGER_PORT 调整)`
|
|
102
|
+
: `管理服务端口 ${wanted} 已被占用,自动改用空闲端口 ${managerPort}(仅本次运行生效)`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
if (managerPort) process.env.ELLM_MANAGER_PORT = String(managerPort);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 两份 pm2 start 配置(单启主服务 / 双启一并提交两条路径共用);env 在端口注入后快照
|
|
109
|
+
const mainOpts = {
|
|
110
|
+
name: APP_NAME,
|
|
111
|
+
script,
|
|
112
|
+
interpreter,
|
|
113
|
+
cwd: appDir,
|
|
114
|
+
// 崩溃自动重启但封顶,防端口占用/配置损坏陷入无限重启
|
|
115
|
+
autorestart: true,
|
|
116
|
+
max_restarts: 10,
|
|
117
|
+
restart_delay: 3000,
|
|
118
|
+
merge_logs: true,
|
|
119
|
+
out_file: join(appDir, "logs", "ellm-out.log"),
|
|
120
|
+
error_file: join(appDir, "logs", "ellm-err.log"),
|
|
121
|
+
env: sanitizePm2Env(process.env),
|
|
122
|
+
};
|
|
123
|
+
const managerOpts = {
|
|
124
|
+
name: MANAGER_APP_NAME,
|
|
125
|
+
script: MANAGER_INDEX_JS,
|
|
126
|
+
interpreter,
|
|
127
|
+
cwd: appDir,
|
|
128
|
+
autorestart: true,
|
|
129
|
+
max_restarts: 10,
|
|
130
|
+
restart_delay: 3000,
|
|
131
|
+
merge_logs: true,
|
|
132
|
+
out_file: join(appDir, "logs", "manager-out.log"),
|
|
133
|
+
error_file: join(appDir, "logs", "manager-err.log"),
|
|
134
|
+
env: sanitizePm2Env(process.env),
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
if (existingManager) {
|
|
138
|
+
// 仅补启主服务(单服务,保持打点进度样式)
|
|
139
|
+
begin(`启动主服务 ${APP_NAME}${mainPort ? ` (端口 ${mainPort})` : ""} `);
|
|
140
|
+
await pm2cb("start", mainOpts);
|
|
141
|
+
const proc = await waitOnline(
|
|
142
|
+
APP_NAME,
|
|
143
|
+
`最可能是端口被占用或配置无效,请查看日志: ${join(appDir, "logs", "ellm-err.log")}`,
|
|
144
|
+
);
|
|
145
|
+
await safeDump();
|
|
146
|
+
return { proc, managerProc: existingManager, appDir, config };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// 双启:两个服务一并提交 pm2(数组形式,daemon 逐个拉起),等待期并行轮询、输出合并为一行。
|
|
150
|
+
// 主服务失败时管理服务往往已在线——其职责正是远程拉起主服务,故失败后不回收进程,文案给出重试路径。
|
|
151
|
+
begin(
|
|
152
|
+
`启动 ${APP_NAME}${mainPort ? ` (端口 ${mainPort})` : ""} + ${MANAGER_APP_NAME}${managerPort ? ` (端口 ${managerPort})` : ""} ...`,
|
|
153
|
+
);
|
|
154
|
+
await pm2cb("start", [mainOpts, managerOpts]);
|
|
155
|
+
const [mainRes, managerRes] = await Promise.allSettled([
|
|
156
|
+
waitOnline(APP_NAME, `最可能是端口被占用或配置无效,请查看日志: ${join(appDir, "logs", "ellm-err.log")}`, {
|
|
157
|
+
quiet: true,
|
|
158
|
+
}),
|
|
159
|
+
waitOnline(
|
|
160
|
+
MANAGER_APP_NAME,
|
|
161
|
+
`最可能是管理服务端口被占用(端口 ${process.env.ELLM_MANAGER_PORT || DEFAULT_MANAGER_PORT},可用 ELLM_MANAGER_PORT 调整),请查看日志: ${join(appDir, "logs", "manager-err.log")}`,
|
|
162
|
+
{ quiet: true },
|
|
163
|
+
),
|
|
164
|
+
]);
|
|
165
|
+
const failures = [mainRes, managerRes].filter((r) => r.status === "rejected");
|
|
166
|
+
if (failures.length) {
|
|
167
|
+
endFail();
|
|
168
|
+
throw new Error(failures.map((f) => f.reason?.message ?? String(f.reason)).join(";"));
|
|
169
|
+
}
|
|
170
|
+
endOk();
|
|
171
|
+
|
|
172
|
+
// dump 进程列表(~/.pm2/dump.pm2),供开机自启 pm2 resurrect 恢复(主服务 + 管理服务一并恢复)
|
|
173
|
+
await safeDump();
|
|
174
|
+
// 首启自愈:从未注册过自启项且用户未显式关闭过 → 静默注册(opt-out 标记尊重用户选择)
|
|
175
|
+
await ensureAutostartOnStart();
|
|
176
|
+
return { proc: mainRes.value, managerProc: managerRes.value, appDir, config };
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** 停止服务:主服务与管理服务一并删除(幂等)。返回 { main, manager } 表示各自此前是否在运行。 */
|
|
181
|
+
export async function doStop() {
|
|
182
|
+
return withPm2(async () => {
|
|
183
|
+
const proc = await findApp();
|
|
184
|
+
const managerProc = await findApp(MANAGER_APP_NAME);
|
|
185
|
+
if (!proc && !managerProc) return { main: false, manager: false };
|
|
186
|
+
// 先删管理服务(避免它在主服务停止后仍对外提供启停接口造成状态误判),再删主服务
|
|
187
|
+
if (managerProc) await pm2cb("delete", MANAGER_APP_NAME);
|
|
188
|
+
if (proc) await pm2cb("delete", APP_NAME);
|
|
189
|
+
await safeDump();
|
|
190
|
+
return { main: !!proc, manager: !!managerProc };
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** 仅停止主服务(管理服务保持存活;幂等)。管理服务仍在 dump 列表中,resurrect 后它会先起来
|
|
195
|
+
* 继续提供远程启停。管理服务的 stop 语义与 CLI 全停不同——管理服务若自杀,再无人能远程拉起主服务。
|
|
196
|
+
* 被 manager /stop 与 updater(更新前停主服务,manager 自身保持存活执行后续流程)复用。 */
|
|
197
|
+
export async function stopMainOnly() {
|
|
198
|
+
return withPm2(async () => {
|
|
199
|
+
const proc = await findApp();
|
|
200
|
+
if (!proc) return "already-stopped";
|
|
201
|
+
await pm2cb("delete", APP_NAME);
|
|
202
|
+
await safeDump();
|
|
203
|
+
return "stopped";
|
|
204
|
+
});
|
|
205
|
+
}
|
package/utils/updater.js
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// 自动更新核心:npm 全局包的检查 / 安装 / 看门狗轮询。
|
|
2
|
+
// 被 manager(manager/api/service-settings.js 挂载 + startUpdateWatchdog 常驻轮询)复用。
|
|
3
|
+
// 检查源 = npm 官方 registry(ELLM_UPDATE_REGISTRY 可覆盖);包名取 pkg.name(ellm-proxy)。
|
|
4
|
+
// 更新流程:stopMainOnly(停主服务释放文件句柄,manager 保持存活)→ npm i -g <pkg>@latest →
|
|
5
|
+
// doStart(startOpts)(主服务即新代码)→ 延迟 3s pm2 restart manager 自身(daemon 负责拉起,不依赖自身存活)。
|
|
6
|
+
// 开关状态 <appDir>/updater.json:{ enabled?, lastCheck?, lastApply? };文件缺失 = 默认开启。
|
|
7
|
+
// 仓库 dev 模式防护:pkgRoot 父目录不是 node_modules(源码运行)时更新域 unsupported,看门狗不启动。
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
10
|
+
import { basename, dirname, join } from "node:path";
|
|
11
|
+
import { pkg, pkgRoot, defaultAppDir, MANAGER_APP_NAME } from "./paths.js";
|
|
12
|
+
import { doStart, stopMainOnly } from "./service.js";
|
|
13
|
+
import { pm2cb, withPm2 } from "./pm2.js";
|
|
14
|
+
|
|
15
|
+
const UPDATER_SETTINGS_FILE = "updater.json";
|
|
16
|
+
const DEFAULT_REGISTRY = "https://registry.npmjs.org/";
|
|
17
|
+
const CHECK_INTERVAL_MS = 60 * 60 * 1000; // 每小时检查
|
|
18
|
+
const FIRST_CHECK_DELAY_MS = 30 * 1000; // 启动后 30s 首查(错开启动高峰)
|
|
19
|
+
|
|
20
|
+
/** 更新开关配置路径 */
|
|
21
|
+
function settingsPath() {
|
|
22
|
+
return join(defaultAppDir, UPDATER_SETTINGS_FILE);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 读取开关设置(文件缺失/损坏 = 默认开启);enabled 显式 false 才视为关闭 */
|
|
26
|
+
export function readUpdaterSettings() {
|
|
27
|
+
try {
|
|
28
|
+
const raw = JSON.parse(readFileSync(settingsPath(), "utf-8"));
|
|
29
|
+
return { enabled: raw.enabled !== false, lastCheck: raw.lastCheck ?? null, lastApply: raw.lastApply ?? null };
|
|
30
|
+
} catch {
|
|
31
|
+
return { enabled: true, lastCheck: null, lastApply: null };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 合并写回开关设置(patch 浅合并;写盘失败不 throw——检查结果落盘失败不影响主流程) */
|
|
36
|
+
export function writeUpdaterSettings(patch) {
|
|
37
|
+
const next = { ...readUpdaterSettings(), ...patch };
|
|
38
|
+
try {
|
|
39
|
+
mkdirSync(defaultAppDir, { recursive: true });
|
|
40
|
+
writeFileSync(settingsPath(), JSON.stringify(next, null, 2) + "\n", "utf-8");
|
|
41
|
+
} catch {
|
|
42
|
+
/* 只读盘等场景容忍:内存态仍可用 */
|
|
43
|
+
}
|
|
44
|
+
return next;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 仓库 dev 模式判定:包根(或 scoped 包的上一级 @scope 目录)不在 node_modules 下 = 源码运行,
|
|
48
|
+
* 更新域不可用(防 dev 会话误更新全局包)。全局安装路径形如 <prefix>/node_modules/<pkg>,
|
|
49
|
+
* scoped 包为 <prefix>/node_modules/@scope/<pkg>,故向上最多探两层。 */
|
|
50
|
+
export function isUpdateSupported() {
|
|
51
|
+
const parent = dirname(pkgRoot);
|
|
52
|
+
return basename(parent) === "node_modules" || basename(dirname(parent)) === "node_modules";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** x.y.z 逐段数字比较(prerelease/脏串按 0 段处理:私有源版本号由 release.mjs 保证三段式) */
|
|
56
|
+
export function isNewerVersion(latest, current) {
|
|
57
|
+
const parse = (v) => String(v || "").split(".").map((n) => Number(n) || 0);
|
|
58
|
+
const a = parse(latest);
|
|
59
|
+
const b = parse(current);
|
|
60
|
+
for (let i = 0; i < 3; i++) {
|
|
61
|
+
if ((a[i] ?? 0) !== (b[i] ?? 0)) return (a[i] ?? 0) > (b[i] ?? 0);
|
|
62
|
+
}
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 更新源 registry(env ELLM_UPDATE_REGISTRY 可覆盖,默认私有 group 源) */
|
|
67
|
+
export function updateRegistry() {
|
|
68
|
+
return process.env["ELLM_UPDATE_REGISTRY"] || DEFAULT_REGISTRY;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** spawn 执行 npm 命令并收集 stdout(shell:true 兼容 Windows npm.cmd;先例 scripts/release.mjs) */
|
|
72
|
+
function runNpm(args, timeoutMs) {
|
|
73
|
+
return new Promise((resolvePromise, reject) => {
|
|
74
|
+
// windowsHide: true 必须带——manager 是无控制台的 pm2 后台进程,shell:true 经 cmd.exe 跑 npm.cmd
|
|
75
|
+
// 时 Windows 会给命令链分配新控制台(web-ui 点更新/看门狗 tick 都会弹 cmd 黑窗),此选项全程隐藏
|
|
76
|
+
const child = spawn(`npm ${args.join(" ")}`, { shell: true, stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
|
|
77
|
+
let stdout = "";
|
|
78
|
+
let stderr = "";
|
|
79
|
+
const timer = setTimeout(() => {
|
|
80
|
+
child.kill();
|
|
81
|
+
reject(new Error(`npm ${args[0]} 超时(${Math.round(timeoutMs / 1000)}s)`));
|
|
82
|
+
}, timeoutMs);
|
|
83
|
+
child.stdout.on("data", (d) => (stdout += d));
|
|
84
|
+
child.stderr.on("data", (d) => (stderr += d));
|
|
85
|
+
child.on("error", (err) => {
|
|
86
|
+
clearTimeout(timer);
|
|
87
|
+
reject(err);
|
|
88
|
+
});
|
|
89
|
+
child.on("exit", (code) => {
|
|
90
|
+
clearTimeout(timer);
|
|
91
|
+
if (code === 0) resolvePromise(stdout.trim());
|
|
92
|
+
else reject(new Error(`npm ${args[0]} 失败(exit ${code}):${stderr.trim().slice(-400) || "无错误输出"}`));
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 查询 registry 上该包的最新版本(npm view) */
|
|
98
|
+
async function fetchLatestVersion() {
|
|
99
|
+
const out = await runNpm(["view", pkg.name, "version", "--registry", updateRegistry()], 60 * 1000);
|
|
100
|
+
const version = out.split("\n").filter(Boolean).pop()?.trim();
|
|
101
|
+
if (!version || !/^\d+\.\d+\.\d+/.test(version)) {
|
|
102
|
+
throw new Error(`无法解析 npm view 输出版本号: ${out.slice(0, 200)}`);
|
|
103
|
+
}
|
|
104
|
+
return version;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** 检查更新:查询最新版本并与当前比较,结果落 lastCheck。返回 { hasUpdate, currentVersion, latestVersion, checkedAt } */
|
|
108
|
+
export async function checkForUpdate() {
|
|
109
|
+
const latestVersion = await fetchLatestVersion();
|
|
110
|
+
const result = {
|
|
111
|
+
hasUpdate: isNewerVersion(latestVersion, pkg.version),
|
|
112
|
+
currentVersion: pkg.version,
|
|
113
|
+
latestVersion,
|
|
114
|
+
checkedAt: Date.now(),
|
|
115
|
+
};
|
|
116
|
+
writeUpdaterSettings({ lastCheck: result });
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// —— 安装与重启 ————————————————————————————————————————————————————————
|
|
121
|
+
|
|
122
|
+
/** 更新执行互斥锁:手动触发与看门狗 tick 并发时只放行一个(其余收到明确报错) */
|
|
123
|
+
let updating = false;
|
|
124
|
+
|
|
125
|
+
export function isUpdating() {
|
|
126
|
+
return updating;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 执行更新(内部先查版本,无新版直接返回 updated:false):停主服务 → npm i -g →
|
|
130
|
+
* 重启主服务(新代码)→ 3s 后重启 manager 自身换新代码。安装失败 → 回滚为主服务旧版本继续服务。
|
|
131
|
+
* startOpts = 主服务启动参数(与 manager/index.js 的 startOpts 同源,保证重启后参数不漂移)。
|
|
132
|
+
* 返回 { fromVersion, toVersion, updated }。 */
|
|
133
|
+
export async function performUpdate(startOpts) {
|
|
134
|
+
if (updating) throw new Error("已有更新在执行中,请稍后再试。");
|
|
135
|
+
updating = true;
|
|
136
|
+
const fromVersion = pkg.version;
|
|
137
|
+
try {
|
|
138
|
+
const latest = await fetchLatestVersion();
|
|
139
|
+
if (!isNewerVersion(latest, fromVersion)) {
|
|
140
|
+
const result = { hasUpdate: false, currentVersion: fromVersion, latestVersion: latest, checkedAt: Date.now() };
|
|
141
|
+
writeUpdaterSettings({ lastCheck: result });
|
|
142
|
+
return { fromVersion, toVersion: fromVersion, updated: false };
|
|
143
|
+
}
|
|
144
|
+
// 停主服务释放 dist 文件句柄(Windows 上 npm 替换全局目录时句柄占用会致安装失败);
|
|
145
|
+
// manager 保持存活执行后续流程
|
|
146
|
+
await stopMainOnly();
|
|
147
|
+
try {
|
|
148
|
+
await runNpm(["install", "-g", `${pkg.name}@latest`, "--registry", updateRegistry()], 10 * 60 * 1000);
|
|
149
|
+
} catch (err) {
|
|
150
|
+
// 安装失败:旧版本继续服务(doStart 用的仍是当前包代码)
|
|
151
|
+
await doStart(startOpts).catch(() => {});
|
|
152
|
+
throw new Error(`安装新版本失败,已恢复旧版本 ${fromVersion} 继续服务。${err?.message ?? err}`);
|
|
153
|
+
}
|
|
154
|
+
writeUpdaterSettings({ lastApply: { fromVersion, toVersion: latest, appliedAt: Date.now() } });
|
|
155
|
+
await doStart(startOpts);
|
|
156
|
+
// 延迟重启 manager 自身:让本次 HTTP 响应先送达,pm2 daemon 负责拉起新代码
|
|
157
|
+
setTimeout(() => {
|
|
158
|
+
withPm2(() => pm2cb("restart", MANAGER_APP_NAME)).catch(() => {});
|
|
159
|
+
}, 3000);
|
|
160
|
+
return { fromVersion, toVersion: latest, updated: true };
|
|
161
|
+
} finally {
|
|
162
|
+
updating = false;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** 检查到新版后的一步式自动处理(看门狗 tick 用):返回是否执行了更新 */
|
|
167
|
+
async function autoUpdateIfNeeded(startOpts) {
|
|
168
|
+
const check = await checkForUpdate();
|
|
169
|
+
if (!check.hasUpdate) return false;
|
|
170
|
+
console.log(`[updater] 发现新版本 ${check.latestVersion}(当前 ${check.currentVersion}),开始自动更新...`);
|
|
171
|
+
const result = await performUpdate(startOpts);
|
|
172
|
+
if (result.updated) {
|
|
173
|
+
console.log(`[updater] 已更新 ${result.fromVersion} → ${result.toVersion},主服务已重启,manager 将延迟自重启`);
|
|
174
|
+
}
|
|
175
|
+
return result.updated;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// —— 看门狗 ————————————————————————————————————————————————————————
|
|
179
|
+
|
|
180
|
+
let watchdogTimer = null;
|
|
181
|
+
|
|
182
|
+
/** 启动更新看门狗(manager 启动时调用):更新域受支持且开关开启时,
|
|
183
|
+
* 启动后 30s 首查 + 每小时轮询;每次 tick 重读开关,已关闭则停表(下次 manager 启动再评估)。
|
|
184
|
+
* tick 内检查/更新失败只记日志(下个周期再试),绝不 throw——看门狗不能把 manager 打崩。
|
|
185
|
+
* startOpts = 主服务启动参数(透传 performUpdate → doStart,与 manager/index.js 同源)。 */
|
|
186
|
+
export async function startUpdateWatchdog(startOpts) {
|
|
187
|
+
if (!isUpdateSupported()) return; // 源码运行:不轮询不检查
|
|
188
|
+
const { enabled } = readUpdaterSettings();
|
|
189
|
+
if (!enabled) return;
|
|
190
|
+
if (watchdogTimer) return; // 已在跑(幂等)
|
|
191
|
+
|
|
192
|
+
const tick = async () => {
|
|
193
|
+
const { enabled: nowEnabled } = readUpdaterSettings();
|
|
194
|
+
if (!nowEnabled) {
|
|
195
|
+
clearInterval(watchdogTimer);
|
|
196
|
+
watchdogTimer = null;
|
|
197
|
+
console.log("[updater] 自动更新已关闭,看门狗停止");
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
try {
|
|
201
|
+
await autoUpdateIfNeeded(startOpts);
|
|
202
|
+
} catch (err) {
|
|
203
|
+
console.warn(`[updater] 自动检查/更新失败(下个周期重试): ${err?.message ?? err}`);
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
setTimeout(tick, FIRST_CHECK_DELAY_MS).unref?.();
|
|
208
|
+
watchdogTimer = setInterval(tick, CHECK_INTERVAL_MS);
|
|
209
|
+
console.log(`[updater] 更新看门狗已启动(每 ${CHECK_INTERVAL_MS / 60000} 分钟检查,源 ${updateRegistry()})`);
|
|
210
|
+
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
.copy-text[data-v-a652207c]{color:#409eff;cursor:pointer}.copy-text[data-v-a652207c]:hover{text-decoration:underline}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{d as C,c as y,a as t,w as o,b as l,r as s,o as V,e as r,t as W,u as B,f as D,g as L,h as N}from"./index-DFlUx18F.js";const S={class:"layout"},A={class:"header-nav"},E={class:"header-actions"},I={class:"main"},R={class:"page"},M=C({__name:"LayoutView",setup(T){const d=N(),i=L(),u=(()=>{const n=localStorage.getItem("webui_token")||"";try{return atob(n).split(":")[0]||"用户"}catch{return"用户"}})(),_=D(()=>{const n=d.path;return n==="/"?"/":n.startsWith("/providers")?"/providers":n.startsWith("/settings")?"/settings":n.startsWith("/logs")?"/logs":n.startsWith("/process")?"/process":"/"}),c=()=>{localStorage.removeItem("webui_token"),i.push("/login")},m=n=>{n==="logout"&&c()};return(n,e)=>{const a=s("el-menu-item"),p=s("el-menu"),f=s("ArrowDown"),g=s("el-icon"),w=s("el-button"),v=s("el-dropdown-item"),h=s("el-dropdown-menu"),x=s("el-dropdown"),b=s("el-header"),k=s("router-view");return V(),y("div",S,[t(b,{class:"header"},{default:o(()=>[e[6]||(e[6]=l("h1",null,[l("span",null,"E"),r("LLM")],-1)),l("nav",A,[t(p,{mode:"horizontal","default-active":_.value,ellipsis:!1,router:"",class:"header-menu"},{default:o(()=>[t(a,{index:"/"},{default:o(()=>[...e[0]||(e[0]=[r("首页",-1)])]),_:1}),t(a,{index:"/providers"},{default:o(()=>[...e[1]||(e[1]=[r("供应商管理",-1)])]),_:1}),t(a,{index:"/logs"},{default:o(()=>[...e[2]||(e[2]=[r("日志",-1)])]),_:1}),t(a,{index:"/settings"},{default:o(()=>[...e[3]||(e[3]=[r("设置",-1)])]),_:1}),t(a,{index:"/process"},{default:o(()=>[...e[4]||(e[4]=[r("进程管理",-1)])]),_:1})]),_:1},8,["default-active"])]),l("div",E,[t(x,{trigger:"click",onCommand:m,class:"ml-12"},{dropdown:o(()=>[t(h,null,{default:o(()=>[t(v,{command:"logout"},{default:o(()=>[...e[5]||(e[5]=[r("退出",-1)])]),_:1})]),_:1})]),default:o(()=>[t(w,null,{default:o(()=>[r(W(B(u))+" ",1),t(g,{class:"el-icon--right"},{default:o(()=>[t(f)]),_:1})]),_:1})]),_:1})])]),_:1}),l("main",I,[l("div",R,[t(k)])])])}}});export{M as default};
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{d as b,c as h,a as o,w as l,r as a,o as _,b as k,Z as x,X as f,j as C,k as L,e as B,l as i,g as N,_ as I}from"./index-DFlUx18F.js";import{A as K}from"./useApi-BRPinb_2.js";const A={class:"login-wrap"},E=b({__name:"LoginView",setup(M){const v=N(),n=i(""),s=i(""),t=i(""),d=i(!1);async function r(){if(!n.value){t.value="请输入用户名";return}if(!s.value){t.value="请输入密码";return}d.value=!0,t.value="";try{const u=await K.post("/api/auth/login",{username:n.value.trim(),password:s.value});localStorage.setItem("webui_token",u.token),v.push("/")}catch(u){t.value=u.message||"登录失败"}finally{d.value=!1}}return(u,e)=>{const c=a("el-input"),m=a("el-form-item"),w=a("el-alert"),g=a("el-button"),V=a("el-form"),y=a("el-card");return _(),h("div",A,[o(y,{shadow:"hover",class:"login-card"},{default:l(()=>[e[3]||(e[3]=k("h2",{class:"login-title"},"ELLM",-1)),o(V,{"label-position":"top",onSubmit:x(r,["prevent"])},{default:l(()=>[o(m,{label:"用户名"},{default:l(()=>[o(c,{modelValue:n.value,"onUpdate:modelValue":e[0]||(e[0]=p=>n.value=p),placeholder:"请输入用户名",onKeyup:f(r,["enter"])},null,8,["modelValue"])]),_:1}),o(m,{label:"密码"},{default:l(()=>[o(c,{modelValue:s.value,"onUpdate:modelValue":e[1]||(e[1]=p=>s.value=p),type:"password",placeholder:"请输入密码","show-password":"",onKeyup:f(r,["enter"])},null,8,["modelValue"])]),_:1}),t.value?(_(),C(w,{key:0,title:t.value,type:"error","show-icon":"",class:"mb-16"},null,8,["title"])):L("",!0),o(g,{type:"primary",class:"full-width",loading:d.value,onClick:r},{default:l(()=>[...e[2]||(e[2]=[B(" 登录 ",-1)])]),_:1},8,["loading"])]),_:1})]),_:1})])}}}),j=I(E,[["__scopeId","data-v-d5b6a90c"]]);export{j as default};
|