@kne/fastify-app-manager 0.1.8 → 0.1.10
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 +198 -21
- package/index.js +17 -0
- package/libs/controllers/app.js +167 -0
- package/libs/controllers/gateway.js +16 -2
- package/libs/services/app.js +175 -28
- package/libs/services/bootstrap.js +160 -3
- package/libs/services/dbops.js +2 -10
- package/libs/utils/dbIdentity.js +16 -7
- package/libs/utils/env.js +39 -7
- package/libs/utils/loadMonitor.js +73 -0
- package/libs/utils/logFiles.js +168 -20
- package/libs/utils/logRotate.js +162 -0
- package/libs/utils/logTime.js +132 -0
- package/libs/utils/pathRewrite.js +28 -3
- package/libs/utils/pm2.js +7 -0
- package/libs/utils/requestMetrics.js +132 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@ npm i --save @kne/fastify-app-manager
|
|
|
22
22
|
|
|
23
23
|
适用场景:内网/堡垒机上的业务应用集中托管;不负责多机编排、硬磁盘配额或 WebSocket 升级。
|
|
24
24
|
|
|
25
|
-
> **关键设计**:子应用前端通过部署时注入的 `runtimePublicUrl` / `runtimeApiUrl` 感知对外前缀;网关对路径模式剥前缀反代,并改写 Location / Cookie / 文本 body
|
|
25
|
+
> **关键设计**:子应用前端通过部署时注入的 `runtimePublicUrl` / `runtimeApiUrl` 感知对外前缀;网关对路径模式剥前缀反代,并改写 Location / Cookie / 文本 body 兜底(JS 脚本不改写,避免与 runtimeApiUrl 重复拼前缀)。Scheme B(子进程挂 `APP_BASE_PATH`)已弃用。
|
|
26
26
|
>
|
|
27
27
|
> **存储隔离**:同时注入 `window.__LOCAL_STORAGE_PREFIX="<应用名>"`,`@kne/local-storage`(≥0.1.2,`@kne/token-storage` 底层依赖)读到后自动给 key 加 `<应用名>:` 前缀,避免同源挂载的子应用与宿主、其它子应用互相覆盖登录 token 等本地存储。
|
|
28
28
|
|
|
@@ -50,8 +50,8 @@ Client / Admin UI
|
|
|
50
50
|
| Domain gateway | Host 精确匹配且状态为 `running` 时透明反代,不改写路径 |
|
|
51
51
|
| Path gateway | `/app/{name}` 剥前缀;改写绝对路径 Location、Cookie Path、文本类 body |
|
|
52
52
|
| 未运行兜底 | 应用存在但未 `running` 时,网关返回 503 状态页(已停止 / 启动中 / 异常 / 未部署),接口请求返回 JSON,不再落到宿主 404;`/app/{name}` 下应用不存在时返回 404「应用不存在」页 |
|
|
53
|
-
| PM2 | 每应用一进程,名 `app-manager__{name}`;stdout/stderr 落 `appsRoot/{name}/logs
|
|
54
|
-
| Bootstrap | `onReady` 连接 PM2、挂 log bus
|
|
53
|
+
| PM2 | 每应用一进程,名 `app-manager__{name}`;stdout/stderr 由 PM2 落 `appsRoot/{name}/logs`,插件按大小/按天切分归档 |
|
|
54
|
+
| Bootstrap | `onReady` 连接 PM2、挂 log bus(只推 SSE)、启动日志切分与负载采样定时器;**异步**对托管应用做 `syncAllStatuses`(不阻塞父应用 ready) |
|
|
55
55
|
|
|
56
56
|
##### 部署时序
|
|
57
57
|
|
|
@@ -106,8 +106,8 @@ deploy(versionId)
|
|
|
106
106
|
| `save-env` patch | `null` 删除键;`********` 对 secret 键表示保持原值 |
|
|
107
107
|
| 显式密钥 | 存于 `options.secretEnvKeys`;`save-env` 可传 `secretEnvKeys` 将普通键标为密钥;响应顶层回传合并后的键名列表(不含明文) |
|
|
108
108
|
| 默认托管库 | `defaultAppDb`(默认 `appsRoot/_shared/apps-data.sqlite`),与宿主主库强制分离;应用未配 `DB_*` 时注入 |
|
|
109
|
-
| 应用自有库 | 应用 `env` 含完整 `DB_*` 时为独享库(`dbScope=dedicated
|
|
110
|
-
| 共享库表前缀 | 启动时注入 `DB_TABLE_PREFIX=t_{appName}_
|
|
109
|
+
| 应用自有库 | 应用 `env` 含完整 `DB_*` 且 `DB_DEDICATED` 不为 `false` 时为独享库(`dbScope=dedicated`);`DB_DEDICATED=false` 保留配置但回落共享库。`dbScope` 始终由 env 推导,与启动注入的连接一致;独享库若带 `DB_TABLE_PREFIX`(与其它系统共用库)只认该前缀的表 |
|
|
110
|
+
| 共享库表前缀 | 启动时注入 `DB_TABLE_PREFIX=t_{appName}_` 并由系统写回应用 env(接口不返回、不可修改);子应用 sequelize 须读取该变量建表 |
|
|
111
111
|
| 表归属 | `options.ownedTables`;共享库启动就绪后按前缀自动认领,亦可 `sync-owned` 扫描;运维仅限归属表 |
|
|
112
112
|
|
|
113
113
|
##### SQL 迁移
|
|
@@ -122,7 +122,9 @@ deploy(versionId)
|
|
|
122
122
|
| 短部署 | `deploy` / `start` / `restart` 立即返回 `deploying`,后台按 PM2 真实状态 + healthCheck 写回 `running`/`error`/`stopped` |
|
|
123
123
|
| 生命周期 | `start` / `stop` / `restart` / `remove`;父应用重启后异步 `syncAllStatuses` 对齐 PM2 |
|
|
124
124
|
| 双通道访问 | 自定义域名透明反代 + `/app/{name}` 剥前缀反代 |
|
|
125
|
-
| 日志 |
|
|
125
|
+
| 日志 | PM2 落盘 + 历史分页(`beforeLine` 上滚、每页最多 100 行)+ SSE(含回放行与心跳) |
|
|
126
|
+
| 日志切分与归档 | 按大小(`logMaxSize`)与按天(`logTimezone` 零点,默认 +08:00)切分;归档 gzip、按数量/天数清理;接口可列出、分页读取、下载(单个或多选打包 zip)、删除归档;返回的行时间前缀换算到 `logTimezone` |
|
|
127
|
+
| 负载监控 | 每 5 秒采样 PM2 进程指标(CPU、内存、运行时长、重启次数)与网关请求指标(QPS、每分钟请求数、平均 RT / P95 / P99、5xx 错误率、上游异常、并发);内存保留最近 10 分钟,SSE 连接先回放历史再实时推送 |
|
|
126
128
|
| 鉴权 | `createAuthenticate` 可注入;默认尝试 `fastify.account.authenticate.admin` |
|
|
127
129
|
| 数据运维 | 归属表浏览、行 CRUD、只读 query SQL、导出 zip、cleanup(无权限返回 SQL) |
|
|
128
130
|
|
|
@@ -189,6 +191,9 @@ await fastify.listen({ port: 3000 });
|
|
|
189
191
|
| `pathPrefix` | string | 否 | `'/app'` | 路径访问前缀(无尾斜杠亦可) |
|
|
190
192
|
| `passthroughEnvKeys` | array | 否 | `[]` | 允许注入子进程的宿主 env 键名 |
|
|
191
193
|
| `secretEnvKeyPattern` | RegExp | 否 | `/(SECRET\|PASSWORD\|TOKEN\|KEY\|PRIVATE)/i` | 按键名正则脱敏;与显式 `secretEnvKeys` 取并集 |
|
|
194
|
+
| `systemEnvKeys` | array | 否 | `[]` | 宿主维护的系统变量键名,与内置 `DB_TABLE_PREFIX` 合并:接口不返回,create / save / saveEnv 不可新增、修改或删除 |
|
|
195
|
+
| `resolveSystemEnv` | function | 否 | `null` | `async ({ app, version, serverDir }) => env \| null`,每次启动进程(deploy / start / restart / 恢复)前调用;返回值覆盖应用变量注入子进程,值为 `null` / `undefined` 的键忽略,属于 `systemEnvKeys` 的键持久化到 `app.env` |
|
|
196
|
+
| `onAppRemoved` | function | 否 | `null` | `async ({ app }) => void`,应用删除成功后调用,`app` 为删除前快照(含 `env`);数据清理未完成而未删除时不调用,钩子报错只记 warn 日志 |
|
|
192
197
|
| `defaultAppDb` | object | 否 | `appsRoot/_shared/apps-data.sqlite` | 托管应用默认库(须与宿主主库分离) |
|
|
193
198
|
| `dbQueryMaxRows` | number | 否 | `500` | 只读 query SQL 最大返回行数 |
|
|
194
199
|
| `pm2Defaults` | object | 否 | 见下表 | 全局 PM2 默认;可被应用 `pm2Config` 覆盖 |
|
|
@@ -202,7 +207,15 @@ await fastify.listen({ port: 3000 });
|
|
|
202
207
|
| `npmInstallTimeoutMs` | number | 否 | `600000` | `server` 目录 `npm install` 超时 |
|
|
203
208
|
| `sseReplayLines` | number | 否 | `100` | SSE 连接时回放最近行数 |
|
|
204
209
|
| `sseHeartbeatMs` | number | 否 | `15000` | SSE 心跳间隔 |
|
|
205
|
-
| `logMaxSize` | number | 否 | `52428800` |
|
|
210
|
+
| `logMaxSize` | number | 否 | `52428800` | 单日志文件上限(50MB),达到即切分;`0` 关闭按大小切分 |
|
|
211
|
+
| `logTimezone` | string | 否 | `'+08:00'` | 日志时区:固定偏移(`'+08:00'`、`'-05:30'`)或 IANA 名称(`'Asia/Shanghai'`);非法值注册时抛错 |
|
|
212
|
+
| `logRotateDaily` | boolean | 否 | `true` | 按天切分(按 `logTimezone` 的零点) |
|
|
213
|
+
| `logRotateIntervalMs` | number | 否 | `60000` | 切分检查间隔(毫秒);`0` 关闭定时切分 |
|
|
214
|
+
| `logRetentionMaxFiles` | number | 否 | `10` | 每个 stream 最多保留的归档数;`0` 不限 |
|
|
215
|
+
| `logRetentionDays` | number | 否 | `30` | 归档最长保留天数(按文件修改时间);`0` 不限 |
|
|
216
|
+
| `logCompress` | boolean | 否 | `true` | 归档 gzip 压缩为 `.log.gz` |
|
|
217
|
+
| `loadSampleIntervalMs` | number | 否 | `5000` | 负载采样间隔(毫秒):PM2 进程指标 + 网关请求指标;`0` 关闭采样 |
|
|
218
|
+
| `loadHistoryMinutes` | number | 否 | `10` | 每个应用在内存中保留的负载历史时长(分钟),SSE 连接时回放 |
|
|
206
219
|
| `logRetentionMaxRows` | number | 否 | `10000` | 预留:日志行保留上限 |
|
|
207
220
|
| `sqlPath` | string | 否 | `'sql'` | 相对 `server/` 的 SQL 目录名 |
|
|
208
221
|
| `migrateBeforeStart` | boolean | 否 | `false` | 为 true 且版本含迁移时,启动前执行 SQL |
|
|
@@ -243,11 +256,12 @@ await fastify.listen({ port: 3000 });
|
|
|
243
256
|
| description | string | 否 | - | 描述 |
|
|
244
257
|
| category | object / string | 否 | - | 应用分类(应用中心分组用),写入 `options.category`;推荐传 fastify-group 分组对象,对象只保留 `{ code, name }`,字符串原样保存 |
|
|
245
258
|
| isPublic | boolean | 否 | `true` | 是否在公开应用中心展示,写入 `options.isPublic`;未设置视为公开 |
|
|
259
|
+
| entries | array | 否 | `[]` | 应用入口 `[{ label, path }]`,写入 `options.entries`;`path` 相对应用根(缺前导 `/` 自动补),缺 `label` 或 `path` 的项丢弃,同 `path` 只保留第一项 |
|
|
246
260
|
| env | object | 否 | `{}` | 应用自有环境变量 |
|
|
247
261
|
| pm2Config | object | 否 | `{}` | PM2 覆盖项 |
|
|
248
262
|
| options | object | 否 | `{}` | 扩展字段;可含 `secretEnvKeys: string[]` 显式密钥键名 |
|
|
249
263
|
|
|
250
|
-
返回脱敏后的应用对象(含 `port`、`pathUrl`、`category`、`passthroughEnvKeys`、`secretEnvKeys`、`status: 'idle'` 等)。
|
|
264
|
+
返回脱敏后的应用对象(含 `port`、`pathUrl`、`category`、`entries`(每项附 `url` = 挂载前缀 + `path`)、`passthroughEnvKeys`、`secretEnvKeys`、`status: 'idle'` 等)。
|
|
251
265
|
|
|
252
266
|
##### POST `{prefix}/app/save`
|
|
253
267
|
|
|
@@ -259,6 +273,7 @@ await fastify.listen({ port: 3000 });
|
|
|
259
273
|
| label / domain / icon / description / env / pm2Config / options | - | 否 | 传入的字段才更新;`env` 按 patch 语义合并 |
|
|
260
274
|
| category | object / string / null | 否 | 合并写入 `options.category`(对象只保留 `{ code, name }`),不影响 `options` 其它键;传空值清除分类 |
|
|
261
275
|
| isPublic | boolean | 否 | 合并写入 `options.isPublic`,不影响 `options` 其它键 |
|
|
276
|
+
| entries | array / null | 否 | 整体替换 `options.entries`(规则同创建),不影响 `options` 其它键;传 `[]` / `null` 清空入口 |
|
|
262
277
|
|
|
263
278
|
##### POST `{prefix}/app/save-env`
|
|
264
279
|
|
|
@@ -268,7 +283,7 @@ await fastify.listen({ port: 3000 });
|
|
|
268
283
|
|--------|------|------|------|
|
|
269
284
|
| name | string | 是 | 应用 slug |
|
|
270
285
|
| env | object | 是 | patch:`null` 删键;secret 键值为 `********` 时保持原值 |
|
|
271
|
-
| secretEnvKeys | array | 否 |
|
|
286
|
+
| secretEnvKeys | array | 否 | 新增的显式密钥键名,与原列表合并写入 `options.secretEnvKeys`(仅保留仍存在于 env 的键)。密钥类型不可撤销,只有删除该变量才会移出列表 |
|
|
272
287
|
|
|
273
288
|
> **关键设计**:密钥判定 = 键名匹配 `secretEnvKeyPattern` **或** 出现在 `options.secretEnvKeys`。响应顶层 `secretEnvKeys` 为二者并集,便于管理端把普通变量标成密钥而不改键名。
|
|
274
289
|
|
|
@@ -276,7 +291,7 @@ await fastify.listen({ port: 3000 });
|
|
|
276
291
|
|
|
277
292
|
解析后的应用库:应用 `env` 的 `DB_*` 优先,否则注入 `defaultAppDb`。共享库(未自配 `DB_*`)仅能操作 `options.ownedTables`。
|
|
278
293
|
|
|
279
|
-
> **关键设计**:共享库启动时注入 `DB_TABLE_PREFIX=t_{appName}_
|
|
294
|
+
> **关键设计**:共享库启动时注入 `DB_TABLE_PREFIX=t_{appName}_`,由系统写回应用 env 并维护:所有返回 env 的接口都不包含该键,`save` / `save-env` / `create` 传入的该键(含 `null` 删除)一律忽略。子应用若使用 `@kne/fastify-sequelize@>=4.0.4`,存在该环境变量时默认 `forcePrefix`,连接上所有模型(含 account/message/tenant)表名必须以该前缀开头,无法被 `addModels({ prefix })` 覆盖。就绪后按该前缀自动认领表。
|
|
280
295
|
|
|
281
296
|
| 方法 | 路径 | 说明 |
|
|
282
297
|
|------|------|------|
|
|
@@ -416,7 +431,11 @@ await fastify.listen({ port: 3000 });
|
|
|
416
431
|
"description": "d",
|
|
417
432
|
"category": { "code": "tools", "name": "工具" },
|
|
418
433
|
"isPublic": true,
|
|
419
|
-
"pathUrl": "/app/demo/"
|
|
434
|
+
"pathUrl": "/app/demo/",
|
|
435
|
+
"entries": [
|
|
436
|
+
{ "label": "PC", "path": "/", "url": "/app/demo/" },
|
|
437
|
+
{ "label": "移动端", "path": "/mobile", "url": "/app/demo/mobile" }
|
|
438
|
+
]
|
|
420
439
|
}
|
|
421
440
|
],
|
|
422
441
|
"totalCount": 1
|
|
@@ -448,26 +467,82 @@ await fastify.listen({ port: 3000 });
|
|
|
448
467
|
|
|
449
468
|
##### GET `{prefix}/app/logs`
|
|
450
469
|
|
|
451
|
-
读取日志文件尾部分页(较新页在前)。`perPage` 上限 100。停止实时后前端可用 `beforeLine`
|
|
470
|
+
读取日志文件尾部分页(较新页在前)。`perPage` 上限 100。停止实时后前端可用 `beforeLine` 向上滚动加载更早日志。传 `file` 时读取指定归档(含 `.log.gz`),否则读当前 `stream` 文件。
|
|
452
471
|
|
|
453
472
|
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|
|
454
473
|
|--------|------|------|--------|------|
|
|
455
474
|
| name | string | 是 | - | 应用 slug |
|
|
456
475
|
| stream | string | 否 | `'out'` | `out` / `err` |
|
|
476
|
+
| file | string | 否 | - | 日志文件名(来自 `logs/files`);优先于 `stream`;非法名返回 400,不存在返回 404 |
|
|
457
477
|
| perPage | number | 否 | `100` | 每页行数(最大 100) |
|
|
458
478
|
| currentPage | number | 否 | `1` | 页码(1=最新一页;与 `beforeLine` 二选一) |
|
|
459
479
|
| beforeLine | number | 否 | - | 取行号 `< beforeLine` 的尾部至多 `perPage` 行(上滚历史) |
|
|
460
480
|
|
|
461
|
-
|
|
481
|
+
返回(行首 PM2 时间前缀已换算到 `logTimezone`):
|
|
462
482
|
|
|
463
483
|
```json
|
|
464
484
|
{
|
|
465
|
-
"pageData": [{ "line": 12, "content": "..." }],
|
|
485
|
+
"pageData": [{ "line": 12, "content": "2026-10-08T18:30:00: ..." }],
|
|
466
486
|
"totalCount": 12,
|
|
467
487
|
"hasMore": true
|
|
468
488
|
}
|
|
469
489
|
```
|
|
470
490
|
|
|
491
|
+
##### GET `{prefix}/app/logs/files`
|
|
492
|
+
|
|
493
|
+
列出应用 `logs/` 下的当前文件与归档,当前文件在前,归档按修改时间倒序。
|
|
494
|
+
|
|
495
|
+
| 参数名 | 类型 | 必填 | 说明 |
|
|
496
|
+
|--------|------|------|------|
|
|
497
|
+
| name | string | 是 | 应用 slug |
|
|
498
|
+
|
|
499
|
+
返回:
|
|
500
|
+
|
|
501
|
+
```json
|
|
502
|
+
{
|
|
503
|
+
"pageData": [
|
|
504
|
+
{ "fileName": "out.log", "stream": "out", "size": 1024, "compressed": false, "current": true, "mtime": "2026-10-08T18:30:00+08:00" },
|
|
505
|
+
{ "fileName": "out-20261007-000012.log.gz", "stream": "out", "size": 20480, "compressed": true, "current": false, "mtime": "2026-10-08T00:00:40+08:00" }
|
|
506
|
+
],
|
|
507
|
+
"totalCount": 2
|
|
508
|
+
}
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
##### GET `{prefix}/app/logs/download`
|
|
512
|
+
|
|
513
|
+
流式下载日志文件,`Content-Disposition: attachment; filename="{name}-{file}"`;`.gz` 为 `application/gzip`,其余为 `text/plain`。
|
|
514
|
+
|
|
515
|
+
| 参数名 | 类型 | 必填 | 说明 |
|
|
516
|
+
|--------|------|------|------|
|
|
517
|
+
| name | string | 是 | 应用 slug |
|
|
518
|
+
| file | string | 是 | 日志文件名 |
|
|
519
|
+
|
|
520
|
+
> **注意**:下载内容为磁盘原始文件,行首时间前缀是 PM2 守护进程所在服务器的时区,未做 `logTimezone` 换算。
|
|
521
|
+
|
|
522
|
+
> **大文件**:服务端按 `stat` 时的大小设置 `Content-Length` 并流式输出,正在追加的当前日志只下载到请求时刻的长度。前端应使用浏览器原生下载(`<a href>` 导航,鉴权 token 走 query,与 SSE 一致),不要 `fetch` 后转 Blob,以免整个文件读进内存。
|
|
523
|
+
|
|
524
|
+
##### GET `{prefix}/app/logs/download-zip`
|
|
525
|
+
|
|
526
|
+
将多个日志文件流式打包为一个 zip 下载,`Content-Disposition: attachment; filename="{name}-logs-{YYYYMMDD-HHmmss}.zip"`(时间戳按 `logTimezone`)。`.gz` 归档原样存入(不二次压缩),其余文件 deflate 压缩;当前日志只打包到请求时刻的长度。响应为 chunked,无 `Content-Length`。
|
|
527
|
+
|
|
528
|
+
| 参数名 | 类型 | 必填 | 说明 |
|
|
529
|
+
|--------|------|------|------|
|
|
530
|
+
| name | string | 是 | 应用 slug |
|
|
531
|
+
| files | string[] | 是 | 日志文件名,query 中重复传参:`files=a&files=b`;1~100 个,重复项去重 |
|
|
532
|
+
|
|
533
|
+
任一文件名非法返回 400,不存在返回 404。前端同样用原生 `<a href>` 下载,token 走 query。
|
|
534
|
+
|
|
535
|
+
##### POST `{prefix}/app/logs/remove`
|
|
536
|
+
|
|
537
|
+
删除选中的日志归档。
|
|
538
|
+
|
|
539
|
+
| 参数名 | 类型 | 必填 | 说明 |
|
|
540
|
+
|--------|------|------|------|
|
|
541
|
+
| name | string | 是 | 应用 slug |
|
|
542
|
+
| files | array | 是 | 归档文件名列表(至少 1 个);含当前文件 `out.log` / `err.log` 或非法名时整体返回 400 |
|
|
543
|
+
|
|
544
|
+
返回 `{ "removed": ["out-20261007-000012.log.gz"] }`(已不存在的文件不计入)。
|
|
545
|
+
|
|
471
546
|
##### GET `{prefix}/app/logs/stream`
|
|
472
547
|
|
|
473
548
|
SSE 实时日志。连接时先回放最近 `sseReplayLines` 行,再订阅 `log:{name}`。
|
|
@@ -483,7 +558,60 @@ SSE 实时日志。连接时先回放最近 `sseReplayLines` 行,再订阅 `lo
|
|
|
483
558
|
|
|
484
559
|
```text
|
|
485
560
|
event: log
|
|
486
|
-
data: {"appName":"demo","stream":"out","content":"...","line":1}
|
|
561
|
+
data: {"appName":"demo","stream":"out","content":"2026-10-08T18:30:00: ...","line":1,"loggedAt":"2026-10-08T18:30:00+08:00"}
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
实时事件的 `content` 前缀与 `loggedAt` 均按 `logTimezone` 输出;`line` 取当前文件行数,PM2 落盘略晚于 bus 时可能偏小 1 行。
|
|
565
|
+
|
|
566
|
+
##### GET `{prefix}/app/load`
|
|
567
|
+
|
|
568
|
+
应用当前负载与内存中的最近历史(最多 `loadHistoryMinutes` 分钟,旧在前)。
|
|
569
|
+
|
|
570
|
+
| 参数名 | 类型 | 必填 | 说明 |
|
|
571
|
+
|--------|------|------|------|
|
|
572
|
+
| name | string | 是 | 应用 slug;不存在返回 404 |
|
|
573
|
+
|
|
574
|
+
返回:
|
|
575
|
+
|
|
576
|
+
```json
|
|
577
|
+
{
|
|
578
|
+
"intervalMs": 5000,
|
|
579
|
+
"current": { "name": "demo", "ts": 1791456603533, "sampledAt": "2026-10-08T18:50:03+08:00", "status": "online", "cpu": 7.5, "memory": 52428800, "instances": 1, "pids": [4321], "uptime": 65061, "restarts": 2, "requests": { "qps": 0.8, "rpm": 48, "avgRt": 14, "p95": 18, "p99": 18, "errorRate": 0.25, "errors5xx": 1, "upstreamErrors": 0, "concurrency": 0, "peakConcurrency": 1 } },
|
|
580
|
+
"pageData": []
|
|
581
|
+
}
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
样本字段:
|
|
585
|
+
|
|
586
|
+
| 字段 | 说明 |
|
|
587
|
+
|------|------|
|
|
588
|
+
| `ts` / `sampledAt` | 采样时间:毫秒时间戳 / 按 `logTimezone` 的 ISO 字符串 |
|
|
589
|
+
| `status` | PM2 状态;任一实例 `online` 即为 `online`;PM2 中无该进程时为 `offline` |
|
|
590
|
+
| `cpu` / `memory` | 各实例 CPU% 之和 / RSS 字节之和 |
|
|
591
|
+
| `instances` / `pids` | PM2 实例数 / 在线实例 pid |
|
|
592
|
+
| `uptime` | 最早在线实例的运行时长(毫秒),不在线为 `0` |
|
|
593
|
+
| `restarts` | 各实例 PM2 重启次数之和(累计值) |
|
|
594
|
+
| `requests.qps` | 本采样周期请求数 / 周期秒数 |
|
|
595
|
+
| `requests.rpm` | 最近 60 秒请求数折算到每分钟 |
|
|
596
|
+
| `requests.avgRt` / `p95` / `p99` | 最近 60 秒响应时间(毫秒),无请求时为 `null`;分位数为对数分桶近似(误差约 5%) |
|
|
597
|
+
| `requests.errorRate` | 最近 60 秒 5xx 数 / 请求数,无请求时为 `null` |
|
|
598
|
+
| `requests.errors5xx` / `upstreamErrors` | 本周期 5xx 次数 / 上游连接失败、超时、重置次数(后者同时计入 5xx) |
|
|
599
|
+
| `requests.concurrency` / `peakConcurrency` | 采样时刻在途请求数 / 本周期在途峰值 |
|
|
600
|
+
|
|
601
|
+
##### GET `{prefix}/app/load/stream`
|
|
602
|
+
|
|
603
|
+
SSE 实时负载。连接时先推送一次 `history`,之后每次采样推送 `load`。
|
|
604
|
+
|
|
605
|
+
| 参数名 | 类型 | 必填 | 说明 |
|
|
606
|
+
|--------|------|------|------|
|
|
607
|
+
| name | string | 是 | 应用 slug |
|
|
608
|
+
|
|
609
|
+
```text
|
|
610
|
+
event: history
|
|
611
|
+
data: {"intervalMs":5000,"pageData":[{...}]}
|
|
612
|
+
|
|
613
|
+
event: load
|
|
614
|
+
data: {"name":"demo","ts":1791456608533,...}
|
|
487
615
|
```
|
|
488
616
|
|
|
489
617
|
#### 程序化 API
|
|
@@ -506,10 +634,17 @@ data: {"appName":"demo","stream":"out","content":"...","line":1}
|
|
|
506
634
|
| `syncStatus({ name, recoverIfMissing? })` | 同步单个应用真实状态并返回公开对象 |
|
|
507
635
|
| `syncAllStatuses({ recoverIfMissing? })` | 批量同步(父应用启动 reconcile 使用) |
|
|
508
636
|
| `remove({ name, exportBeforeRemove?, cleanupData?, ... })` | 删除;默认可导出+清理所属表 |
|
|
509
|
-
| `logs({ name, stream, perPage, currentPage })` |
|
|
637
|
+
| `logs({ name, stream, file?, perPage, currentPage, beforeLine? })` | 读当前日志或指定归档 |
|
|
638
|
+
| `logFiles({ name })` | 列出日志文件 |
|
|
639
|
+
| `resolveAppLogFile({ name, file })` | 校验并返回 `{ path, fileName, size, compressed }`(下载用) |
|
|
640
|
+
| `resolveAppLogFiles({ name, files })` | 批量校验(去重),返回 `{ name, files: [{ path, fileName, size, mtime, compressed }] }`(打包下载用) |
|
|
641
|
+
| `removeLogFiles({ name, files })` | 删除归档,返回 `{ removed }` |
|
|
642
|
+
| `load({ name })` | 返回 `{ intervalMs, current, pageData }` |
|
|
643
|
+
| `getLoadStore()` | 负载环形缓冲:`history(name)` / `latest(name)` / `emitter`(事件 `load:{name}`) |
|
|
510
644
|
| `findByHost(host, { running? })` | 网关:按 domain 查找;默认只查 `running`,`running: false` 时不限状态 |
|
|
511
645
|
| `findByPathName(name, { running? })` | 网关:按 name 查找;默认只查 `running`,`running: false` 时不限状态 |
|
|
512
|
-
| `appendAppLog(appName, stream, content)` |
|
|
646
|
+
| `appendAppLog(appName, stream, content)` | 管理端主动写入:追加到当前日志文件并 emit hub 事件 |
|
|
647
|
+
| `emitAppLog(appName, stream, content)` | 只 emit hub 事件(PM2 bus 日志用,不写文件) |
|
|
513
648
|
| `readLastLines(name, stream, n)` | SSE 回放用 |
|
|
514
649
|
| `getLogHub()` | 获取 EventEmitter |
|
|
515
650
|
| `toPublicApp(app)` / `mountPrefix(name)` | 序列化辅助 |
|
|
@@ -537,8 +672,10 @@ data: {"appName":"demo","stream":"out","content":"...","line":1}
|
|
|
537
672
|
|
|
538
673
|
| 方法签名 | 说明 |
|
|
539
674
|
|----------|------|
|
|
540
|
-
| `onReady()` | 连接 PM2、挂 log bus
|
|
541
|
-
| `onClose()` |
|
|
675
|
+
| `onReady()` | 连接 PM2、挂 log bus、启动日志切分与负载采样定时器,并 **setImmediate** 异步 `reconcile`、一次 `rotateLogs` 与一次 `sampleLoad`(不阻塞父应用 ready) |
|
|
676
|
+
| `onClose()` | 停止切分与采样定时器并等待进行中的任务,断开 bus / PM2(由插件 `onClose` 调用) |
|
|
677
|
+
| `rotateLogs({ now? })` | 立即执行一轮切分 + 压缩 + 保留清理;返回本轮切分的 `[{ name, stream, fileName, reason }]`,`reason` 为 `size` / `daily` |
|
|
678
|
+
| `sampleLoad({ now? })` | 立即采样一轮:每个应用一条样本写入负载缓冲并推送 SSE,返回样本数组;PM2 未连接时返回 `[]` |
|
|
542
679
|
| `reconcile()` | 调用 `app.syncAllStatuses({ recoverIfMissing: true })`:按 PM2 真实状态写回 DB;`running`/`deploying` 且进程缺失时尝试拉起 |
|
|
543
680
|
|
|
544
681
|
#### 数据模型
|
|
@@ -557,7 +694,7 @@ data: {"appName":"demo","stream":"out","content":"...","line":1}
|
|
|
557
694
|
| description | string / null | 描述 |
|
|
558
695
|
| env | object | 自有环境变量 |
|
|
559
696
|
| pm2Config | object | PM2 覆盖 |
|
|
560
|
-
| options | object | 扩展字段;`secretEnvKeys` 为显式密钥键名列表,`category` 为应用分类,`isPublic`
|
|
697
|
+
| options | object | 扩展字段;`secretEnvKeys` 为显式密钥键名列表,`category` 为应用分类,`isPublic` 为是否公开(未设置视为公开),`entries` 为应用入口 `[{ label, path }]` |
|
|
561
698
|
| port | number | 分配端口 |
|
|
562
699
|
| status | string | `idle` / `deploying` / `running` / `stopped` / `error` |
|
|
563
700
|
| currentVersionId | string / null | 当前部署版本 id |
|
|
@@ -594,6 +731,46 @@ data: {"appName":"demo","stream":"out","content":"...","line":1}
|
|
|
594
731
|
| Set-Cookie | `Path=/` 改为 `Path=/app/{name}` |
|
|
595
732
|
| Body | 对文本类 Content-Type,将 `"/static/`、`"/api/`、`"/account/` 等前缀改写;有 `content-encoding` 时跳过 |
|
|
596
733
|
|
|
734
|
+
##### 日志切分与归档
|
|
735
|
+
|
|
736
|
+
```
|
|
737
|
+
PM2 守护进程 ──(out_file / error_file)──→ logs/out.log、err.log
|
|
738
|
+
└──(bus log:out / log:err)──→ logHub ──→ SSE
|
|
739
|
+
定时器(每 logRotateIntervalMs)
|
|
740
|
+
↓ size >= logMaxSize 或 跨天(logTimezone)
|
|
741
|
+
rename 为 {stream}-{起始时间}.log ──→ pm2 reloadLogs ──→ gzip ──→ 保留清理
|
|
742
|
+
```
|
|
743
|
+
|
|
744
|
+
| 机制 | 说明 |
|
|
745
|
+
|------|------|
|
|
746
|
+
| 写入者 | 仅 PM2 写日志文件;bus 只推 SSE,不再重复落盘 |
|
|
747
|
+
| 切分条件 | 文件非空且 `size >= logMaxSize`,或 `logRotateDaily` 下文件起始日期早于今天(按 `logTimezone`) |
|
|
748
|
+
| 起始时间 | 记录在 `logs/.rotate-state.json`,切分后更新为当前时间;缺失时用文件创建时间,不可用则用修改时间 |
|
|
749
|
+
| 归档命名 | `{stream}-YYYYMMDD-HHmmss.log[.gz]`,时间为该文件起始时间(`logTimezone`);重名追加 `-1`、`-2` |
|
|
750
|
+
| 不丢日志 | rename 后 PM2 仍写原 inode(即归档),`reloadLogs` 重开新文件后才压缩 |
|
|
751
|
+
| 保留 | 每个 stream 按修改时间倒序保留 `logRetentionMaxFiles` 个,且删除超过 `logRetentionDays` 天的归档 |
|
|
752
|
+
| 读取 | 当前文件从末尾分块反向读取,行数增量缓存;`.gz` 流式解压按行号区间读取 |
|
|
753
|
+
|
|
754
|
+
> **时区**:PM2 `time: true` 的行前缀由守护进程按服务器本地时区生成(无偏移,全局共享无法单独设置)。读取接口与 SSE 识别 `YYYY-MM-DDTHH:mm:ss: ` 前缀并换算为 `logTimezone`;磁盘文件与下载内容不改写。换算假设守护进程与宿主进程时区一致。
|
|
755
|
+
|
|
756
|
+
##### 负载与请求指标
|
|
757
|
+
|
|
758
|
+
```
|
|
759
|
+
网关 proxyToApp ──begin / finish──→ requestMetrics(每应用:在途数 + 当前周期直方图)
|
|
760
|
+
定时器(每 loadSampleIntervalMs)
|
|
761
|
+
pm2.list ──汇总同名实例──┐
|
|
762
|
+
requestMetrics.snapshot ─┴→ 样本 ──→ loadStore 环形缓冲 ──→ SSE load:{name}
|
|
763
|
+
```
|
|
764
|
+
|
|
765
|
+
| 机制 | 说明 |
|
|
766
|
+
|------|------|
|
|
767
|
+
| 进程指标 | 来自 `pm2.list` 的 `monit` 与 `pm2_env`;不随访问量变化,应用不在 PM2 中时推送 `offline` 样本 |
|
|
768
|
+
| 统计范围 | 仅经网关反代的请求(域名与 `/app/{name}` 两种);应用未运行时网关返回的 503/404 状态页、直连 `127.0.0.1:{port}` 的请求(含健康检查)不统计;WebSocket 不经网关 |
|
|
769
|
+
| 响应时间 | 网关收到请求到响应发送完毕(或客户端断开),包含应用处理与传输时间 |
|
|
770
|
+
| 内存 | 每应用每周期最多约 230 个直方图桶,与请求量无关;历史只在内存,宿主重启后清空 |
|
|
771
|
+
| 多进程 | 宿主多进程部署时各进程分别统计,接口只返回当前进程的数据 |
|
|
772
|
+
| 异常 | fork 模式下 PM2 不上报应用内异常;以 `upstreamErrors`(连不上 / 超时)与 `errors5xx` 作为网关侧异常指标 |
|
|
773
|
+
|
|
597
774
|
##### 包校验与安全
|
|
598
775
|
|
|
599
776
|
| 检查 | 失败行为 |
|
|
@@ -607,7 +784,7 @@ data: {"appName":"demo","stream":"out","content":"...","line":1}
|
|
|
607
784
|
##### 环境合并顺序
|
|
608
785
|
|
|
609
786
|
```
|
|
610
|
-
pickHostEnv(passthroughEnvKeys) → app.env → PORT=分配端口(强制)
|
|
787
|
+
pickHostEnv(passthroughEnvKeys) → app.env → PORT=分配端口(强制) → resolveSystemEnv 返回值(覆盖)
|
|
611
788
|
```
|
|
612
789
|
|
|
613
790
|
API 响应脱敏:`secretEnvKeyPattern` ∪ `options.secretEnvKeys`;明文密钥不回传,顶层 `secretEnvKeys` 只暴露键名。
|
package/index.js
CHANGED
|
@@ -2,6 +2,7 @@ const fp = require('fastify-plugin');
|
|
|
2
2
|
const path = require('node:path');
|
|
3
3
|
const fs = require('fs-extra');
|
|
4
4
|
const { assertDefaultAppDbSeparated } = require('./libs/utils/dbIdentity');
|
|
5
|
+
const { parseTimezone } = require('./libs/utils/logTime');
|
|
5
6
|
|
|
6
7
|
module.exports = fp(
|
|
7
8
|
async (fastify, options) => {
|
|
@@ -26,7 +27,21 @@ module.exports = fp(
|
|
|
26
27
|
sseReplayLines: 100,
|
|
27
28
|
sseHeartbeatMs: 15000,
|
|
28
29
|
logMaxSize: 50 * 1024 * 1024,
|
|
30
|
+
logTimezone: '+08:00',
|
|
31
|
+
logRotateDaily: true,
|
|
32
|
+
logRotateIntervalMs: 60 * 1000,
|
|
33
|
+
logRetentionMaxFiles: 10,
|
|
34
|
+
logRetentionDays: 30,
|
|
35
|
+
logCompress: true,
|
|
36
|
+
loadSampleIntervalMs: 5000,
|
|
37
|
+
loadHistoryMinutes: 10,
|
|
29
38
|
passthroughEnvKeys: [],
|
|
39
|
+
/** 宿主维护的系统变量:接口不返回、不可外部修改,resolveSystemEnv 返回的同名键会持久化 */
|
|
40
|
+
systemEnvKeys: [],
|
|
41
|
+
/** async ({ app, version, serverDir }) => env | null,每次启动进程前调用,返回值覆盖应用变量 */
|
|
42
|
+
resolveSystemEnv: null,
|
|
43
|
+
/** async ({ app }) => void,应用删除成功后调用,app 为删除前快照;报错只记日志 */
|
|
44
|
+
onAppRemoved: null,
|
|
30
45
|
secretEnvKeyPattern: /(SECRET|PASSWORD|TOKEN|KEY|PRIVATE)/i,
|
|
31
46
|
sqlPath: 'sql',
|
|
32
47
|
migrateBeforeStart: false,
|
|
@@ -66,6 +81,8 @@ module.exports = fp(
|
|
|
66
81
|
options
|
|
67
82
|
);
|
|
68
83
|
|
|
84
|
+
parseTimezone(options.logTimezone);
|
|
85
|
+
|
|
69
86
|
if (!options.defaultAppDb) {
|
|
70
87
|
options.defaultAppDb = {
|
|
71
88
|
dialect: 'sqlite',
|
package/libs/controllers/app.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
const fp = require('fastify-plugin');
|
|
2
|
+
const fs = require('fs-extra');
|
|
3
|
+
const yazl = require('yazl');
|
|
2
4
|
const { createSseReply } = require('../utils/logStream');
|
|
5
|
+
const { formatInTz } = require('../utils/logTime');
|
|
3
6
|
|
|
4
7
|
module.exports = fp(async (fastify, options) => {
|
|
5
8
|
const { services } = fastify[options.name];
|
|
@@ -23,6 +26,10 @@ module.exports = fp(async (fastify, options) => {
|
|
|
23
26
|
description: { type: 'string' },
|
|
24
27
|
category: { type: ['object', 'string', 'null'] },
|
|
25
28
|
isPublic: { type: 'boolean' },
|
|
29
|
+
entries: {
|
|
30
|
+
type: ['array', 'null'],
|
|
31
|
+
items: { type: 'object', properties: { label: { type: 'string' }, path: { type: 'string' } } }
|
|
32
|
+
},
|
|
26
33
|
env: { type: 'object' },
|
|
27
34
|
pm2Config: { type: 'object' },
|
|
28
35
|
options: { type: 'object' }
|
|
@@ -50,6 +57,10 @@ module.exports = fp(async (fastify, options) => {
|
|
|
50
57
|
description: { type: 'string' },
|
|
51
58
|
category: { type: ['object', 'string', 'null'] },
|
|
52
59
|
isPublic: { type: 'boolean' },
|
|
60
|
+
entries: {
|
|
61
|
+
type: ['array', 'null'],
|
|
62
|
+
items: { type: 'object', properties: { label: { type: 'string' }, path: { type: 'string' } } }
|
|
63
|
+
},
|
|
53
64
|
env: { type: 'object' },
|
|
54
65
|
pm2Config: { type: 'object' },
|
|
55
66
|
options: { type: 'object' }
|
|
@@ -280,6 +291,10 @@ module.exports = fp(async (fastify, options) => {
|
|
|
280
291
|
beforeLine: {
|
|
281
292
|
type: 'number',
|
|
282
293
|
description: 'Load up to perPage lines with line number < beforeLine (for scroll-up history)'
|
|
294
|
+
},
|
|
295
|
+
file: {
|
|
296
|
+
type: 'string',
|
|
297
|
+
description: 'Archive file name from /app/logs/files; overrides stream'
|
|
283
298
|
}
|
|
284
299
|
}
|
|
285
300
|
}
|
|
@@ -288,6 +303,108 @@ module.exports = fp(async (fastify, options) => {
|
|
|
288
303
|
async request => services.app.logs(request.query)
|
|
289
304
|
);
|
|
290
305
|
|
|
306
|
+
fastify.get(
|
|
307
|
+
`${options.prefix}/app/logs/files`,
|
|
308
|
+
{
|
|
309
|
+
onRequest: auth(),
|
|
310
|
+
schema: {
|
|
311
|
+
summary: '列出应用日志文件(当前文件与归档)',
|
|
312
|
+
query: {
|
|
313
|
+
type: 'object',
|
|
314
|
+
required: ['name'],
|
|
315
|
+
properties: {
|
|
316
|
+
name: { type: 'string' }
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
},
|
|
321
|
+
async request => services.app.logFiles(request.query)
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
fastify.get(
|
|
325
|
+
`${options.prefix}/app/logs/download`,
|
|
326
|
+
{
|
|
327
|
+
onRequest: auth(),
|
|
328
|
+
schema: {
|
|
329
|
+
summary: '下载日志文件',
|
|
330
|
+
query: {
|
|
331
|
+
type: 'object',
|
|
332
|
+
required: ['name', 'file'],
|
|
333
|
+
properties: {
|
|
334
|
+
name: { type: 'string' },
|
|
335
|
+
file: { type: 'string' }
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
},
|
|
340
|
+
async (request, reply) => {
|
|
341
|
+
const { name } = request.query;
|
|
342
|
+
const { path: filePath, fileName, size, compressed } = await services.app.resolveAppLogFile(request.query);
|
|
343
|
+
reply.header('Content-Type', compressed ? 'application/gzip' : 'text/plain; charset=utf-8');
|
|
344
|
+
reply.header('Content-Disposition', `attachment; filename="${name}-${fileName}"`);
|
|
345
|
+
reply.header('Content-Length', size);
|
|
346
|
+
if (size === 0) {
|
|
347
|
+
return reply.send('');
|
|
348
|
+
}
|
|
349
|
+
// 当前日志仍在被 PM2 追加,只读到 stat 时的长度,保证与 Content-Length 一致
|
|
350
|
+
return reply.send(fs.createReadStream(filePath, { start: 0, end: size - 1 }));
|
|
351
|
+
}
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
fastify.get(
|
|
355
|
+
`${options.prefix}/app/logs/download-zip`,
|
|
356
|
+
{
|
|
357
|
+
onRequest: auth(),
|
|
358
|
+
schema: {
|
|
359
|
+
summary: '批量打包下载日志文件',
|
|
360
|
+
query: {
|
|
361
|
+
type: 'object',
|
|
362
|
+
required: ['name', 'files'],
|
|
363
|
+
properties: {
|
|
364
|
+
name: { type: 'string' },
|
|
365
|
+
files: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 100 }
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
},
|
|
370
|
+
async (request, reply) => {
|
|
371
|
+
const { name, files } = await services.app.resolveAppLogFiles(request.query);
|
|
372
|
+
const zipFile = new yazl.ZipFile();
|
|
373
|
+
for (const { path: filePath, fileName, size, mtime, compressed } of files) {
|
|
374
|
+
if (size === 0) {
|
|
375
|
+
zipFile.addBuffer(Buffer.alloc(0), fileName, { mtime });
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
// .gz 归档已压缩,原样存入;当前日志只打包到 stat 时的长度
|
|
379
|
+
zipFile.addReadStream(fs.createReadStream(filePath, { start: 0, end: size - 1 }), fileName, { mtime, size, compress: !compressed });
|
|
380
|
+
}
|
|
381
|
+
zipFile.end();
|
|
382
|
+
const stamp = formatInTz(new Date(), options.logTimezone, 'YYYYMMDD-HHmmss');
|
|
383
|
+
reply.header('Content-Type', 'application/zip');
|
|
384
|
+
reply.header('Content-Disposition', `attachment; filename="${name}-logs-${stamp}.zip"`);
|
|
385
|
+
return reply.send(zipFile.outputStream);
|
|
386
|
+
}
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
fastify.post(
|
|
390
|
+
`${options.prefix}/app/logs/remove`,
|
|
391
|
+
{
|
|
392
|
+
onRequest: auth(),
|
|
393
|
+
schema: {
|
|
394
|
+
summary: '删除选中的日志归档',
|
|
395
|
+
body: {
|
|
396
|
+
type: 'object',
|
|
397
|
+
required: ['name', 'files'],
|
|
398
|
+
properties: {
|
|
399
|
+
name: { type: 'string' },
|
|
400
|
+
files: { type: 'array', minItems: 1, items: { type: 'string' } }
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
},
|
|
405
|
+
async request => services.app.removeLogFiles(request.body)
|
|
406
|
+
);
|
|
407
|
+
|
|
291
408
|
fastify.get(
|
|
292
409
|
`${options.prefix}/app/logs/stream`,
|
|
293
410
|
{
|
|
@@ -337,4 +454,54 @@ module.exports = fp(async (fastify, options) => {
|
|
|
337
454
|
});
|
|
338
455
|
}
|
|
339
456
|
);
|
|
457
|
+
|
|
458
|
+
fastify.get(
|
|
459
|
+
`${options.prefix}/app/load`,
|
|
460
|
+
{
|
|
461
|
+
onRequest: auth(),
|
|
462
|
+
schema: {
|
|
463
|
+
summary: '应用当前负载与最近历史',
|
|
464
|
+
query: {
|
|
465
|
+
type: 'object',
|
|
466
|
+
required: ['name'],
|
|
467
|
+
properties: {
|
|
468
|
+
name: { type: 'string' }
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
},
|
|
473
|
+
async request => services.app.load(request.query)
|
|
474
|
+
);
|
|
475
|
+
|
|
476
|
+
fastify.get(
|
|
477
|
+
`${options.prefix}/app/load/stream`,
|
|
478
|
+
{
|
|
479
|
+
onRequest: auth(),
|
|
480
|
+
schema: {
|
|
481
|
+
summary: 'SSE 实时负载',
|
|
482
|
+
query: {
|
|
483
|
+
type: 'object',
|
|
484
|
+
required: ['name'],
|
|
485
|
+
properties: {
|
|
486
|
+
name: { type: 'string' }
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
},
|
|
491
|
+
async (request, reply) => {
|
|
492
|
+
const { name } = request.query;
|
|
493
|
+
const { intervalMs, pageData } = await services.app.load({ name });
|
|
494
|
+
const sse = createSseReply(reply, { heartbeatMs: options.sseHeartbeatMs });
|
|
495
|
+
sse.writeEvent('history', { intervalMs, pageData });
|
|
496
|
+
|
|
497
|
+
const store = services.app.getLoadStore();
|
|
498
|
+
const onLoad = sample => sse.writeEvent('load', sample);
|
|
499
|
+
const eventName = `load:${name}`;
|
|
500
|
+
store?.emitter.on(eventName, onLoad);
|
|
501
|
+
request.raw.on('close', () => {
|
|
502
|
+
store?.emitter.off(eventName, onLoad);
|
|
503
|
+
sse.close();
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
);
|
|
340
507
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const fp = require('fastify-plugin');
|
|
2
2
|
const { Readable } = require('node:stream');
|
|
3
|
-
const { rewriteLocation, rewriteSetCookiePath, shouldRewriteBody, rewriteBody } = require('../utils/pathRewrite');
|
|
3
|
+
const { rewriteLocation, rewriteSetCookiePath, shouldRewriteBody, rewriteBody, tagEtag, restoreConditionalHeaders } = require('../utils/pathRewrite');
|
|
4
4
|
const { wantsHtml, renderUnavailablePage, getStatusText } = require('../utils/unavailablePage');
|
|
5
5
|
|
|
6
6
|
module.exports = fp(async (fastify, options) => {
|
|
@@ -22,6 +22,13 @@ module.exports = fp(async (fastify, options) => {
|
|
|
22
22
|
const dest = `http://127.0.0.1:${app.port}${targetPath}`;
|
|
23
23
|
const mountPrefix = `${pathPrefix}/${app.name}`;
|
|
24
24
|
|
|
25
|
+
const tracker = fastify[ns].requestMetrics?.begin(app.name);
|
|
26
|
+
if (tracker) {
|
|
27
|
+
const finish = () => tracker.end({ statusCode: reply.raw.statusCode });
|
|
28
|
+
reply.raw.once('finish', finish);
|
|
29
|
+
reply.raw.once('close', finish);
|
|
30
|
+
}
|
|
31
|
+
|
|
25
32
|
// The gateway runs in onRequest, before body parsing; reply-from only forwards request.body,
|
|
26
33
|
// so hand it the raw stream or POST/PUT bodies reach the app empty.
|
|
27
34
|
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
@@ -29,14 +36,21 @@ module.exports = fp(async (fastify, options) => {
|
|
|
29
36
|
}
|
|
30
37
|
|
|
31
38
|
return reply.from(dest, {
|
|
39
|
+
onError: (reply, { error }) => {
|
|
40
|
+
tracker?.markUpstreamError();
|
|
41
|
+
reply.send(error);
|
|
42
|
+
},
|
|
32
43
|
rewriteRequestHeaders: (req, headers) => {
|
|
33
|
-
const next = { ...headers };
|
|
44
|
+
const next = stripPrefix ? restoreConditionalHeaders(headers) : { ...headers };
|
|
34
45
|
delete next['content-length'];
|
|
35
46
|
return next;
|
|
36
47
|
},
|
|
37
48
|
rewriteHeaders: headers => {
|
|
38
49
|
const next = { ...headers };
|
|
39
50
|
if (stripPrefix) {
|
|
51
|
+
if (next.etag) {
|
|
52
|
+
next.etag = tagEtag(next.etag);
|
|
53
|
+
}
|
|
40
54
|
if (next.location) {
|
|
41
55
|
next.location = rewriteLocation(next.location, mountPrefix);
|
|
42
56
|
}
|