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 #################
|
################# 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
|
||||||
|
@ -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 {
|
||||||
|
@ -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',
|
||||||
|
@ -0,0 +1,3 @@
|
|||||||
|
import { name } from '../package.json';
|
||||||
|
|
||||||
|
export const NAMESPACE = name;
|
@ -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"
|
||||||
}
|
}
|
||||||
|
@ -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": "该数据库不支持使用模板创建应用程序"
|
||||||
}
|
}
|
||||||
|
@ -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',
|
||||||
|
@ -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) {
|
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);
|
||||||
|
@ -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 {
|
||||||
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) {
|
} 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();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user