@kne/fastify-app-manager 0.1.6 → 0.1.8

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 CHANGED
@@ -49,6 +49,7 @@ Client / Admin UI
49
49
  | Management API | 创建应用、上传版本、部署、启停、日志;均走 `createAuthenticate` |
50
50
  | Domain gateway | Host 精确匹配且状态为 `running` 时透明反代,不改写路径 |
51
51
  | Path gateway | `/app/{name}` 剥前缀;改写绝对路径 Location、Cookie Path、文本类 body |
52
+ | 未运行兜底 | 应用存在但未 `running` 时,网关返回 503 状态页(已停止 / 启动中 / 异常 / 未部署),接口请求返回 JSON,不再落到宿主 404;`/app/{name}` 下应用不存在时返回 404「应用不存在」页 |
52
53
  | PM2 | 每应用一进程,名 `app-manager__{name}`;stdout/stderr 落 `appsRoot/{name}/logs` |
53
54
  | Bootstrap | `onReady` 连接 PM2、挂 log bus;**异步**对托管应用做 `syncAllStatuses`(不阻塞父应用 ready) |
54
55
 
@@ -192,6 +193,7 @@ await fastify.listen({ port: 3000 });
192
193
  | `dbQueryMaxRows` | number | 否 | `500` | 只读 query SQL 最大返回行数 |
193
194
  | `pm2Defaults` | object | 否 | 见下表 | 全局 PM2 默认;可被应用 `pm2Config` 覆盖 |
194
195
  | `createAuthenticate` | function | 否 | 见说明 | 返回 `onRequest` 钩子数组;默认尝试 admin |
196
+ | `createUserAuthenticate` | function | 否 | 见说明 | 应用中心等普通用户接口的 `onRequest` 钩子数组;默认尝试 `fastify.account.authenticate.user` |
195
197
  | `healthCheckPath` | string | 否 | `'/'` | 部署后就绪探测路径 |
196
198
  | `healthCheckTimeoutMs` | number | 否 | `30000` | 探测总超时(毫秒) |
197
199
  | `healthCheckIntervalMs` | number | 否 | `1000` | 探测轮询间隔(毫秒) |
@@ -239,11 +241,13 @@ await fastify.listen({ port: 3000 });
239
241
  | domain | string | 否 | - | 绑定 Host(唯一);冲突返回 409 |
240
242
  | icon | string | 否 | - | 图标 |
241
243
  | description | string | 否 | - | 描述 |
244
+ | category | object / string | 否 | - | 应用分类(应用中心分组用),写入 `options.category`;推荐传 fastify-group 分组对象,对象只保留 `{ code, name }`,字符串原样保存 |
245
+ | isPublic | boolean | 否 | `true` | 是否在公开应用中心展示,写入 `options.isPublic`;未设置视为公开 |
242
246
  | env | object | 否 | `{}` | 应用自有环境变量 |
243
247
  | pm2Config | object | 否 | `{}` | PM2 覆盖项 |
244
248
  | options | object | 否 | `{}` | 扩展字段;可含 `secretEnvKeys: string[]` 显式密钥键名 |
245
249
 
246
- 返回脱敏后的应用对象(含 `port`、`pathUrl`、`passthroughEnvKeys`、`secretEnvKeys`、`status: 'idle'` 等)。
250
+ 返回脱敏后的应用对象(含 `port`、`pathUrl`、`category`、`passthroughEnvKeys`、`secretEnvKeys`、`status: 'idle'` 等)。
247
251
 
248
252
  ##### POST `{prefix}/app/save`
249
253
 
@@ -253,6 +257,8 @@ await fastify.listen({ port: 3000 });
253
257
  |--------|------|------|------|
254
258
  | name | string | 是 | 应用 slug |
255
259
  | label / domain / icon / description / env / pm2Config / options | - | 否 | 传入的字段才更新;`env` 按 patch 语义合并 |
260
+ | category | object / string / null | 否 | 合并写入 `options.category`(对象只保留 `{ code, name }`),不影响 `options` 其它键;传空值清除分类 |
261
+ | isPublic | boolean | 否 | 合并写入 `options.isPublic`,不影响 `options` 其它键 |
256
262
 
257
263
  ##### POST `{prefix}/app/save-env`
258
264
 
@@ -350,6 +356,15 @@ await fastify.listen({ port: 3000 });
350
356
  | versionId | string | 否 | - | 版本主键;与 `version` 二选一 |
351
357
  | version | string | 否 | - | 版本号 |
352
358
  | runMigration | boolean | 否 | `true` | false 时向 app.env 写入 `RUN_SQL_ON_SYNC=false`;预跑 SQL 仍受 `migrateBeforeStart` 约束 |
359
+ | migrations | array | 否 | - | `[{ name, action }]` 逐个指定**待执行**脚本的处理方式;未列出的按 `execute`。仅在 `runMigration` 且 `migrateBeforeStart` 时生效 |
360
+
361
+ `migrations[].action`:
362
+
363
+ | 值 | 行为 |
364
+ |----|------|
365
+ | `execute` | 执行脚本并写入 `_fs_sql_migrations` |
366
+ | `skip` | 不执行,只写入执行记录(之后部署和子应用 sync 都不会再执行) |
367
+ | `hold` | 本次不执行也不记账,保持待执行;子应用若启用 `@kne/fastify-sequelize` 的 `runSqlOnSync`,启动时仍可能执行 |
353
368
 
354
369
  返回示例:
355
370
 
@@ -364,6 +379,18 @@ await fastify.listen({ port: 3000 });
364
379
  }
365
380
  ```
366
381
 
382
+ ##### 版本迁移脚本管理
383
+
384
+ 管理某版本 `server/{migrationPath}` 下的 `.sql` 文件,执行状态读写应用库的 `_fs_sql_migrations`(与 `@kne/fastify-sequelize` 共用,按文件名记账;共享库中不同应用的同名脚本会互相影响)。文件名须匹配 `[A-Za-z0-9_-.]+\.sql`。
385
+
386
+ | 方法 | 路径 | 参数 | 说明 |
387
+ |------|------|------|------|
388
+ | GET | `{prefix}/app/version/migration/list` | `name`, `versionId` | 脚本列表:`name`、`size`、`updatedAt`、`executed`、`executedAt`;另返回 `migrateBeforeStart`,库连接失败时 `dbError` 有值且 `executed` 为 null |
389
+ | GET | `{prefix}/app/version/migration/content` | `name`, `versionId`, `file` | 返回 `{ name, content }` |
390
+ | POST | `{prefix}/app/version/migration/save` | `name`, `versionId`, `file`, `content` | 新增或覆盖脚本文件,并同步版本 `hasMigration` |
391
+ | POST | `{prefix}/app/version/migration/remove` | `name`, `versionId`, `file` | 删除脚本文件(不改执行记录),并同步 `hasMigration` |
392
+ | POST | `{prefix}/app/version/migration/action` | `name`, `versionId`, `file`, `action` | `execute` 立即执行并记账(执行后按表差异合并归属表);`mark` 只记账;`unmark` 删除执行记录 |
393
+
367
394
  ##### GET `{prefix}/app/list`
368
395
 
369
396
  | 参数名 | 类型 | 必填 | 默认值 | 说明 |
@@ -375,6 +402,31 @@ await fastify.listen({ port: 3000 });
375
402
 
376
403
  返回 `{ pageData, totalCount }`,元素均为脱敏后的应用对象。
377
404
 
405
+ ##### GET `{prefix}/app/center/list`
406
+
407
+ 应用中心列表,使用 `createUserAuthenticate` 鉴权(普通登录用户可访问)。仅返回 `status: 'running'` 的应用,且只含公开字段(不含 env、端口、路径等)。
408
+
409
+ ```json
410
+ {
411
+ "pageData": [
412
+ {
413
+ "name": "demo",
414
+ "label": "Demo",
415
+ "icon": "file-id",
416
+ "description": "d",
417
+ "category": { "code": "tools", "name": "工具" },
418
+ "isPublic": true,
419
+ "pathUrl": "/app/demo/"
420
+ }
421
+ ],
422
+ "totalCount": 1
423
+ }
424
+ ```
425
+
426
+ ##### GET `{prefix}/app/center/public-list`
427
+
428
+ 公开应用中心列表,**无需登录**。返回结构同 `app/center/list`,仅包含运行中且 `isPublic` 不为 `false` 的应用。
429
+
378
430
  ##### GET `{prefix}/app/detail`
379
431
 
380
432
  | 参数名 | 类型 | 必填 | 说明 |
@@ -449,14 +501,14 @@ data: {"appName":"demo","stream":"out","content":"...","line":1}
449
501
  | `detail({ name })` | 详情(脱敏) |
450
502
  | `uploadVersion({ name, version, label, zipBuffer })` | 上传版本 |
451
503
  | `listVersions({ name, perPage, currentPage })` | 版本列表 |
452
- | `deploy({ name, versionId, version, runMigration })` | 部署;立即 `deploying`,后台 `syncRealStatus` |
504
+ | `deploy({ name, versionId, version, runMigration, migrations? })` | 部署;立即 `deploying`,后台 `syncRealStatus` |
453
505
  | `start` / `stop` / `restart` | 生命周期;`start`/`restart` 立即 `deploying`,后台按 PM2+health 写回真实状态 |
454
506
  | `syncStatus({ name, recoverIfMissing? })` | 同步单个应用真实状态并返回公开对象 |
455
507
  | `syncAllStatuses({ recoverIfMissing? })` | 批量同步(父应用启动 reconcile 使用) |
456
508
  | `remove({ name, exportBeforeRemove?, cleanupData?, ... })` | 删除;默认可导出+清理所属表 |
457
509
  | `logs({ name, stream, perPage, currentPage })` | 读日志文件 |
458
- | `findByHost(host)` | 网关:按 domain + `running` 查找 |
459
- | `findByPathName(name)` | 网关:按 name + `running` 查找 |
510
+ | `findByHost(host, { running? })` | 网关:按 domain 查找;默认只查 `running`,`running: false` 时不限状态 |
511
+ | `findByPathName(name, { running? })` | 网关:按 name 查找;默认只查 `running`,`running: false` 时不限状态 |
460
512
  | `appendAppLog(appName, stream, content)` | 追加日志并 emit hub 事件 |
461
513
  | `readLastLines(name, stream, n)` | SSE 回放用 |
462
514
  | `getLogHub()` | 获取 EventEmitter |
@@ -471,6 +523,16 @@ data: {"appName":"demo","stream":"out","content":"...","line":1}
471
523
  | `runQuery({ name, sql, replacements? })` | 只读 SQL |
472
524
  | `exportData` / `cleanup` | 导出与清理 |
473
525
 
526
+ ##### services.migration
527
+
528
+ | 方法签名 | 说明 |
529
+ |----------|------|
530
+ | `list({ name, versionId })` | 版本脚本及执行状态 |
531
+ | `content({ name, versionId, file })` | 读脚本内容 |
532
+ | `save({ name, versionId, file, content })` / `remove({ name, versionId, file })` | 新增覆盖 / 删除脚本文件 |
533
+ | `execute` / `mark` / `unmark`(`{ name, versionId, file }`) | 执行并记账 / 只记账 / 删除记录 |
534
+ | `action({ name, versionId, file, action })` | 按 `action` 分发到上面三个方法 |
535
+
474
536
  ##### services.bootstrap
475
537
 
476
538
  | 方法签名 | 说明 |
@@ -495,7 +557,7 @@ data: {"appName":"demo","stream":"out","content":"...","line":1}
495
557
  | description | string / null | 描述 |
496
558
  | env | object | 自有环境变量 |
497
559
  | pm2Config | object | PM2 覆盖 |
498
- | options | object | 扩展字段;`secretEnvKeys` 为显式密钥键名列表 |
560
+ | options | object | 扩展字段;`secretEnvKeys` 为显式密钥键名列表,`category` 为应用分类,`isPublic` 为是否公开(未设置视为公开) |
499
561
  | port | number | 分配端口 |
500
562
  | status | string | `idle` / `deploying` / `running` / `stopped` / `error` |
501
563
  | currentVersionId | string / null | 当前部署版本 id |
@@ -526,6 +588,8 @@ data: {"appName":"demo","stream":"out","content":"...","line":1}
526
588
  | 机制 | 说明 |
527
589
  |------|------|
528
590
  | 路由优先级 | 管理 API(`prefix`)不进网关;先 domain 匹配,再 path 前缀匹配 |
591
+ | 应用未运行 | 命中已存在但非 `running` 的应用时返回 503(`Cache-Control: no-store`、`Retry-After`):浏览器 `GET`/`HEAD` 且 `Accept` 含 `text/html` 返回状态说明页(`deploying` 每 5 秒自动刷新),其余请求返回 JSON `{ statusCode, error, message, appName, appStatus }` |
592
+ | 应用不存在 | `/app/{name}` 下找不到应用时返回 404「应用不存在」页(带返回首页)或 JSON(`appStatus: 'missing'`);`pathPrefix` 之外以及未绑定的域名仍交给宿主路由 |
529
593
  | Location | 相对路径前缀补上 `/app/{name}` |
530
594
  | Set-Cookie | `Path=/` 改为 `Path=/app/{name}` |
531
595
  | Body | 对文本类 Content-Type,将 `"/static/`、`"/api/`、`"/account/` 等前缀改写;有 `content-encoding` 时跳过 |
package/index.js CHANGED
@@ -54,6 +54,13 @@ module.exports = fp(
54
54
  return [fastify.account.authenticate.admin];
55
55
  }
56
56
  return [];
57
+ },
58
+ /** 普通登录用户可访问的接口(应用中心列表) */
59
+ createUserAuthenticate: () => {
60
+ if (fastify.account?.authenticate?.user) {
61
+ return [fastify.account.authenticate.user];
62
+ }
63
+ return [];
57
64
  }
58
65
  },
59
66
  options
@@ -4,6 +4,7 @@ const { createSseReply } = require('../utils/logStream');
4
4
  module.exports = fp(async (fastify, options) => {
5
5
  const { services } = fastify[options.name];
6
6
  const auth = () => options.createAuthenticate();
7
+ const userAuth = () => options.createUserAuthenticate();
7
8
 
8
9
  fastify.post(
9
10
  `${options.prefix}/app/create`,
@@ -20,6 +21,8 @@ module.exports = fp(async (fastify, options) => {
20
21
  domain: { type: 'string' },
21
22
  icon: { type: 'string' },
22
23
  description: { type: 'string' },
24
+ category: { type: ['object', 'string', 'null'] },
25
+ isPublic: { type: 'boolean' },
23
26
  env: { type: 'object' },
24
27
  pm2Config: { type: 'object' },
25
28
  options: { type: 'object' }
@@ -45,6 +48,8 @@ module.exports = fp(async (fastify, options) => {
45
48
  domain: { type: 'string' },
46
49
  icon: { type: 'string' },
47
50
  description: { type: 'string' },
51
+ category: { type: ['object', 'string', 'null'] },
52
+ isPublic: { type: 'boolean' },
48
53
  env: { type: 'object' },
49
54
  pm2Config: { type: 'object' },
50
55
  options: { type: 'object' }
@@ -55,6 +60,27 @@ module.exports = fp(async (fastify, options) => {
55
60
  async request => services.app.save(request.body)
56
61
  );
57
62
 
63
+ fastify.get(
64
+ `${options.prefix}/app/center/list`,
65
+ {
66
+ onRequest: userAuth(),
67
+ schema: {
68
+ summary: '应用中心:运行中应用的公开信息(普通登录用户可访问)'
69
+ }
70
+ },
71
+ async () => services.app.centerList()
72
+ );
73
+
74
+ fastify.get(
75
+ `${options.prefix}/app/center/public-list`,
76
+ {
77
+ schema: {
78
+ summary: '应用中心:运行中且公开的应用(无需登录)'
79
+ }
80
+ },
81
+ async () => services.app.centerList({ publicOnly: true })
82
+ );
83
+
58
84
  fastify.post(
59
85
  `${options.prefix}/app/save-env`,
60
86
  {
@@ -139,7 +165,19 @@ module.exports = fp(async (fastify, options) => {
139
165
  name: { type: 'string' },
140
166
  versionId: { type: 'string' },
141
167
  version: { type: 'string' },
142
- runMigration: { type: 'boolean', default: true }
168
+ runMigration: { type: 'boolean', default: true },
169
+ migrations: {
170
+ type: 'array',
171
+ description: '逐个指定待执行脚本的处理方式;未列出的待执行脚本按 execute 处理',
172
+ items: {
173
+ type: 'object',
174
+ required: ['name', 'action'],
175
+ properties: {
176
+ name: { type: 'string' },
177
+ action: { type: 'string', enum: ['execute', 'skip', 'hold'] }
178
+ }
179
+ }
180
+ }
143
181
  }
144
182
  }
145
183
  }
@@ -1,6 +1,7 @@
1
1
  const fp = require('fastify-plugin');
2
2
  const { Readable } = require('node:stream');
3
3
  const { rewriteLocation, rewriteSetCookiePath, shouldRewriteBody, rewriteBody } = require('../utils/pathRewrite');
4
+ const { wantsHtml, renderUnavailablePage, getStatusText } = require('../utils/unavailablePage');
4
5
 
5
6
  module.exports = fp(async (fastify, options) => {
6
7
  const ns = options.name;
@@ -66,6 +67,35 @@ module.exports = fp(async (fastify, options) => {
66
67
  });
67
68
  };
68
69
 
70
+ // Not running (503) or unknown under the path prefix (404): answer here instead of falling through to the host's 404.
71
+ const replyUnavailable = (request, reply, app) => {
72
+ const missing = app.status === 'missing';
73
+ const statusCode = missing ? 404 : 503;
74
+ reply.code(statusCode).header('cache-control', 'no-store');
75
+ if (!missing) {
76
+ reply.header('retry-after', app.status === 'deploying' ? '5' : '60');
77
+ }
78
+ if (wantsHtml(request)) {
79
+ return reply.type('text/html; charset=utf-8').send(Readable.from([Buffer.from(renderUnavailablePage(app))], { objectMode: false }));
80
+ }
81
+ const payload = {
82
+ statusCode,
83
+ error: missing ? 'Not Found' : 'Service Unavailable',
84
+ message: getStatusText(app.status).title,
85
+ appName: app.name,
86
+ appStatus: app.status
87
+ };
88
+ return reply.type('application/json; charset=utf-8').send(Readable.from([Buffer.from(JSON.stringify(payload))], { objectMode: false }));
89
+ };
90
+
91
+ const decodeName = raw => {
92
+ try {
93
+ return decodeURIComponent(raw);
94
+ } catch (e) {
95
+ return raw;
96
+ }
97
+ };
98
+
69
99
  // Domain + path gateway: run early, but skip management API and let other routes try first for non-matches.
70
100
  fastify.addHook('onRequest', async (request, reply) => {
71
101
  const url = request.raw.url || '';
@@ -79,18 +109,19 @@ module.exports = fp(async (fastify, options) => {
79
109
  }
80
110
 
81
111
  const host = request.headers.host;
82
- const byHost = await services.app.findByHost(host);
112
+ const byHost = await services.app.findByHost(host, { running: false });
83
113
  if (byHost) {
84
- return proxyToApp(request, reply, byHost, { stripPrefix: false });
114
+ return byHost.status === 'running' ? proxyToApp(request, reply, byHost, { stripPrefix: false }) : replyUnavailable(request, reply, byHost);
85
115
  }
86
116
 
87
117
  const match = url.match(new RegExp(`^${pathPrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/([^/?#]+)`));
88
118
  if (match) {
89
- const appName = match[1];
90
- const app = await services.app.findByPathName(appName);
119
+ const appName = decodeName(match[1]);
120
+ const app = await services.app.findByPathName(appName, { running: false });
91
121
  if (app) {
92
- return proxyToApp(request, reply, app, { stripPrefix: true });
122
+ return app.status === 'running' ? proxyToApp(request, reply, app, { stripPrefix: true }) : replyUnavailable(request, reply, app);
93
123
  }
124
+ return replyUnavailable(request, reply, { name: appName, status: 'missing' });
94
125
  }
95
126
  });
96
127
  });
@@ -0,0 +1,97 @@
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
+ const versionProps = {
8
+ name: { type: 'string' },
9
+ versionId: { type: 'string' }
10
+ };
11
+
12
+ fastify.get(
13
+ `${options.prefix}/app/version/migration/list`,
14
+ {
15
+ onRequest: auth(),
16
+ schema: {
17
+ summary: '版本迁移脚本列表(含执行状态)',
18
+ query: {
19
+ type: 'object',
20
+ required: ['name', 'versionId'],
21
+ properties: versionProps
22
+ }
23
+ }
24
+ },
25
+ async request => services.migration.list(request.query)
26
+ );
27
+
28
+ fastify.get(
29
+ `${options.prefix}/app/version/migration/content`,
30
+ {
31
+ onRequest: auth(),
32
+ schema: {
33
+ summary: '读取迁移脚本内容',
34
+ query: {
35
+ type: 'object',
36
+ required: ['name', 'versionId', 'file'],
37
+ properties: Object.assign({}, versionProps, { file: { type: 'string' } })
38
+ }
39
+ }
40
+ },
41
+ async request => services.migration.content(request.query)
42
+ );
43
+
44
+ fastify.post(
45
+ `${options.prefix}/app/version/migration/save`,
46
+ {
47
+ onRequest: auth(),
48
+ schema: {
49
+ summary: '新增或覆盖迁移脚本',
50
+ body: {
51
+ type: 'object',
52
+ required: ['name', 'versionId', 'file', 'content'],
53
+ properties: Object.assign({}, versionProps, {
54
+ file: { type: 'string' },
55
+ content: { type: 'string' }
56
+ })
57
+ }
58
+ }
59
+ },
60
+ async request => services.migration.save(request.body)
61
+ );
62
+
63
+ fastify.post(
64
+ `${options.prefix}/app/version/migration/remove`,
65
+ {
66
+ onRequest: auth(),
67
+ schema: {
68
+ summary: '删除迁移脚本文件(不改执行记录)',
69
+ body: {
70
+ type: 'object',
71
+ required: ['name', 'versionId', 'file'],
72
+ properties: Object.assign({}, versionProps, { file: { type: 'string' } })
73
+ }
74
+ }
75
+ },
76
+ async request => services.migration.remove(request.body)
77
+ );
78
+
79
+ fastify.post(
80
+ `${options.prefix}/app/version/migration/action`,
81
+ {
82
+ onRequest: auth(),
83
+ schema: {
84
+ summary: '执行 / 标记已执行 / 撤销执行记录',
85
+ body: {
86
+ type: 'object',
87
+ required: ['name', 'versionId', 'file', 'action'],
88
+ properties: Object.assign({}, versionProps, {
89
+ file: { type: 'string' },
90
+ action: { type: 'string', enum: ['execute', 'mark', 'unmark'] }
91
+ })
92
+ }
93
+ }
94
+ },
95
+ async request => services.migration.action(request.body)
96
+ );
97
+ });
@@ -7,7 +7,7 @@ const { mergeEnv, maskEnvForResponse, applyEnvPatch, mergePm2Config, normalizeSe
7
7
  const { prepareVersionArtifact } = require('../utils/version');
8
8
  const { injectEntryHtml } = require('../utils/entryInject');
9
9
  const { ensureLogFiles, readLogTail, readLastLines, appendLog } = require('../utils/logFiles');
10
- const { runSqlMigrations } = require('../utils/migrate');
10
+ const { runSqlMigrations, MIGRATION_ACTIONS } = require('../utils/migrate');
11
11
  const { listSqlFiles } = require('../utils/validatePackage');
12
12
  const pm2Util = require('../utils/pm2');
13
13
 
@@ -31,6 +31,18 @@ module.exports = fp(async (fastify, options) => {
31
31
  const mountPrefix = name => `${options.pathPrefix.replace(/\/$/, '')}/${name}`;
32
32
 
33
33
  const resolveSecretEnvKeys = appLike => normalizeSecretKeys(appLike?.options?.secretEnvKeys);
34
+ // 未设置视为公开(与远程组件默认公开一致)
35
+ const isAppPublic = appLike => appLike?.options?.isPublic !== false;
36
+ // 分组选择器的值可能是整条分组记录(含 id/children/options),只保留 code/name
37
+ const normalizeCategory = category => {
38
+ if (!category) {
39
+ return null;
40
+ }
41
+ if (typeof category !== 'object') {
42
+ return String(category);
43
+ }
44
+ return category.code ? { code: category.code, name: category.name || category.code } : null;
45
+ };
34
46
 
35
47
  const toPublicApp = app => {
36
48
  if (!app) {
@@ -43,6 +55,8 @@ module.exports = fp(async (fastify, options) => {
43
55
  json.passthroughEnvKeys = options.passthroughEnvKeys || [];
44
56
  json.secretEnvKeys = collectSecretKeys(json.env || {}, envOpts);
45
57
  json.pathUrl = `${mountPrefix(json.name)}/`;
58
+ json.category = json.options?.category || null;
59
+ json.isPublic = isAppPublic(json);
46
60
  return json;
47
61
  };
48
62
 
@@ -55,7 +69,7 @@ module.exports = fp(async (fastify, options) => {
55
69
  };
56
70
 
57
71
  const create = async data => {
58
- const { name, label, domain, icon, description, env, pm2Config, options: appOptions } = data;
72
+ const { name, label, domain, icon, description, category, isPublic, env, pm2Config, options: appOptions } = data;
59
73
  if (!name || !NAME_RE.test(name)) {
60
74
  httpError(400, 'invalid name: use lowercase slug [a-z0-9-]');
61
75
  }
@@ -89,6 +103,12 @@ module.exports = fp(async (fastify, options) => {
89
103
  if (normalizedOptions.secretEnvKeys !== undefined) {
90
104
  normalizedOptions.secretEnvKeys = normalizeSecretKeys(normalizedOptions.secretEnvKeys);
91
105
  }
106
+ if (category !== undefined) {
107
+ normalizedOptions.category = normalizeCategory(category);
108
+ }
109
+ if (isPublic !== undefined) {
110
+ normalizedOptions.isPublic = !!isPublic;
111
+ }
92
112
 
93
113
  const app = await models.app.create({
94
114
  name,
@@ -108,7 +128,7 @@ module.exports = fp(async (fastify, options) => {
108
128
  return toPublicApp(app);
109
129
  };
110
130
 
111
- const save = async ({ name, ...data }) => {
131
+ const save = async ({ name, category, isPublic, ...data }) => {
112
132
  const app = await getByName(name);
113
133
  const omit = ['name', 'port', 'rootPath', 'pm2Name', 'status', 'currentVersionId'];
114
134
  const patch = {};
@@ -133,6 +153,12 @@ module.exports = fp(async (fastify, options) => {
133
153
  secretEnvKeys: normalizeSecretKeys(patch.options.secretEnvKeys)
134
154
  });
135
155
  }
156
+ if (category !== undefined) {
157
+ patch.options = Object.assign({}, patch.options || app.options || {}, { category: normalizeCategory(category) });
158
+ }
159
+ if (isPublic !== undefined) {
160
+ patch.options = Object.assign({}, patch.options || app.options || {}, { isPublic: !!isPublic });
161
+ }
136
162
  await app.update(patch);
137
163
  return toPublicApp(app);
138
164
  };
@@ -172,6 +198,22 @@ module.exports = fp(async (fastify, options) => {
172
198
  };
173
199
  };
174
200
 
201
+ const centerList = async ({ publicOnly = false } = {}) => {
202
+ const rows = await models.app.findAll({ where: { status: 'running' }, order: [['createdAt', 'DESC']] });
203
+ const pageData = rows
204
+ .filter(app => !publicOnly || isAppPublic(app))
205
+ .map(app => ({
206
+ name: app.name,
207
+ label: app.label,
208
+ icon: app.icon,
209
+ description: app.description,
210
+ category: app.options?.category || null,
211
+ isPublic: isAppPublic(app),
212
+ pathUrl: `${mountPrefix(app.name)}/`
213
+ }));
214
+ return { pageData, totalCount: pageData.length };
215
+ };
216
+
175
217
  const detail = async ({ name }) => {
176
218
  const app = await getByName(name);
177
219
  return toPublicApp(app);
@@ -458,7 +500,7 @@ module.exports = fp(async (fastify, options) => {
458
500
  });
459
501
  };
460
502
 
461
- const deploy = async ({ name, versionId, version, runMigration = true }) => {
503
+ const deploy = async ({ name, versionId, version, runMigration = true, migrations }) => {
462
504
  const app = await getByName(name);
463
505
  let ver = null;
464
506
  if (versionId) {
@@ -499,11 +541,18 @@ module.exports = fp(async (fastify, options) => {
499
541
  } catch (e) {
500
542
  beforeTables = [];
501
543
  }
544
+ const actions = {};
545
+ for (const item of Array.isArray(migrations) ? migrations : []) {
546
+ if (item?.name && MIGRATION_ACTIONS.includes(item.action)) {
547
+ actions[item.name] = item.action;
548
+ }
549
+ }
502
550
  await runSqlMigrations({
503
551
  serverDir: path.join(ver.artifactPath, 'server'),
504
552
  sqlPath: ver.migrationPath || options.sqlPath,
505
553
  env,
506
- Sequelize: fastify.sequelize.Sequelize
554
+ Sequelize: fastify.sequelize.Sequelize,
555
+ actions
507
556
  });
508
557
  try {
509
558
  const afterTables = await dbops.withSequelize(app, async sequelize => dbops.listAllTables(sequelize));
@@ -628,16 +677,16 @@ module.exports = fp(async (fastify, options) => {
628
677
  return readLogTail(file, { perPage, currentPage, beforeLine });
629
678
  };
630
679
 
631
- const findByHost = async host => {
680
+ const findByHost = async (host, { running = true } = {}) => {
632
681
  if (!host) {
633
682
  return null;
634
683
  }
635
684
  const hostname = String(host).split(':')[0].toLowerCase();
636
- return models.app.findOne({ where: { domain: hostname, status: 'running' } });
685
+ return models.app.findOne({ where: Object.assign({ domain: hostname }, running ? { status: 'running' } : {}) });
637
686
  };
638
687
 
639
- const findByPathName = async name => {
640
- return models.app.findOne({ where: { name, status: 'running' } });
688
+ const findByPathName = async (name, { running = true } = {}) => {
689
+ return models.app.findOne({ where: Object.assign({ name }, running ? { status: 'running' } : {}) });
641
690
  };
642
691
 
643
692
  Object.assign(fastify[options.name].services, {
@@ -646,6 +695,7 @@ module.exports = fp(async (fastify, options) => {
646
695
  save,
647
696
  saveEnv,
648
697
  list,
698
+ centerList,
649
699
  detail,
650
700
  uploadVersion,
651
701
  listVersions,
@@ -0,0 +1,166 @@
1
+ const fp = require('fastify-plugin');
2
+ const path = require('node:path');
3
+ const fs = require('fs-extra');
4
+ const { listSqlFiles } = require('../utils/validatePackage');
5
+ const { isValidSqlFileName, readExecutedMigrations, recordMigration, removeMigrationRecord } = require('../utils/migrate');
6
+
7
+ module.exports = fp(async (fastify, options) => {
8
+ const { models } = fastify[options.name];
9
+ const getDbops = () => fastify[options.name].services.dbops;
10
+
11
+ const httpError = (statusCode, message) => {
12
+ if (fastify.httpErrors) {
13
+ if (statusCode === 404) throw fastify.httpErrors.notFound(message);
14
+ if (statusCode === 400) throw fastify.httpErrors.badRequest(message);
15
+ }
16
+ const err = new Error(message);
17
+ err.statusCode = statusCode;
18
+ throw err;
19
+ };
20
+
21
+ const resolveVersion = async ({ name, versionId }) => {
22
+ const app = await models.app.findOne({ where: { name } });
23
+ if (!app) {
24
+ httpError(404, `app ${name} not found`);
25
+ }
26
+ const ver = versionId ? await models.appVersion.findByPk(versionId) : null;
27
+ if (!ver || ver.appName !== name) {
28
+ httpError(404, 'version not found');
29
+ }
30
+ const sqlDir = path.join(ver.artifactPath, 'server', ver.migrationPath || options.sqlPath);
31
+ return { app, ver, sqlDir };
32
+ };
33
+
34
+ const assertFileName = file => {
35
+ if (!isValidSqlFileName(file)) {
36
+ httpError(400, 'invalid sql file name: use [A-Za-z0-9_-.] and end with .sql');
37
+ }
38
+ };
39
+
40
+ const assertFileExists = async (sqlDir, file) => {
41
+ assertFileName(file);
42
+ const filePath = path.join(sqlDir, file);
43
+ if (!(await fs.pathExists(filePath))) {
44
+ httpError(404, `sql file ${file} not found`);
45
+ }
46
+ return filePath;
47
+ };
48
+
49
+ const syncHasMigration = async (ver, sqlDir) => {
50
+ const hasMigration = (await listSqlFiles(sqlDir)).length > 0;
51
+ if (ver.hasMigration !== hasMigration) {
52
+ await ver.update({ hasMigration });
53
+ }
54
+ };
55
+
56
+ const list = async ({ name, versionId }) => {
57
+ const { app, ver, sqlDir } = await resolveVersion({ name, versionId });
58
+ const files = await listSqlFiles(sqlDir);
59
+ let executedMap = new Map();
60
+ let dbError = null;
61
+ try {
62
+ executedMap = await getDbops().withSequelize(app, sequelize => readExecutedMigrations(sequelize));
63
+ } catch (e) {
64
+ dbError = e.message;
65
+ }
66
+ const pageData = await Promise.all(
67
+ files.map(async file => {
68
+ const stat = await fs.stat(path.join(sqlDir, file));
69
+ return {
70
+ name: file,
71
+ size: stat.size,
72
+ updatedAt: stat.mtime,
73
+ executed: dbError ? null : executedMap.has(file),
74
+ executedAt: executedMap.get(file) || null
75
+ };
76
+ })
77
+ );
78
+ return {
79
+ versionId: String(ver.id),
80
+ version: ver.version,
81
+ migrationPath: ver.migrationPath || options.sqlPath,
82
+ migrateBeforeStart: !!options.migrateBeforeStart,
83
+ dbError,
84
+ pageData,
85
+ totalCount: pageData.length
86
+ };
87
+ };
88
+
89
+ const content = async ({ name, versionId, file }) => {
90
+ const { sqlDir } = await resolveVersion({ name, versionId });
91
+ const filePath = await assertFileExists(sqlDir, file);
92
+ return { name: file, content: await fs.readFile(filePath, 'utf8') };
93
+ };
94
+
95
+ const save = async ({ name, versionId, file, content: sql }) => {
96
+ assertFileName(file);
97
+ const { ver, sqlDir } = await resolveVersion({ name, versionId });
98
+ await fs.ensureDir(sqlDir);
99
+ await fs.writeFile(path.join(sqlDir, file), sql || '', 'utf8');
100
+ await syncHasMigration(ver, sqlDir);
101
+ return { name: file };
102
+ };
103
+
104
+ const remove = async ({ name, versionId, file }) => {
105
+ const { ver, sqlDir } = await resolveVersion({ name, versionId });
106
+ const filePath = await assertFileExists(sqlDir, file);
107
+ await fs.remove(filePath);
108
+ await syncHasMigration(ver, sqlDir);
109
+ return { name: file };
110
+ };
111
+
112
+ const execute = async ({ name, versionId, file }) => {
113
+ const { app, sqlDir } = await resolveVersion({ name, versionId });
114
+ const filePath = await assertFileExists(sqlDir, file);
115
+ const sql = await fs.readFile(filePath, 'utf8');
116
+ const dbops = getDbops();
117
+ let beforeTables = [];
118
+ let afterTables = [];
119
+ await dbops.withSequelize(app, async sequelize => {
120
+ beforeTables = await dbops.listAllTables(sequelize).catch(() => []);
121
+ await sequelize.query(sql);
122
+ await recordMigration(sequelize, file);
123
+ afterTables = await dbops.listAllTables(sequelize).catch(() => []);
124
+ });
125
+ try {
126
+ await dbops.mergeOwnedTablesFromDiff(app, beforeTables, afterTables);
127
+ } catch (e) {
128
+ fastify.log.warn({ err: e, app: name, file }, 'merge owned tables after migration failed');
129
+ }
130
+ return { name: file, executed: true };
131
+ };
132
+
133
+ const mark = async ({ name, versionId, file }) => {
134
+ const { app, sqlDir } = await resolveVersion({ name, versionId });
135
+ await assertFileExists(sqlDir, file);
136
+ await getDbops().withSequelize(app, sequelize => recordMigration(sequelize, file));
137
+ return { name: file, executed: true };
138
+ };
139
+
140
+ const unmark = async ({ name, versionId, file }) => {
141
+ assertFileName(file);
142
+ const { app } = await resolveVersion({ name, versionId });
143
+ await getDbops().withSequelize(app, sequelize => removeMigrationRecord(sequelize, file));
144
+ return { name: file, executed: false };
145
+ };
146
+
147
+ const action = async ({ action: type, ...props }) => {
148
+ if (type === 'execute') return execute(props);
149
+ if (type === 'mark') return mark(props);
150
+ if (type === 'unmark') return unmark(props);
151
+ httpError(400, `unknown action ${type}`);
152
+ };
153
+
154
+ Object.assign(fastify[options.name].services, {
155
+ migration: {
156
+ list,
157
+ content,
158
+ save,
159
+ remove,
160
+ execute,
161
+ mark,
162
+ unmark,
163
+ action
164
+ }
165
+ });
166
+ });
@@ -3,44 +3,80 @@ const fs = require('fs-extra');
3
3
  const { listSqlFiles } = require('./validatePackage');
4
4
 
5
5
  const SQL_MIGRATIONS_TABLE = '_fs_sql_migrations';
6
+ const MIGRATION_ACTIONS = ['execute', 'skip', 'hold'];
7
+ const SQL_FILE_NAME_RE = /^[A-Za-z0-9_\-.]+\.sql$/;
8
+
9
+ const isValidSqlFileName = name => typeof name === 'string' && SQL_FILE_NAME_RE.test(name) && !name.includes('..');
10
+
11
+ const ensureMigrationsTable = sequelize =>
12
+ // TIMESTAMP is portable across sqlite / postgres / mysql (avoid DATETIME — missing on PG)
13
+ sequelize.query(
14
+ `CREATE TABLE IF NOT EXISTS ${SQL_MIGRATIONS_TABLE} (
15
+ name VARCHAR(255) PRIMARY KEY,
16
+ executed_at TIMESTAMP
17
+ )`
18
+ );
19
+
20
+ const readExecutedMigrations = async sequelize => {
21
+ await ensureMigrationsTable(sequelize);
22
+ const [rows] = await sequelize.query(`SELECT name, executed_at FROM ${SQL_MIGRATIONS_TABLE}`);
23
+ return new Map((rows || []).map(r => [r.name, r.executed_at || null]));
24
+ };
25
+
26
+ const recordMigration = async (sequelize, name) => {
27
+ await ensureMigrationsTable(sequelize);
28
+ await sequelize.query(`DELETE FROM ${SQL_MIGRATIONS_TABLE} WHERE name = ?`, { replacements: [name] });
29
+ await sequelize.query(`INSERT INTO ${SQL_MIGRATIONS_TABLE} (name, executed_at) VALUES (?, ?)`, {
30
+ replacements: [name, new Date().toISOString()]
31
+ });
32
+ };
33
+
34
+ const removeMigrationRecord = async (sequelize, name) => {
35
+ await ensureMigrationsTable(sequelize);
36
+ await sequelize.query(`DELETE FROM ${SQL_MIGRATIONS_TABLE} WHERE name = ?`, { replacements: [name] });
37
+ };
6
38
 
7
39
  /**
8
40
  * Run .sql files under server/{sqlPath} with the same tracking semantics as @kne/fastify-sequelize.
41
+ * actions: { [file]: 'execute' | 'skip' | 'hold' } for pending files; missing entries default to 'execute'.
42
+ * skip records the file as executed without running it (so the child app won't run it on sync either);
43
+ * hold leaves it pending.
9
44
  */
10
- const runSqlMigrations = async ({ serverDir, sqlPath = 'sql', env = {}, Sequelize: SequelizeCtor } = {}) => {
45
+ const runSqlMigrations = async ({ serverDir, sqlPath = 'sql', env = {}, Sequelize: SequelizeCtor, actions = {} } = {}) => {
11
46
  const sqlDir = path.join(serverDir, sqlPath);
12
47
  const files = await listSqlFiles(sqlDir);
13
48
  if (!files.length) {
14
- return { executed: [], skipped: true };
49
+ return { executed: [], marked: [], held: [], skipped: true };
15
50
  }
16
51
 
17
52
  const sequelize = createSequelizeFromEnv(env, SequelizeCtor);
18
53
  try {
19
54
  await sequelize.authenticate();
20
- // TIMESTAMP is portable across sqlite / postgres / mysql (avoid DATETIME — missing on PG)
21
- await sequelize.query(
22
- `CREATE TABLE IF NOT EXISTS ${SQL_MIGRATIONS_TABLE} (
23
- name VARCHAR(255) PRIMARY KEY,
24
- executed_at TIMESTAMP
25
- )`
26
- );
27
-
28
- const [rows] = await sequelize.query(`SELECT name FROM ${SQL_MIGRATIONS_TABLE}`);
29
- const done = new Set((rows || []).map(r => r.name));
55
+ const done = await readExecutedMigrations(sequelize);
30
56
  const executed = [];
57
+ const marked = [];
58
+ const held = [];
31
59
 
32
60
  for (const file of files) {
33
61
  if (done.has(file)) {
34
62
  continue;
35
63
  }
64
+ const action = actions[file] || 'execute';
65
+ if (action === 'hold') {
66
+ held.push(file);
67
+ continue;
68
+ }
69
+ if (action === 'skip') {
70
+ await recordMigration(sequelize, file);
71
+ marked.push(file);
72
+ continue;
73
+ }
36
74
  const sql = await fs.readFile(path.join(sqlDir, file), 'utf8');
37
75
  await sequelize.query(sql);
38
- await sequelize.query(`INSERT INTO ${SQL_MIGRATIONS_TABLE} (name, executed_at) VALUES (?, ?)`, {
39
- replacements: [file, new Date().toISOString()]
40
- });
76
+ await recordMigration(sequelize, file);
41
77
  executed.push(file);
42
78
  }
43
- return { executed, skipped: false };
79
+ return { executed, marked, held, skipped: false };
44
80
  } finally {
45
81
  await sequelize.close();
46
82
  }
@@ -70,6 +106,12 @@ const createSequelizeFromEnv = (env, SequelizeCtor) => {
70
106
 
71
107
  module.exports = {
72
108
  SQL_MIGRATIONS_TABLE,
109
+ MIGRATION_ACTIONS,
110
+ isValidSqlFileName,
111
+ ensureMigrationsTable,
112
+ readExecutedMigrations,
113
+ recordMigration,
114
+ removeMigrationRecord,
73
115
  runSqlMigrations,
74
116
  createSequelizeFromEnv
75
117
  };
@@ -0,0 +1,77 @@
1
+ const STATUS_TEXT = {
2
+ stopped: { title: '应用已停止', desc: '该应用当前处于停止状态,请联系管理员启动后再访问。' },
3
+ deploying: { title: '应用正在启动', desc: '应用正在部署或启动中,页面将自动刷新。' },
4
+ error: { title: '应用运行异常', desc: '应用启动失败或运行异常,请联系管理员处理。' },
5
+ idle: { title: '应用尚未部署', desc: '该应用还没有部署任何版本,请联系管理员部署后再访问。' },
6
+ missing: { title: '应用不存在', desc: '没有找到这个应用,请检查访问地址是否正确。' }
7
+ };
8
+
9
+ const STATUS_COLOR = {
10
+ deploying: { background: '#e6f4ff', color: '#1677ff' },
11
+ error: { background: '#fff1f0', color: '#f5222d' },
12
+ missing: { background: '#f2f3f5', color: '#8f959e' },
13
+ default: { background: '#fff7e6', color: '#fa8c16' }
14
+ };
15
+
16
+ const escapeHtml = value => String(value == null ? '' : value).replace(/[&<>"']/g, ch => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[ch]);
17
+
18
+ const getStatusText = status => STATUS_TEXT[status] || STATUS_TEXT.stopped;
19
+
20
+ const wantsHtml = request => {
21
+ if (request.method !== 'GET' && request.method !== 'HEAD') {
22
+ return false;
23
+ }
24
+ if (request.headers['x-requested-with']) {
25
+ return false;
26
+ }
27
+ return String(request.headers.accept || '').includes('text/html');
28
+ };
29
+
30
+ /** app: { name, label?, status } — status `missing` renders the not-found variant. */
31
+ const renderUnavailablePage = app => {
32
+ const { title, desc } = getStatusText(app.status);
33
+ const { background, color } = STATUS_COLOR[app.status] || STATUS_COLOR.default;
34
+ const label = escapeHtml(app.label || app.name);
35
+ const autoRefresh = app.status === 'deploying' ? '<meta http-equiv="refresh" content="5">' : '';
36
+ const action = app.status === 'missing' ? `<a class="btn" href="/">返回首页</a>` : `<button type="button" class="btn" onclick="location.reload()">刷新页面</button>`;
37
+ return `<!DOCTYPE html>
38
+ <html lang="zh-CN">
39
+ <head>
40
+ <meta charset="utf-8">
41
+ <meta name="viewport" content="width=device-width, initial-scale=1">
42
+ ${autoRefresh}
43
+ <title>${escapeHtml(title)} - ${label}</title>
44
+ <style>
45
+ * { box-sizing: border-box; }
46
+ body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px;
47
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
48
+ background: #f5f7fa; color: #1f2329; }
49
+ .card { width: 100%; max-width: 440px; padding: 40px 32px; text-align: center; background: #fff; border-radius: 12px;
50
+ box-shadow: 0 4px 24px rgba(31, 35, 41, 0.08); }
51
+ .icon { width: 56px; height: 56px; margin: 0 auto 20px; border-radius: 50%; display: flex; align-items: center; justify-content: center;
52
+ background: ${background}; color: ${color}; font-size: 28px; font-weight: 600; }
53
+ h1 { margin: 0 0 8px; font-size: 20px; font-weight: 600; }
54
+ .app { margin: 0 0 16px; color: #646a73; font-size: 14px; word-break: break-all; }
55
+ .desc { margin: 0 0 28px; color: #8f959e; font-size: 14px; line-height: 1.7; }
56
+ .btn { display: inline-block; padding: 8px 24px; border: 0; border-radius: 6px; background: #1677ff; color: #fff; font-size: 14px;
57
+ text-decoration: none; cursor: pointer; }
58
+ .btn:hover { background: #4096ff; }
59
+ </style>
60
+ </head>
61
+ <body>
62
+ <div class="card">
63
+ <div class="icon">${app.status === 'deploying' ? '&#8635;' : app.status === 'missing' ? '?' : '!'}</div>
64
+ <h1>${escapeHtml(title)}</h1>
65
+ <p class="app">${label}</p>
66
+ <p class="desc">${escapeHtml(desc)}</p>
67
+ ${action}
68
+ </div>
69
+ </body>
70
+ </html>`;
71
+ };
72
+
73
+ module.exports = {
74
+ getStatusText,
75
+ wantsHtml,
76
+ renderUnavailablePage
77
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kne/fastify-app-manager",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Fastify 单机应用市场插件:版本上传、PM2 托管、路径/域名反代与日志 SSE",
5
5
  "main": "index.js",
6
6
  "scripts": {