@kne/fastify-app-manager 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -19
- package/index.js +29 -1
- package/libs/controllers/app.js +32 -4
- package/libs/controllers/dbops.js +268 -0
- package/libs/services/app.js +247 -48
- package/libs/services/bootstrap.js +10 -20
- package/libs/services/dbops.js +817 -0
- package/libs/utils/dbIdentity.js +118 -0
- package/libs/utils/entryInject.js +2 -1
- package/libs/utils/env.js +110 -10
- package/libs/utils/logFiles.js +19 -5
- package/libs/utils/migrate.js +10 -5
- package/libs/utils/sqlQueryGuard.js +72 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -23,6 +23,8 @@ npm i --save @kne/fastify-app-manager
|
|
|
23
23
|
适用场景:内网/堡垒机上的业务应用集中托管;不负责多机编排、硬磁盘配额或 WebSocket 升级。
|
|
24
24
|
|
|
25
25
|
> **关键设计**:子应用前端通过部署时注入的 `runtimePublicUrl` / `runtimeApiUrl` 感知对外前缀;网关对路径模式剥前缀反代,并改写 Location / Cookie / 文本 body 兜底。Scheme B(子进程挂 `APP_BASE_PATH`)已弃用。
|
|
26
|
+
>
|
|
27
|
+
> **存储隔离**:同时注入 `window.__LOCAL_STORAGE_PREFIX="<应用名>"`,`@kne/local-storage`(≥0.1.2,`@kne/token-storage` 底层依赖)读到后自动给 key 加 `<应用名>:` 前缀,避免同源挂载的子应用与宿主、其它子应用互相覆盖登录 token 等本地存储。
|
|
26
28
|
|
|
27
29
|
#### 核心架构与流程
|
|
28
30
|
|
|
@@ -48,7 +50,7 @@ Client / Admin UI
|
|
|
48
50
|
| Domain gateway | Host 精确匹配且状态为 `running` 时透明反代,不改写路径 |
|
|
49
51
|
| Path gateway | `/app/{name}` 剥前缀;改写绝对路径 Location、Cookie Path、文本类 body |
|
|
50
52
|
| PM2 | 每应用一进程,名 `app-manager__{name}`;stdout/stderr 落 `appsRoot/{name}/logs` |
|
|
51
|
-
| Bootstrap | `onReady` 连接 PM2、挂 log bus
|
|
53
|
+
| Bootstrap | `onReady` 连接 PM2、挂 log bus;**异步**对托管应用做 `syncAllStatuses`(不阻塞父应用 ready) |
|
|
52
54
|
|
|
53
55
|
##### 部署时序
|
|
54
56
|
|
|
@@ -98,9 +100,14 @@ deploy(versionId)
|
|
|
98
100
|
| 来源 | 优先级与规则 |
|
|
99
101
|
|------|----------------|
|
|
100
102
|
| 宿主 `passthroughEnvKeys` | 仅注入白名单键的当前值;API **不回传**宿主值,只暴露键名列表 |
|
|
101
|
-
| 应用 `env` | 覆盖透传键;响应中匹配 `secretEnvKeyPattern` 的键显示为 `********` |
|
|
103
|
+
| 应用 `env` | 覆盖透传键;响应中匹配 `secretEnvKeyPattern` **或**显式 `secretEnvKeys` 的键显示为 `********` |
|
|
102
104
|
| `PORT` | 始终强制为分配端口,不可被 app.env 覆盖 |
|
|
103
105
|
| `save-env` patch | `null` 删除键;`********` 对 secret 键表示保持原值 |
|
|
106
|
+
| 显式密钥 | 存于 `options.secretEnvKeys`;`save-env` 可传 `secretEnvKeys` 将普通键标为密钥;响应顶层回传合并后的键名列表(不含明文) |
|
|
107
|
+
| 默认托管库 | `defaultAppDb`(默认 `appsRoot/_shared/apps-data.sqlite`),与宿主主库强制分离;应用未配 `DB_*` 时注入 |
|
|
108
|
+
| 应用自有库 | 应用 `env` 含完整 `DB_*` 时为独享库(`dbScope=dedicated`) |
|
|
109
|
+
| 共享库表前缀 | 启动时注入 `DB_TABLE_PREFIX=t_{appName}_`(应用可显式覆盖);子应用 sequelize 须读取该变量建表 |
|
|
110
|
+
| 表归属 | `options.ownedTables`;共享库启动就绪后按前缀自动认领,亦可 `sync-owned` 扫描;运维仅限归属表 |
|
|
104
111
|
|
|
105
112
|
##### SQL 迁移
|
|
106
113
|
|
|
@@ -111,11 +118,12 @@ deploy(versionId)
|
|
|
111
118
|
| 特性 | 说明 |
|
|
112
119
|
|------|------|
|
|
113
120
|
| 版本上传 | Zip Slip 防护、条目数/体积上限、Fullstack 包格式校验、server 生产依赖安装 |
|
|
114
|
-
| 短部署 | `deploy` 立即返回 `deploying
|
|
115
|
-
| 生命周期 | `start` / `stop` / `restart` / `remove
|
|
121
|
+
| 短部署 | `deploy` / `start` / `restart` 立即返回 `deploying`,后台按 PM2 真实状态 + healthCheck 写回 `running`/`error`/`stopped` |
|
|
122
|
+
| 生命周期 | `start` / `stop` / `restart` / `remove`;父应用重启后异步 `syncAllStatuses` 对齐 PM2 |
|
|
116
123
|
| 双通道访问 | 自定义域名透明反代 + `/app/{name}` 剥前缀反代 |
|
|
117
|
-
| 日志 | 文件落盘 +
|
|
124
|
+
| 日志 | 文件落盘 + 历史分页(`beforeLine` 上滚、每页最多 100 行)+ SSE(含回放行与心跳) |
|
|
118
125
|
| 鉴权 | `createAuthenticate` 可注入;默认尝试 `fastify.account.authenticate.admin` |
|
|
126
|
+
| 数据运维 | 归属表浏览、行 CRUD、只读 query SQL、导出 zip、cleanup(无权限返回 SQL) |
|
|
119
127
|
|
|
120
128
|
#### 使用方法
|
|
121
129
|
|
|
@@ -179,7 +187,9 @@ await fastify.listen({ port: 3000 });
|
|
|
179
187
|
| `portMax` | number | 否 | `7999` | 端口池上限(含) |
|
|
180
188
|
| `pathPrefix` | string | 否 | `'/app'` | 路径访问前缀(无尾斜杠亦可) |
|
|
181
189
|
| `passthroughEnvKeys` | array | 否 | `[]` | 允许注入子进程的宿主 env 键名 |
|
|
182
|
-
| `secretEnvKeyPattern` | RegExp | 否 | `/(SECRET\|PASSWORD\|TOKEN\|KEY\|PRIVATE)/i` |
|
|
190
|
+
| `secretEnvKeyPattern` | RegExp | 否 | `/(SECRET\|PASSWORD\|TOKEN\|KEY\|PRIVATE)/i` | 按键名正则脱敏;与显式 `secretEnvKeys` 取并集 |
|
|
191
|
+
| `defaultAppDb` | object | 否 | `appsRoot/_shared/apps-data.sqlite` | 托管应用默认库(须与宿主主库分离) |
|
|
192
|
+
| `dbQueryMaxRows` | number | 否 | `500` | 只读 query SQL 最大返回行数 |
|
|
183
193
|
| `pm2Defaults` | object | 否 | 见下表 | 全局 PM2 默认;可被应用 `pm2Config` 覆盖 |
|
|
184
194
|
| `createAuthenticate` | function | 否 | 见说明 | 返回 `onRequest` 钩子数组;默认尝试 admin |
|
|
185
195
|
| `healthCheckPath` | string | 否 | `'/'` | 部署后就绪探测路径 |
|
|
@@ -231,9 +241,9 @@ await fastify.listen({ port: 3000 });
|
|
|
231
241
|
| description | string | 否 | - | 描述 |
|
|
232
242
|
| env | object | 否 | `{}` | 应用自有环境变量 |
|
|
233
243
|
| pm2Config | object | 否 | `{}` | PM2 覆盖项 |
|
|
234
|
-
| options | object | 否 | `{}` |
|
|
244
|
+
| options | object | 否 | `{}` | 扩展字段;可含 `secretEnvKeys: string[]` 显式密钥键名 |
|
|
235
245
|
|
|
236
|
-
返回脱敏后的应用对象(含 `port`、`pathUrl`、`passthroughEnvKeys`、`status: 'idle'` 等)。
|
|
246
|
+
返回脱敏后的应用对象(含 `port`、`pathUrl`、`passthroughEnvKeys`、`secretEnvKeys`、`status: 'idle'` 等)。
|
|
237
247
|
|
|
238
248
|
##### POST `{prefix}/app/save`
|
|
239
249
|
|
|
@@ -246,12 +256,49 @@ await fastify.listen({ port: 3000 });
|
|
|
246
256
|
|
|
247
257
|
##### POST `{prefix}/app/save-env`
|
|
248
258
|
|
|
249
|
-
|
|
259
|
+
仅合并环境变量;可选更新显式密钥键列表。
|
|
250
260
|
|
|
251
261
|
| 参数名 | 类型 | 必填 | 说明 |
|
|
252
262
|
|--------|------|------|------|
|
|
253
263
|
| name | string | 是 | 应用 slug |
|
|
254
264
|
| env | object | 是 | patch:`null` 删键;secret 键值为 `********` 时保持原值 |
|
|
265
|
+
| secretEnvKeys | array | 否 | 显式密钥键名;传入则整体替换并写入 `options.secretEnvKeys`(仅保留仍存在于 env 的键);省略则沿用原列表 |
|
|
266
|
+
|
|
267
|
+
> **关键设计**:密钥判定 = 键名匹配 `secretEnvKeyPattern` **或** 出现在 `options.secretEnvKeys`。响应顶层 `secretEnvKeys` 为二者并集,便于管理端把普通变量标成密钥而不改键名。
|
|
268
|
+
|
|
269
|
+
##### 数据运维与表生命周期
|
|
270
|
+
|
|
271
|
+
解析后的应用库:应用 `env` 的 `DB_*` 优先,否则注入 `defaultAppDb`。共享库(未自配 `DB_*`)仅能操作 `options.ownedTables`。
|
|
272
|
+
|
|
273
|
+
> **关键设计**:共享库启动时注入 `DB_TABLE_PREFIX=t_{appName}_`(可用应用 env 覆盖)。子应用若使用 `@kne/fastify-sequelize@>=4.0.4`,存在该环境变量时默认 `forcePrefix`,连接上所有模型(含 account/message/tenant)表名必须以该前缀开头,无法被 `addModels({ prefix })` 覆盖。就绪后按该前缀自动认领表。
|
|
274
|
+
|
|
275
|
+
| 方法 | 路径 | 说明 |
|
|
276
|
+
|------|------|------|
|
|
277
|
+
| GET | `{prefix}/app/db/tables` | 归属表 + 列/主键摘要;`scope=all` 仅独享库 |
|
|
278
|
+
| POST | `{prefix}/app/db/tables/register` | 登记 `ownedTables`(`mode`: `union`/`replace`) |
|
|
279
|
+
| POST | `{prefix}/app/db/tables/sync-owned` | 扫描库表并登记归属(共享库优先领取本应用前缀表;无前缀命中时再领取未被其它应用占用的表) |
|
|
280
|
+
| POST | `{prefix}/app/db/tables/unregister` | 取消登记 |
|
|
281
|
+
| GET | `{prefix}/app/db/rows` | 分页行;`filter` 为 JSON(等值或 `{ $gte,$lte,$gt,$lt }` 范围);`keyword` 在字符串列上 OR LIKE;`sort` 为 JSON `[{ name, sort: ASC\|DESC }]` |
|
|
282
|
+
| GET | `{prefix}/app/db/row` | 按 `id` 或 `pk` JSON 查一行 |
|
|
283
|
+
| POST | `{prefix}/app/db/row/save` | 按主键 upsert;可选 `autoGenerate`(主键字段 → boolean)空值时用宿主 `sequelize.generateId` 雪花填充 |
|
|
284
|
+
| POST | `{prefix}/app/db/row/remove` | 按主键删除 |
|
|
285
|
+
| POST | `{prefix}/app/db/row/restore` | 按主键恢复软删除(清空 deleted_at) |
|
|
286
|
+
| POST | `{prefix}/app/db/query` | **只读** SQL(`SELECT`/`WITH`/`EXPLAIN`/`SHOW`/`DESCRIBE`/`PRAGMA`);共享库引用表须归属 |
|
|
287
|
+
| POST | `{prefix}/app/db/export` | 导出归属表为 zip(`mode=file` 仅独享 sqlite) |
|
|
288
|
+
| POST | `{prefix}/app/db/cleanup` | `DROP` 归属表;失败项进入 `sql[]`,`manualRequired` |
|
|
289
|
+
|
|
290
|
+
##### POST `{prefix}/app/remove`
|
|
291
|
+
|
|
292
|
+
删除应用。默认先导出再清理所属表;清理不完整则默认**不删**元数据。
|
|
293
|
+
|
|
294
|
+
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|
|
295
|
+
|--------|------|------|--------|------|
|
|
296
|
+
| name | string | 是 | - | 应用 slug |
|
|
297
|
+
| exportBeforeRemove | boolean | 否 | `true` | 删除前导出 |
|
|
298
|
+
| cleanupData | boolean | 否 | `true` | 删除前 cleanup |
|
|
299
|
+
| allowRemoveIfCleanupIncomplete | boolean | 否 | `false` | 允许在 `manualRequired` 时仍删管控侧 |
|
|
300
|
+
| force | boolean | 否 | `false` | 跳过导出 |
|
|
301
|
+
| removeSqliteFile | boolean | 否 | `false` | 独享 sqlite 清理成功后删除文件 |
|
|
255
302
|
|
|
256
303
|
##### POST `{prefix}/app/version/upload`
|
|
257
304
|
|
|
@@ -349,21 +396,23 @@ await fastify.listen({ port: 3000 });
|
|
|
349
396
|
|
|
350
397
|
##### GET `{prefix}/app/logs`
|
|
351
398
|
|
|
352
|
-
|
|
399
|
+
读取日志文件尾部分页(较新页在前)。`perPage` 上限 100。停止实时后前端可用 `beforeLine` 向上滚动加载更早日志。
|
|
353
400
|
|
|
354
401
|
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|
|
355
402
|
|--------|------|------|--------|------|
|
|
356
403
|
| name | string | 是 | - | 应用 slug |
|
|
357
404
|
| stream | string | 否 | `'out'` | `out` / `err` |
|
|
358
|
-
| perPage | number | 否 | `100` |
|
|
359
|
-
| currentPage | number | 否 | `1` | 页码(1
|
|
405
|
+
| perPage | number | 否 | `100` | 每页行数(最大 100) |
|
|
406
|
+
| currentPage | number | 否 | `1` | 页码(1=最新一页;与 `beforeLine` 二选一) |
|
|
407
|
+
| beforeLine | number | 否 | - | 取行号 `< beforeLine` 的尾部至多 `perPage` 行(上滚历史) |
|
|
360
408
|
|
|
361
409
|
返回:
|
|
362
410
|
|
|
363
411
|
```json
|
|
364
412
|
{
|
|
365
413
|
"pageData": [{ "line": 12, "content": "..." }],
|
|
366
|
-
"totalCount": 12
|
|
414
|
+
"totalCount": 12,
|
|
415
|
+
"hasMore": true
|
|
367
416
|
}
|
|
368
417
|
```
|
|
369
418
|
|
|
@@ -395,13 +444,16 @@ data: {"appName":"demo","stream":"out","content":"...","line":1}
|
|
|
395
444
|
|----------|------|
|
|
396
445
|
| `create(data)` | 同 create 接口 |
|
|
397
446
|
| `save({ name, ... })` | 同 save |
|
|
398
|
-
| `saveEnv({ name, env })` | 同 save-env |
|
|
447
|
+
| `saveEnv({ name, env, secretEnvKeys? })` | 同 save-env |
|
|
399
448
|
| `list({ filter, perPage, currentPage })` | 列表 |
|
|
400
449
|
| `detail({ name })` | 详情(脱敏) |
|
|
401
450
|
| `uploadVersion({ name, version, label, zipBuffer })` | 上传版本 |
|
|
402
451
|
| `listVersions({ name, perPage, currentPage })` | 版本列表 |
|
|
403
|
-
| `deploy({ name, versionId, version, runMigration })` |
|
|
404
|
-
| `start` / `stop` / `restart`
|
|
452
|
+
| `deploy({ name, versionId, version, runMigration })` | 部署;立即 `deploying`,后台 `syncRealStatus` |
|
|
453
|
+
| `start` / `stop` / `restart` | 生命周期;`start`/`restart` 立即 `deploying`,后台按 PM2+health 写回真实状态 |
|
|
454
|
+
| `syncStatus({ name, recoverIfMissing? })` | 同步单个应用真实状态并返回公开对象 |
|
|
455
|
+
| `syncAllStatuses({ recoverIfMissing? })` | 批量同步(父应用启动 reconcile 使用) |
|
|
456
|
+
| `remove({ name, exportBeforeRemove?, cleanupData?, ... })` | 删除;默认可导出+清理所属表 |
|
|
405
457
|
| `logs({ name, stream, perPage, currentPage })` | 读日志文件 |
|
|
406
458
|
| `findByHost(host)` | 网关:按 domain + `running` 查找 |
|
|
407
459
|
| `findByPathName(name)` | 网关:按 name + `running` 查找 |
|
|
@@ -410,13 +462,22 @@ data: {"appName":"demo","stream":"out","content":"...","line":1}
|
|
|
410
462
|
| `getLogHub()` | 获取 EventEmitter |
|
|
411
463
|
| `toPublicApp(app)` / `mountPrefix(name)` | 序列化辅助 |
|
|
412
464
|
|
|
465
|
+
##### services.dbops
|
|
466
|
+
|
|
467
|
+
| 方法签名 | 说明 |
|
|
468
|
+
|----------|------|
|
|
469
|
+
| `listTables` / `registerTables` / `unregisterTables` | 表归属 |
|
|
470
|
+
| `listRows` / `getRow` / `saveRow` / `removeRow` | 行级运维 |
|
|
471
|
+
| `runQuery({ name, sql, replacements? })` | 只读 SQL |
|
|
472
|
+
| `exportData` / `cleanup` | 导出与清理 |
|
|
473
|
+
|
|
413
474
|
##### services.bootstrap
|
|
414
475
|
|
|
415
476
|
| 方法签名 | 说明 |
|
|
416
477
|
|----------|------|
|
|
417
|
-
| `onReady()` | 连接 PM2、挂 log bus
|
|
478
|
+
| `onReady()` | 连接 PM2、挂 log bus,并 **setImmediate** 异步 `reconcile`(不阻塞父应用 ready) |
|
|
418
479
|
| `onClose()` | 断开 bus / PM2(由插件 `onClose` 调用) |
|
|
419
|
-
| `reconcile()` |
|
|
480
|
+
| `reconcile()` | 调用 `app.syncAllStatuses({ recoverIfMissing: true })`:按 PM2 真实状态写回 DB;`running`/`deploying` 且进程缺失时尝试拉起 |
|
|
420
481
|
|
|
421
482
|
#### 数据模型
|
|
422
483
|
|
|
@@ -434,7 +495,7 @@ data: {"appName":"demo","stream":"out","content":"...","line":1}
|
|
|
434
495
|
| description | string / null | 描述 |
|
|
435
496
|
| env | object | 自有环境变量 |
|
|
436
497
|
| pm2Config | object | PM2 覆盖 |
|
|
437
|
-
| options | object |
|
|
498
|
+
| options | object | 扩展字段;`secretEnvKeys` 为显式密钥键名列表 |
|
|
438
499
|
| port | number | 分配端口 |
|
|
439
500
|
| status | string | `idle` / `deploying` / `running` / `stopped` / `error` |
|
|
440
501
|
| currentVersionId | string / null | 当前部署版本 id |
|
|
@@ -484,3 +545,5 @@ data: {"appName":"demo","stream":"out","content":"...","line":1}
|
|
|
484
545
|
```
|
|
485
546
|
pickHostEnv(passthroughEnvKeys) → app.env → PORT=分配端口(强制)
|
|
486
547
|
```
|
|
548
|
+
|
|
549
|
+
API 响应脱敏:`secretEnvKeyPattern` ∪ `options.secretEnvKeys`;明文密钥不回传,顶层 `secretEnvKeys` 只暴露键名。
|
package/index.js
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
const fp = require('fastify-plugin');
|
|
2
2
|
const path = require('node:path');
|
|
3
|
+
const fs = require('fs-extra');
|
|
4
|
+
const { assertDefaultAppDbSeparated } = require('./libs/utils/dbIdentity');
|
|
3
5
|
|
|
4
6
|
module.exports = fp(
|
|
5
7
|
async (fastify, options) => {
|
|
8
|
+
const appsRoot = options.appsRoot || path.join(process.cwd(), 'managed-apps');
|
|
6
9
|
options = Object.assign(
|
|
7
10
|
{},
|
|
8
11
|
{
|
|
9
12
|
dbTableNamePrefix: 't_app_manager_',
|
|
10
13
|
name: 'appManager',
|
|
11
14
|
prefix: '/api/v1/app-manager',
|
|
12
|
-
appsRoot
|
|
15
|
+
appsRoot,
|
|
13
16
|
portMin: 4000,
|
|
14
17
|
portMax: 7999,
|
|
15
18
|
pathPrefix: '/app',
|
|
@@ -27,6 +30,15 @@ module.exports = fp(
|
|
|
27
30
|
secretEnvKeyPattern: /(SECRET|PASSWORD|TOKEN|KEY|PRIVATE)/i,
|
|
28
31
|
sqlPath: 'sql',
|
|
29
32
|
migrateBeforeStart: false,
|
|
33
|
+
defaultAppDb: {
|
|
34
|
+
dialect: 'sqlite',
|
|
35
|
+
storage: path.join(appsRoot, '_shared', 'apps-data.sqlite'),
|
|
36
|
+
logging: false
|
|
37
|
+
},
|
|
38
|
+
dbQueryMaxRows: 500,
|
|
39
|
+
dbQueryTimeoutMs: 15000,
|
|
40
|
+
/** 宿主 fastify-sequelize 命名连接,用于默认托管库(推荐,复用宿主 pg 等驱动) */
|
|
41
|
+
defaultAppDbConnection: null,
|
|
30
42
|
pm2Defaults: {
|
|
31
43
|
exec_mode: 'fork',
|
|
32
44
|
instances: 1,
|
|
@@ -47,6 +59,22 @@ module.exports = fp(
|
|
|
47
59
|
options
|
|
48
60
|
);
|
|
49
61
|
|
|
62
|
+
if (!options.defaultAppDb) {
|
|
63
|
+
options.defaultAppDb = {
|
|
64
|
+
dialect: 'sqlite',
|
|
65
|
+
storage: path.join(options.appsRoot, '_shared', 'apps-data.sqlite'),
|
|
66
|
+
logging: false
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (fastify.sequelize?.instance) {
|
|
71
|
+
assertDefaultAppDbSeparated(options.defaultAppDb, fastify.sequelize.instance);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (options.defaultAppDb?.dialect === 'sqlite' && options.defaultAppDb.storage) {
|
|
75
|
+
await fs.ensureDir(path.dirname(options.defaultAppDb.storage));
|
|
76
|
+
}
|
|
77
|
+
|
|
50
78
|
await fastify.register(require('@fastify/multipart'), {
|
|
51
79
|
limits: {
|
|
52
80
|
fileSize: options.maxZipSize
|
package/libs/controllers/app.js
CHANGED
|
@@ -66,7 +66,8 @@ module.exports = fp(async (fastify, options) => {
|
|
|
66
66
|
required: ['name', 'env'],
|
|
67
67
|
properties: {
|
|
68
68
|
name: { type: 'string' },
|
|
69
|
-
env: { type: 'object' }
|
|
69
|
+
env: { type: 'object' },
|
|
70
|
+
secretEnvKeys: { type: 'array', items: { type: 'string' } }
|
|
70
71
|
}
|
|
71
72
|
}
|
|
72
73
|
}
|
|
@@ -183,7 +184,7 @@ module.exports = fp(async (fastify, options) => {
|
|
|
183
184
|
async request => services.app.detail(request.query)
|
|
184
185
|
);
|
|
185
186
|
|
|
186
|
-
for (const action of ['start', 'stop', 'restart'
|
|
187
|
+
for (const action of ['start', 'stop', 'restart']) {
|
|
187
188
|
fastify.post(
|
|
188
189
|
`${options.prefix}/app/${action}`,
|
|
189
190
|
{
|
|
@@ -201,6 +202,29 @@ module.exports = fp(async (fastify, options) => {
|
|
|
201
202
|
);
|
|
202
203
|
}
|
|
203
204
|
|
|
205
|
+
fastify.post(
|
|
206
|
+
`${options.prefix}/app/remove`,
|
|
207
|
+
{
|
|
208
|
+
onRequest: auth(),
|
|
209
|
+
schema: {
|
|
210
|
+
summary: '删除应用(默认可先导出并清理所属表)',
|
|
211
|
+
body: {
|
|
212
|
+
type: 'object',
|
|
213
|
+
required: ['name'],
|
|
214
|
+
properties: {
|
|
215
|
+
name: { type: 'string' },
|
|
216
|
+
exportBeforeRemove: { type: 'boolean', default: true },
|
|
217
|
+
cleanupData: { type: 'boolean', default: true },
|
|
218
|
+
allowRemoveIfCleanupIncomplete: { type: 'boolean', default: false },
|
|
219
|
+
force: { type: 'boolean', default: false },
|
|
220
|
+
removeSqliteFile: { type: 'boolean', default: false }
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
async request => services.app.remove(request.body)
|
|
226
|
+
);
|
|
227
|
+
|
|
204
228
|
fastify.get(
|
|
205
229
|
`${options.prefix}/app/logs`,
|
|
206
230
|
{
|
|
@@ -213,8 +237,12 @@ module.exports = fp(async (fastify, options) => {
|
|
|
213
237
|
properties: {
|
|
214
238
|
name: { type: 'string' },
|
|
215
239
|
stream: { type: 'string', enum: ['out', 'err'], default: 'out' },
|
|
216
|
-
perPage: { type: 'number', default: 100 },
|
|
217
|
-
currentPage: { type: 'number', default: 1 }
|
|
240
|
+
perPage: { type: 'number', default: 100, maximum: 100 },
|
|
241
|
+
currentPage: { type: 'number', default: 1 },
|
|
242
|
+
beforeLine: {
|
|
243
|
+
type: 'number',
|
|
244
|
+
description: 'Load up to perPage lines with line number < beforeLine (for scroll-up history)'
|
|
245
|
+
}
|
|
218
246
|
}
|
|
219
247
|
}
|
|
220
248
|
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
const fp = require('fastify-plugin');
|
|
2
|
+
|
|
3
|
+
module.exports = fp(async (fastify, options) => {
|
|
4
|
+
const { services } = fastify[options.name];
|
|
5
|
+
const auth = () => options.createAuthenticate();
|
|
6
|
+
|
|
7
|
+
fastify.get(
|
|
8
|
+
`${options.prefix}/app/db/tables`,
|
|
9
|
+
{
|
|
10
|
+
onRequest: auth(),
|
|
11
|
+
schema: {
|
|
12
|
+
summary: '列出应用所属数据表',
|
|
13
|
+
query: {
|
|
14
|
+
type: 'object',
|
|
15
|
+
required: ['name'],
|
|
16
|
+
properties: {
|
|
17
|
+
name: { type: 'string' },
|
|
18
|
+
scope: { type: 'string', enum: ['owned', 'all'] }
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
async request => services.dbops.listTables(request.query)
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
fastify.post(
|
|
27
|
+
`${options.prefix}/app/db/tables/register`,
|
|
28
|
+
{
|
|
29
|
+
onRequest: auth(),
|
|
30
|
+
schema: {
|
|
31
|
+
summary: '登记应用所属表',
|
|
32
|
+
body: {
|
|
33
|
+
type: 'object',
|
|
34
|
+
required: ['name', 'tables'],
|
|
35
|
+
properties: {
|
|
36
|
+
name: { type: 'string' },
|
|
37
|
+
tables: { type: 'array', items: { type: 'string' } },
|
|
38
|
+
mode: { type: 'string', enum: ['union', 'replace'] }
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
async request => services.dbops.registerTables(request.body)
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
fastify.post(
|
|
47
|
+
`${options.prefix}/app/db/tables/sync-owned`,
|
|
48
|
+
{
|
|
49
|
+
onRequest: auth(),
|
|
50
|
+
schema: {
|
|
51
|
+
summary: '扫描数据库并登记本应用可管理表(共享库仅领取未被其它应用登记的表)',
|
|
52
|
+
body: {
|
|
53
|
+
type: 'object',
|
|
54
|
+
required: ['name'],
|
|
55
|
+
properties: {
|
|
56
|
+
name: { type: 'string' }
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
async request => services.dbops.syncOwnedFromDatabase(request.body)
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
fastify.get(
|
|
65
|
+
`${options.prefix}/app/db/rows`,
|
|
66
|
+
{
|
|
67
|
+
onRequest: auth(),
|
|
68
|
+
schema: {
|
|
69
|
+
summary: '分页查询表数据',
|
|
70
|
+
query: {
|
|
71
|
+
type: 'object',
|
|
72
|
+
required: ['name', 'table'],
|
|
73
|
+
properties: {
|
|
74
|
+
name: { type: 'string' },
|
|
75
|
+
table: { type: 'string' },
|
|
76
|
+
currentPage: { type: 'number', default: 1 },
|
|
77
|
+
perPage: { type: 'number', default: 20 },
|
|
78
|
+
filter: { type: 'string', description: 'JSON object: equality or range { $gte,$lte,$gt,$lt }' },
|
|
79
|
+
includeDeleted: { type: 'boolean', default: false },
|
|
80
|
+
keyword: { type: 'string', description: 'Fuzzy search across string columns (OR LIKE)' },
|
|
81
|
+
sort: {
|
|
82
|
+
type: 'string',
|
|
83
|
+
description: 'JSON array of { name, sort: ASC|DESC } for ORDER BY'
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
async request => {
|
|
90
|
+
const query = { ...request.query };
|
|
91
|
+
if (typeof query.filter === 'string' && query.filter) {
|
|
92
|
+
try {
|
|
93
|
+
query.filter = JSON.parse(query.filter);
|
|
94
|
+
} catch (e) {
|
|
95
|
+
const err = new Error('filter must be valid JSON');
|
|
96
|
+
err.statusCode = 400;
|
|
97
|
+
throw err;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (typeof query.sort === 'string' && query.sort) {
|
|
101
|
+
try {
|
|
102
|
+
query.sort = JSON.parse(query.sort);
|
|
103
|
+
} catch (e) {
|
|
104
|
+
const err = new Error('sort must be valid JSON array');
|
|
105
|
+
err.statusCode = 400;
|
|
106
|
+
throw err;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return services.dbops.listRows(query);
|
|
110
|
+
}
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
fastify.get(
|
|
114
|
+
`${options.prefix}/app/db/row`,
|
|
115
|
+
{
|
|
116
|
+
onRequest: auth(),
|
|
117
|
+
schema: {
|
|
118
|
+
summary: '按主键查询一行',
|
|
119
|
+
query: {
|
|
120
|
+
type: 'object',
|
|
121
|
+
required: ['name', 'table'],
|
|
122
|
+
properties: {
|
|
123
|
+
name: { type: 'string' },
|
|
124
|
+
table: { type: 'string' },
|
|
125
|
+
id: {},
|
|
126
|
+
pk: { type: 'string', description: 'JSON primary key object' }
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
async request => {
|
|
132
|
+
const query = { ...request.query };
|
|
133
|
+
if (typeof query.pk === 'string' && query.pk) {
|
|
134
|
+
query.pk = JSON.parse(query.pk);
|
|
135
|
+
}
|
|
136
|
+
return services.dbops.getRow(query);
|
|
137
|
+
}
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
fastify.post(
|
|
141
|
+
`${options.prefix}/app/db/row/save`,
|
|
142
|
+
{
|
|
143
|
+
onRequest: auth(),
|
|
144
|
+
schema: {
|
|
145
|
+
summary: '插入或更新一行(autoGenerate 可对主键走雪花自动生成)',
|
|
146
|
+
body: {
|
|
147
|
+
type: 'object',
|
|
148
|
+
required: ['name', 'table', 'data'],
|
|
149
|
+
properties: {
|
|
150
|
+
name: { type: 'string' },
|
|
151
|
+
table: { type: 'string' },
|
|
152
|
+
data: { type: 'object' },
|
|
153
|
+
autoGenerate: {
|
|
154
|
+
type: 'object',
|
|
155
|
+
additionalProperties: { type: 'boolean' },
|
|
156
|
+
description: '主键字段名 → 是否用 snowflake generateId 自动填充(仅空值时)'
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
async request => services.dbops.saveRow(request.body)
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
fastify.post(
|
|
166
|
+
`${options.prefix}/app/db/row/remove`,
|
|
167
|
+
{
|
|
168
|
+
onRequest: auth(),
|
|
169
|
+
schema: {
|
|
170
|
+
summary: '按主键软删除一行(默认写 deleted_at;hard=true 才物理删除)',
|
|
171
|
+
body: {
|
|
172
|
+
type: 'object',
|
|
173
|
+
required: ['name', 'table'],
|
|
174
|
+
properties: {
|
|
175
|
+
name: { type: 'string' },
|
|
176
|
+
table: { type: 'string' },
|
|
177
|
+
id: {},
|
|
178
|
+
pk: { type: 'object' },
|
|
179
|
+
hard: { type: 'boolean', default: false }
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
async request => services.dbops.removeRow(request.body)
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
fastify.post(
|
|
188
|
+
`${options.prefix}/app/db/row/restore`,
|
|
189
|
+
{
|
|
190
|
+
onRequest: auth(),
|
|
191
|
+
schema: {
|
|
192
|
+
summary: '按主键恢复软删除行(清空 deleted_at)',
|
|
193
|
+
body: {
|
|
194
|
+
type: 'object',
|
|
195
|
+
required: ['name', 'table'],
|
|
196
|
+
properties: {
|
|
197
|
+
name: { type: 'string' },
|
|
198
|
+
table: { type: 'string' },
|
|
199
|
+
id: {},
|
|
200
|
+
pk: { type: 'object' }
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
async request => services.dbops.restoreRow(request.body)
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
fastify.post(
|
|
209
|
+
`${options.prefix}/app/db/query`,
|
|
210
|
+
{
|
|
211
|
+
onRequest: auth(),
|
|
212
|
+
schema: {
|
|
213
|
+
summary: '执行只读查询 SQL',
|
|
214
|
+
body: {
|
|
215
|
+
type: 'object',
|
|
216
|
+
required: ['name', 'sql'],
|
|
217
|
+
properties: {
|
|
218
|
+
name: { type: 'string' },
|
|
219
|
+
sql: { type: 'string' },
|
|
220
|
+
replacements: { type: 'object' },
|
|
221
|
+
maxRows: { type: 'number' }
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
},
|
|
226
|
+
async request => services.dbops.runQuery(request.body)
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
fastify.post(
|
|
230
|
+
`${options.prefix}/app/db/export`,
|
|
231
|
+
{
|
|
232
|
+
onRequest: auth(),
|
|
233
|
+
schema: {
|
|
234
|
+
summary: '导出应用所属表数据',
|
|
235
|
+
body: {
|
|
236
|
+
type: 'object',
|
|
237
|
+
required: ['name'],
|
|
238
|
+
properties: {
|
|
239
|
+
name: { type: 'string' },
|
|
240
|
+
tables: { type: 'array', items: { type: 'string' } },
|
|
241
|
+
mode: { type: 'string', enum: ['json', 'file'] }
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
},
|
|
246
|
+
async request => services.dbops.exportData(request.body)
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
fastify.post(
|
|
250
|
+
`${options.prefix}/app/db/cleanup`,
|
|
251
|
+
{
|
|
252
|
+
onRequest: auth(),
|
|
253
|
+
schema: {
|
|
254
|
+
summary: '清理应用所属表(失败时返回手工 SQL)',
|
|
255
|
+
body: {
|
|
256
|
+
type: 'object',
|
|
257
|
+
required: ['name'],
|
|
258
|
+
properties: {
|
|
259
|
+
name: { type: 'string' },
|
|
260
|
+
dryRun: { type: 'boolean' },
|
|
261
|
+
removeSqliteFile: { type: 'boolean' }
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
},
|
|
266
|
+
async request => services.dbops.cleanup(request.body)
|
|
267
|
+
);
|
|
268
|
+
});
|