From 7d77549630d0b8cc47a4a02a6bdb285688177a40 Mon Sep 17 00:00:00 2001 From: TomyJan Date: Wed, 21 Aug 2024 07:05:11 +0800 Subject: [PATCH] feat(multi-app-manager): create sub app via tmpl (#1469) Co-authored-by: sealday Reviewed-on: https://git.daoyoucloud.com/daoyoucloud/tachybase/pulls/1469 Reviewed-by: sealday Co-authored-by: TomyJan Co-committed-by: TomyJan --- .env.example | 5 +- packages/core/server/src/application.ts | 1 + .../client/settings/schemas/applications.ts | 30 +++++-- .../plugin-multi-app-manager/src/constants.ts | 3 + .../src/locale/en-US.json | 10 ++- .../src/locale/zh-CN.json | 7 +- .../src/server/collections/applications.ts | 4 + .../20240820153000-add-apps-tmpl.ts | 27 ++++++ .../src/server/models/application.ts | 2 + .../src/server/server.ts | 87 ++++++++++++++++++- 10 files changed, 162 insertions(+), 14 deletions(-) create mode 100644 packages/plugins/@tachybase/plugin-multi-app-manager/src/constants.ts create mode 100644 packages/plugins/@tachybase/plugin-multi-app-manager/src/server/migrations/20240820153000-add-apps-tmpl.ts diff --git a/.env.example b/.env.example index e2d72ee83..adf5cfd90 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,5 @@ ################# TACHYBASE APPLICATION ################# +# NOTE: production 环境将启用遥测,上报数据至生产环境,除非你知道自己在做什么并已沟通确认,否则请不要这样设置! APP_ENV=development APP_PORT=3000 APP_KEY=test-key @@ -56,7 +57,7 @@ INIT_ROOT_USERNAME=tachybase ################# TELEMETRY ################# -# TELEMETRY_ENABLED=on # 是否启用遥测 +# TELEMETRY_ENABLED=on # 是否启用后端遥测 # TELEMETRY_SERVICE_NAME=tachybase-default-demo # 服务名称,默认为 tachybase-main # OTEL_LOG_LEVEL=debug # OpenTelemetry 日志级别,仅输出到控制台,见 https://open-telemetry.github.io/opentelemetry-js/enums/_opentelemetry_api.DiagLogLevel.html ,不配置则默认不输出日志 # OTEL_METRICS_READER=console,prometheus # 指标读取器,两个均为内置,console 为控制台,prometheus 为 prometheus 服务器 @@ -65,7 +66,7 @@ INIT_ROOT_USERNAME=tachybase # OTEL_TRACES_PROCESSOR=console,otlp # 链路追踪处理器,两个均为内置,console 为控制台,otlp 为 OTLP 规范的追踪服务器 # OTEL_EXPORTER_OTLP_TRACES_ENDPOINT_GRPC=http://localhost:4317 # OTLP https://opentelemetry.io/docs/specs/otlp/ 规范的追踪服务器的 gRPC 端点地址 # 下面为前端 Sentry 跟踪配置,注意修改后需要重新 build 前端代码 -# SENTRY_DSN= # Sentry DSN,用于前端遥测 +# SENTRY_DSN= # Sentry DSN,用于前端遥测,留空则不启用前端遥测 # SENTRY_TRACE_ENABLE=on # 是否启用 Sentry 跟踪,文档 https://docs.sentry.io/platforms/javascript/guides/react/tracing/ # SENTRY_TRACE_SAMPLE_RATE=1.0 # Sentry 跟踪采样率,1.0 为 100%,0.0 为 0%,默认为 1.0 # SENTRY_TRACE_PROPAGATION_TARGETS=localhost,/^\// # Sentry 跟踪传播目标过滤器,多个用逗号分隔,支持正则,默认为 localhost,/^\// ,文档 https://docs.sentry.io/platforms/javascript/tracing/instrumentation/automatic-instrumentation/#tracepropagationtargets diff --git a/packages/core/server/src/application.ts b/packages/core/server/src/application.ts index 65575707d..bb527fdd4 100644 --- a/packages/core/server/src/application.ts +++ b/packages/core/server/src/application.ts @@ -84,6 +84,7 @@ export interface ApplicationOptions { authManager?: AuthManagerOptions; perfHooks?: boolean; telemetry?: AppTelemetryOptions; + tmpl?: any; } export interface DefaultState extends KoaDefaultState { diff --git a/packages/plugins/@tachybase/plugin-multi-app-manager/src/client/settings/schemas/applications.ts b/packages/plugins/@tachybase/plugin-multi-app-manager/src/client/settings/schemas/applications.ts index f68ec55ff..9b2e50f1e 100644 --- a/packages/plugins/@tachybase/plugin-multi-app-manager/src/client/settings/schemas/applications.ts +++ b/packages/plugins/@tachybase/plugin-multi-app-manager/src/client/settings/schemas/applications.ts @@ -1,7 +1,6 @@ import React from 'react'; import { SchemaComponentOptions, - tval, useActionContext, useRecord, useRequest, @@ -119,13 +118,13 @@ export const formSchema: ISchema = { // 'x-content': i18nText('Standalone deployment'), // }, 'options.autoStart': { - title: tval('Start mode', { ns: '@tachybase/plugin-multi-app-manager' }), + title: i18nText('Start mode'), 'x-component': 'Radio.Group', 'x-decorator': 'FormItem', default: false, enum: [ - { label: tval('Start on first visit', { ns: '@tachybase/plugin-multi-app-manager' }), value: false }, - { label: tval('Start with main application', { ns: '@tachybase/plugin-multi-app-manager' }), value: true }, + { label: i18nText('Start on first visit'), value: false }, + { label: i18nText('Start with main application'), value: true }, ], }, cname: { @@ -134,17 +133,32 @@ export const formSchema: ISchema = { 'x-decorator': 'FormItem', }, preset: { - title: tval('Preset', { ns: '@tachybase/plugin-multi-app-manager' }), + title: i18nText('Preset'), 'x-component': 'Select', 'x-decorator': 'FormItem', default: 'tachybase', required: true, enum: [ - { label: tval('tachybase', { ns: '@tachybase/plugin-multi-app-manager' }), value: 'tachybase' }, - { label: tval('hera-rental', { ns: '@tachybase/plugin-multi-app-manager' }), value: 'hera-rental' }, - { label: tval('hera-sancongtou', { ns: '@tachybase/plugin-multi-app-manager' }), value: 'hera-sancongtou' }, + { label: i18nText('tachybase'), value: 'tachybase' }, + { label: i18nText('hera-rental'), value: 'hera-rental' }, + { label: i18nText('hera-sancongtou'), value: 'hera-sancongtou' }, ], }, + tmpl: { + title: i18nText('Template'), + 'x-component': 'RemoteSelect', + 'x-component-props': { + fieldNames: { + label: 'displayName', + value: 'name', + }, + service: { + resource: 'applications', + }, + }, + 'x-decorator': 'FormItem', + 'x-disabled': '{{ !createOnly }}', + }, pinned: { 'x-component': 'CollectionField', 'x-decorator': 'FormItem', diff --git a/packages/plugins/@tachybase/plugin-multi-app-manager/src/constants.ts b/packages/plugins/@tachybase/plugin-multi-app-manager/src/constants.ts new file mode 100644 index 000000000..5af8a5680 --- /dev/null +++ b/packages/plugins/@tachybase/plugin-multi-app-manager/src/constants.ts @@ -0,0 +1,3 @@ +import { name } from '../package.json'; + +export const NAMESPACE = name; diff --git a/packages/plugins/@tachybase/plugin-multi-app-manager/src/locale/en-US.json b/packages/plugins/@tachybase/plugin-multi-app-manager/src/locale/en-US.json index 0e18aecaa..b889dac19 100644 --- a/packages/plugins/@tachybase/plugin-multi-app-manager/src/locale/en-US.json +++ b/packages/plugins/@tachybase/plugin-multi-app-manager/src/locale/en-US.json @@ -7,5 +7,13 @@ "Custom domain": "Custom domain", "Manage applications": "Manage applications", "Standalone deployment": "Standalone deployment", - "Auto start": "Auto start" + "Auto start": "Auto start", + "Start mode": "Start mode", + "Start on first visit": "Start on first visit", + "Start with main application": "Start with main application", + "Preset": "Preset", + "Template": "Template", + "App status": "App status", + "Template not exists": "模板不存在", + "This database does not support to create application using template": "This database does not support to create application using template" } diff --git a/packages/plugins/@tachybase/plugin-multi-app-manager/src/locale/zh-CN.json b/packages/plugins/@tachybase/plugin-multi-app-manager/src/locale/zh-CN.json index 7ca07d595..bcee6648a 100644 --- a/packages/plugins/@tachybase/plugin-multi-app-manager/src/locale/zh-CN.json +++ b/packages/plugins/@tachybase/plugin-multi-app-manager/src/locale/zh-CN.json @@ -10,5 +10,10 @@ "Auto start": "自动启动", "Start mode": "启动方式", "Start on first visit": "首次访问时启动", - "Start with main application": "随主应用一同启动" + "Start with main application": "随主应用一同启动", + "Preset": "预设", + "Template": "模板", + "App status": "应用状态", + "Template not exists": "模板不存在", + "This database does not support to create application using template": "该数据库不支持使用模板创建应用程序" } diff --git a/packages/plugins/@tachybase/plugin-multi-app-manager/src/server/collections/applications.ts b/packages/plugins/@tachybase/plugin-multi-app-manager/src/server/collections/applications.ts index aed37073d..42ef9b847 100644 --- a/packages/plugins/@tachybase/plugin-multi-app-manager/src/server/collections/applications.ts +++ b/packages/plugins/@tachybase/plugin-multi-app-manager/src/server/collections/applications.ts @@ -28,6 +28,10 @@ export default defineCollection({ type: 'string', name: 'preset', }, + { + type: 'string', + name: 'tmpl', + }, { type: 'boolean', name: 'pinned', diff --git a/packages/plugins/@tachybase/plugin-multi-app-manager/src/server/migrations/20240820153000-add-apps-tmpl.ts b/packages/plugins/@tachybase/plugin-multi-app-manager/src/server/migrations/20240820153000-add-apps-tmpl.ts new file mode 100644 index 000000000..4a58f5223 --- /dev/null +++ b/packages/plugins/@tachybase/plugin-multi-app-manager/src/server/migrations/20240820153000-add-apps-tmpl.ts @@ -0,0 +1,27 @@ +import { Migration } from '@tachybase/server'; + +export default class AddAppTmplMigration extends Migration { + appVersion = '<0.21.99'; + + async up() { + const Field = this.context.db.getRepository('fields'); + const existed = await Field.count({ + filter: { + name: 'tmpl', + collectionName: 'applications', + }, + }); + if (!existed) { + await Field.create({ + values: { + name: 'tmpl', + collectionName: 'applications', + type: 'string', + unique: true, + }, + }); + } + } + + async down() {} +} diff --git a/packages/plugins/@tachybase/plugin-multi-app-manager/src/server/models/application.ts b/packages/plugins/@tachybase/plugin-multi-app-manager/src/server/models/application.ts index 62d0dd8fe..14a71a200 100644 --- a/packages/plugins/@tachybase/plugin-multi-app-manager/src/server/models/application.ts +++ b/packages/plugins/@tachybase/plugin-multi-app-manager/src/server/models/application.ts @@ -13,6 +13,7 @@ export class ApplicationModel extends Model { registerToSupervisor(mainApp: Application, options: registerAppOptions) { const appName = this.get('name') as string; const preset = this.get('preset') as string; + const tmpl = this.get('tmpl') as string; const appModelOptions = (this.get('options') as any) || {}; const appOptions = options.appOptionsFactory(appName, mainApp, preset); @@ -20,6 +21,7 @@ export class ApplicationModel extends Model { const subAppOptions = { ...(merge(appOptions, appModelOptions) as object), name: appName, + tmpl, }; return new Application(subAppOptions); diff --git a/packages/plugins/@tachybase/plugin-multi-app-manager/src/server/server.ts b/packages/plugins/@tachybase/plugin-multi-app-manager/src/server/server.ts index 0bd421312..d944b3cac 100644 --- a/packages/plugins/@tachybase/plugin-multi-app-manager/src/server/server.ts +++ b/packages/plugins/@tachybase/plugin-multi-app-manager/src/server/server.ts @@ -4,6 +4,7 @@ import Application, { AppSupervisor, Gateway, Plugin } from '@tachybase/server'; import lodash from 'lodash'; +import { NAMESPACE } from '../constants'; import { ApplicationModel } from '../server'; export type AppDbCreator = (app: Application, options?: Transactionable & { context?: any }) => Promise; @@ -58,6 +59,8 @@ const defaultSubAppUpgradeHandle: SubAppUpgradeHandler = async (mainApp: Applica const defaultDbCreator = async (app: Application) => { const databaseOptions = app.options.database as any; const { host, port, username, password, dialect, database } = databaseOptions; + const tmpl = app.options?.tmpl; + // NOTE: 数据库复制的实现暂时只支持 postgres if (dialect === 'mysql') { const mysql = require('mysql2/promise'); @@ -87,9 +90,37 @@ const defaultDbCreator = async (app: Application) => { await client.connect(); try { - await client.query(`CREATE DATABASE "${database}"`); + if (tmpl) { + app.log.info(`create new app db ${database} with tmpl db ${tmpl}...`); + const tmplExists = await client.query(`SELECT 1 FROM pg_database WHERE datname='${tmpl}'`); + if (tmplExists.rows.length === 0) { + // 模板不存在,报错结束 + const errMsg = `template database ${tmpl} not exists.`; + app.log.error(errMsg); + AppSupervisor.getInstance().setAppError(database, new Error(errMsg)); + } else { + // 模板存在,需要先判断是否需要暂时转换为模板数据库,解除占用,禁止连接,然后开始复制,完成后撤销这些操作 + const result = await client.query(`SELECT datistemplate FROM pg_database WHERE datname='${tmpl}'`); + const tmplDbIsTmpl = result.rows.length > 0 && result.rows[0].datistemplate; + if (!tmplDbIsTmpl) await client.query(`ALTER DATABASE "${tmpl}" IS_TEMPLATE true`); + await client.query(`ALTER DATABASE "${tmpl}" ALLOW_CONNECTIONS false`); + let tmplDbInUse = true; + do { + const tmp = await client.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname='${tmpl}' AND pid<>pg_backend_pid()`, + ); + tmplDbInUse = tmp.rows.length > 0; + } while (tmplDbInUse); + await client.query(`CREATE DATABASE "${database}" WITH TEMPLATE "${tmpl}"`); + if (!tmplDbIsTmpl) await client.query(`ALTER DATABASE "${tmpl}" IS_TEMPLATE false`); + await client.query(`ALTER DATABASE "${tmpl}" ALLOW_CONNECTIONS true`); + } + } else { + await client.query(`CREATE DATABASE "${database}"`); + } } catch (e) { - console.log(e); + app.log.error(JSON.stringify(e)); + AppSupervisor.getInstance().setAppError(database, e); } await client.end(); @@ -327,6 +358,7 @@ export class PluginMultiAppManager extends Plugin { actions: ['applications:*'], }); + // 注入 APP 运行状态,替换 tmpl 为 displayName(name) 的格式 this.app.resourcer.use(async (ctx, next) => { await next(); const { actionName, resourceName, params } = ctx.action; @@ -335,8 +367,59 @@ export class PluginMultiAppManager extends Plugin { for (const application of applications) { const appStatus = AppSupervisor.getInstance().getAppStatus(application.name, 'stopped'); application.status = appStatus; + if (application.tmpl) { + const matchedApp = applications.find((app) => app.name === application.tmpl); + if (matchedApp) { + application.tmpl = `${matchedApp.displayName}(${application.tmpl})`; + } else { + application.tmpl = `Not Exists(${application.tmpl})`; + } + } } } }); + + // 基于模板创建应用时防止使用不适配的数据库,防止存入不存在的模板 + this.app.resourcer.use(async (ctx, next) => { + const { actionName, resourceName, params } = ctx.action; + if (actionName === 'create' && resourceName === 'applications') { + const tmpl = params.values?.tmpl; + if (tmpl) { + const dbType = (this.app.options.database as any).dialect; + if (dbType !== 'postgres') { + ctx.withoutDataWrapping = true; + ctx.status = 400; + ctx.body = { + errors: [ + { + message: ctx.t('This database does not support to create application using template', { + ns: NAMESPACE, + }), + }, + ], + }; + return; + } + const matchedApp = await this.db.getRepository('applications').find({ + filter: { + name: tmpl, + }, + }); + if (matchedApp.length === 0) { + ctx.withoutDataWrapping = true; + ctx.status = 400; + ctx.body = { + errors: [ + { + message: ctx.t('Template not exists', { ns: NAMESPACE }), + }, + ], + }; + return; + } + } + } + await next(); + }); } }