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:
parent
2ce5e78aa7
commit
7d77549630
@ -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
|
||||
|
@ -84,6 +84,7 @@ export interface ApplicationOptions {
|
||||
authManager?: AuthManagerOptions;
|
||||
perfHooks?: boolean;
|
||||
telemetry?: AppTelemetryOptions;
|
||||
tmpl?: any;
|
||||
}
|
||||
|
||||
export interface DefaultState extends KoaDefaultState {
|
||||
|
@ -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',
|
||||
|
@ -0,0 +1,3 @@
|
||||
import { name } from '../package.json';
|
||||
|
||||
export const NAMESPACE = name;
|
@ -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"
|
||||
}
|
||||
|
@ -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": "该数据库不支持使用模板创建应用程序"
|
||||
}
|
||||
|
@ -28,6 +28,10 @@ export default defineCollection({
|
||||
type: 'string',
|
||||
name: 'preset',
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'tmpl',
|
||||
},
|
||||
{
|
||||
type: 'boolean',
|
||||
name: 'pinned',
|
||||
|
@ -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() {}
|
||||
}
|
@ -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);
|
||||
|
@ -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<void>;
|
||||
@ -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 {
|
||||
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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user