feat(multi-app-manager): create sub app via tmpl (#1469)

Co-authored-by: sealday <zhanglin@daoyoucloud.com>
Reviewed-on: daoyoucloud/tachybase#1469
Reviewed-by: sealday <zhanglin@daoyoucloud.com>
Co-authored-by: TomyJan <TomyJan6@gmail.com>
Co-committed-by: TomyJan <TomyJan6@gmail.com>
This commit is contained in:
TomyJan 2024-08-21 07:05:11 +08:00 committed by sealday
parent 2ce5e78aa7
commit 7d77549630
10 changed files with 162 additions and 14 deletions

View File

@ -1,4 +1,5 @@
################# TACHYBASE APPLICATION ################# ################# TACHYBASE APPLICATION #################
# NOTE: production 环境将启用遥测,上报数据至生产环境,除非你知道自己在做什么并已沟通确认,否则请不要这样设置!
APP_ENV=development APP_ENV=development
APP_PORT=3000 APP_PORT=3000
APP_KEY=test-key APP_KEY=test-key
@ -56,7 +57,7 @@ INIT_ROOT_USERNAME=tachybase
################# TELEMETRY ################# ################# TELEMETRY #################
# TELEMETRY_ENABLED=on # 是否启用遥测 # TELEMETRY_ENABLED=on # 是否启用后端遥测
# TELEMETRY_SERVICE_NAME=tachybase-default-demo # 服务名称,默认为 tachybase-main # 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_LOG_LEVEL=debug # OpenTelemetry 日志级别,仅输出到控制台,见 https://open-telemetry.github.io/opentelemetry-js/enums/_opentelemetry_api.DiagLogLevel.html ,不配置则默认不输出日志
# OTEL_METRICS_READER=console,prometheus # 指标读取器两个均为内置console 为控制台prometheus 为 prometheus 服务器 # 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_TRACES_PROCESSOR=console,otlp # 链路追踪处理器两个均为内置console 为控制台otlp 为 OTLP 规范的追踪服务器
# OTEL_EXPORTER_OTLP_TRACES_ENDPOINT_GRPC=http://localhost:4317 # OTLP https://opentelemetry.io/docs/specs/otlp/ 规范的追踪服务器的 gRPC 端点地址 # OTEL_EXPORTER_OTLP_TRACES_ENDPOINT_GRPC=http://localhost:4317 # OTLP https://opentelemetry.io/docs/specs/otlp/ 规范的追踪服务器的 gRPC 端点地址
# 下面为前端 Sentry 跟踪配置,注意修改后需要重新 build 前端代码 # 下面为前端 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_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_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 # SENTRY_TRACE_PROPAGATION_TARGETS=localhost,/^\// # Sentry 跟踪传播目标过滤器,多个用逗号分隔,支持正则,默认为 localhost,/^\// ,文档 https://docs.sentry.io/platforms/javascript/tracing/instrumentation/automatic-instrumentation/#tracepropagationtargets

View File

@ -84,6 +84,7 @@ export interface ApplicationOptions {
authManager?: AuthManagerOptions; authManager?: AuthManagerOptions;
perfHooks?: boolean; perfHooks?: boolean;
telemetry?: AppTelemetryOptions; telemetry?: AppTelemetryOptions;
tmpl?: any;
} }
export interface DefaultState extends KoaDefaultState { export interface DefaultState extends KoaDefaultState {

View File

@ -1,7 +1,6 @@
import React from 'react'; import React from 'react';
import { import {
SchemaComponentOptions, SchemaComponentOptions,
tval,
useActionContext, useActionContext,
useRecord, useRecord,
useRequest, useRequest,
@ -119,13 +118,13 @@ export const formSchema: ISchema = {
// 'x-content': i18nText('Standalone deployment'), // 'x-content': i18nText('Standalone deployment'),
// }, // },
'options.autoStart': { 'options.autoStart': {
title: tval('Start mode', { ns: '@tachybase/plugin-multi-app-manager' }), title: i18nText('Start mode'),
'x-component': 'Radio.Group', 'x-component': 'Radio.Group',
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
default: false, default: false,
enum: [ enum: [
{ label: tval('Start on first visit', { ns: '@tachybase/plugin-multi-app-manager' }), value: false }, { label: i18nText('Start on first visit'), value: false },
{ label: tval('Start with main application', { ns: '@tachybase/plugin-multi-app-manager' }), value: true }, { label: i18nText('Start with main application'), value: true },
], ],
}, },
cname: { cname: {
@ -134,17 +133,32 @@ export const formSchema: ISchema = {
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
}, },
preset: { preset: {
title: tval('Preset', { ns: '@tachybase/plugin-multi-app-manager' }), title: i18nText('Preset'),
'x-component': 'Select', 'x-component': 'Select',
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
default: 'tachybase', default: 'tachybase',
required: true, required: true,
enum: [ enum: [
{ label: tval('tachybase', { ns: '@tachybase/plugin-multi-app-manager' }), value: 'tachybase' }, { label: i18nText('tachybase'), value: 'tachybase' },
{ label: tval('hera-rental', { ns: '@tachybase/plugin-multi-app-manager' }), value: 'hera-rental' }, { label: i18nText('hera-rental'), value: 'hera-rental' },
{ label: tval('hera-sancongtou', { ns: '@tachybase/plugin-multi-app-manager' }), value: 'hera-sancongtou' }, { 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: { pinned: {
'x-component': 'CollectionField', 'x-component': 'CollectionField',
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',

View File

@ -0,0 +1,3 @@
import { name } from '../package.json';
export const NAMESPACE = name;

View File

@ -7,5 +7,13 @@
"Custom domain": "Custom domain", "Custom domain": "Custom domain",
"Manage applications": "Manage applications", "Manage applications": "Manage applications",
"Standalone deployment": "Standalone deployment", "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"
} }

View File

@ -10,5 +10,10 @@
"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": "预设",
"Template": "模板",
"App status": "应用状态",
"Template not exists": "模板不存在",
"This database does not support to create application using template": "该数据库不支持使用模板创建应用程序"
} }

View File

@ -28,6 +28,10 @@ export default defineCollection({
type: 'string', type: 'string',
name: 'preset', name: 'preset',
}, },
{
type: 'string',
name: 'tmpl',
},
{ {
type: 'boolean', type: 'boolean',
name: 'pinned', name: 'pinned',

View File

@ -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() {}
}

View File

@ -13,6 +13,7 @@ export class ApplicationModel extends Model {
registerToSupervisor(mainApp: Application, options: registerAppOptions) { registerToSupervisor(mainApp: Application, options: registerAppOptions) {
const appName = this.get('name') as string; const appName = this.get('name') as string;
const preset = this.get('preset') as string; const preset = this.get('preset') as string;
const tmpl = this.get('tmpl') as string;
const appModelOptions = (this.get('options') as any) || {}; const appModelOptions = (this.get('options') as any) || {};
const appOptions = options.appOptionsFactory(appName, mainApp, preset); const appOptions = options.appOptionsFactory(appName, mainApp, preset);
@ -20,6 +21,7 @@ export class ApplicationModel extends Model {
const subAppOptions = { const subAppOptions = {
...(merge(appOptions, appModelOptions) as object), ...(merge(appOptions, appModelOptions) as object),
name: appName, name: appName,
tmpl,
}; };
return new Application(subAppOptions); return new Application(subAppOptions);

View File

@ -4,6 +4,7 @@ import Application, { AppSupervisor, Gateway, Plugin } from '@tachybase/server';
import lodash from 'lodash'; import lodash from 'lodash';
import { NAMESPACE } from '../constants';
import { ApplicationModel } from '../server'; import { ApplicationModel } from '../server';
export type AppDbCreator = (app: Application, options?: Transactionable & { context?: any }) => Promise<void>; export type AppDbCreator = (app: Application, options?: Transactionable & { context?: any }) => Promise<void>;
@ -58,6 +59,8 @@ const defaultSubAppUpgradeHandle: SubAppUpgradeHandler = async (mainApp: Applica
const defaultDbCreator = async (app: Application) => { const defaultDbCreator = async (app: Application) => {
const databaseOptions = app.options.database as any; const databaseOptions = app.options.database as any;
const { host, port, username, password, dialect, database } = databaseOptions; const { host, port, username, password, dialect, database } = databaseOptions;
const tmpl = app.options?.tmpl;
// NOTE: 数据库复制的实现暂时只支持 postgres
if (dialect === 'mysql') { if (dialect === 'mysql') {
const mysql = require('mysql2/promise'); const mysql = require('mysql2/promise');
@ -87,9 +90,37 @@ const defaultDbCreator = async (app: Application) => {
await client.connect(); await client.connect();
try { try {
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}"`); await client.query(`CREATE DATABASE "${database}"`);
}
} catch (e) { } catch (e) {
console.log(e); app.log.error(JSON.stringify(e));
AppSupervisor.getInstance().setAppError(database, e);
} }
await client.end(); await client.end();
@ -327,6 +358,7 @@ export class PluginMultiAppManager extends Plugin {
actions: ['applications:*'], actions: ['applications:*'],
}); });
// 注入 APP 运行状态,替换 tmpl 为 displayName(name) 的格式
this.app.resourcer.use(async (ctx, next) => { this.app.resourcer.use(async (ctx, next) => {
await next(); await next();
const { actionName, resourceName, params } = ctx.action; const { actionName, resourceName, params } = ctx.action;
@ -335,8 +367,59 @@ export class PluginMultiAppManager extends Plugin {
for (const application of applications) { for (const application of applications) {
const appStatus = AppSupervisor.getInstance().getAppStatus(application.name, 'stopped'); const appStatus = AppSupervisor.getInstance().getAppStatus(application.name, 'stopped');
application.status = appStatus; 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();
});
} }
} }