feat: multiple apps admin (#1431)

* fix: dynamic routerBase

* fix: start sub app with empty options

* chore: sync options

* fix: sub app create database

* fix: test

* fix: deps

* feat: register app db creator

* feat: default db creator

* feat: app options factory

* chore: api name

* fix: test

* fix: running sub app

* fix: beforeGetApplication hook

* fix: mysql test

* fix: appManager get selector

* chore: callback

---------

Co-authored-by: Chareice <chareice@live.com>
This commit is contained in:
chenos 2023-02-14 15:30:58 +08:00 committed by GitHub
parent 176bd6e7ac
commit 05740672a0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 199 additions and 83 deletions

View File

@ -36,5 +36,9 @@
</head>
<body>
<div id="root"></div>
<script>
var match = location.pathname.match(/^\/apps\/([^/]*)\//);
window.routerBase = match ? match[0] : "/";
</script>
</body>
</html>

View File

@ -24,6 +24,10 @@ export class APIClient extends APIClientSDK {
interceptors() {
this.axios.interceptors.request.use((config) => {
config.headers['X-With-ACL-Meta'] = true;
const match = location.pathname.match(/^\/apps\/([^/]*)\//);
if (match) {
config.headers['X-App'] = match[1];
}
return config;
});
super.interceptors();

View File

@ -8,7 +8,7 @@ type AppSelector = (req: IncomingMessage) => Application | string | undefined |
export class AppManager extends EventEmitter {
public applications: Map<string, Application> = new Map<string, Application>();
constructor(private app: Application) {
constructor(public app: Application) {
super();
app.on('beforeStop', async (mainApp, options) => {
@ -63,10 +63,13 @@ export class AppManager extends EventEmitter {
callback() {
return async (req: IncomingMessage, res: ServerResponse) => {
let handleApp: any = this.appSelector(req) || this.app;
const appManager = this.app.appManager;
let handleApp: any = appManager.appSelector(req) || appManager.app;
if (typeof handleApp === 'string') {
handleApp = await this.getApplication(handleApp);
handleApp = await appManager.getApplication(handleApp);
if (!handleApp) {
res.statusCode = 404;
return res.end(
@ -74,7 +77,7 @@ export class AppManager extends EventEmitter {
redirectTo: process.env.APP_NOT_FOUND_REDIRECT_TO,
errors: [
{
message: 'Not Found',
message: 'Application Not Found',
},
],
}),

View File

@ -41,6 +41,7 @@ export interface ApplicationOptions {
acl?: boolean;
logger?: AppLoggerOptions;
pmSock?: string;
name?: string;
}
export interface DefaultState extends KoaDefaultState {
@ -220,6 +221,10 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
return this._logger;
}
get name() {
return this.options.name || 'main';
}
protected init() {
const options = this.options;
const logger = createAppLogger(options.logger);
@ -523,6 +528,12 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
}
declare emitAsync: (event: string | symbol, ...args: any[]) => Promise<boolean>;
toJSON() {
return {
appName: this.name,
};
}
}
applyMixins(Application, [AsyncEmitter]);

1
packages/plugins/mdg Symbolic link
View File

@ -0,0 +1 @@
/Users/chareice/projects/plugins-mdg/

View File

@ -21,11 +21,39 @@ describe('multiple apps create', () => {
await app.destroy();
});
it('should register db creator', async () => {
const fn = jest.fn();
const appPlugin = app.getPlugin<PluginMultiAppManager>('PluginMultiAppManager');
const defaultDbCreator = appPlugin.appDbCreator;
appPlugin.setAppDbCreator(async (app) => {
fn();
await defaultDbCreator(app);
});
const name = `td_${uid()}`;
await db.getRepository('applications').create({
values: {
name,
options: {
plugins: [],
},
},
});
await app.appManager.removeApplication(name);
expect(fn).toBeCalled();
});
it('should create application', async () => {
const name = `td_${uid()}`;
const miniApp = await db.getRepository('applications').create({
values: {
name,
options: {
plugins: [],
},
},
});
@ -37,6 +65,9 @@ describe('multiple apps create', () => {
await db.getRepository('applications').create({
values: {
name,
options: {
plugins: [],
},
},
});

View File

@ -1,27 +1,28 @@
import Database, { IDatabaseOptions, Model, Transactionable } from '@nocobase/database';
import { Model, Transactionable } from '@nocobase/database';
import { Application } from '@nocobase/server';
import lodash from 'lodash';
import * as path from 'path';
import { AppDbCreator, AppOptionsFactory } from '../server';
export interface registerAppOptions extends Transactionable {
skipInstall?: boolean;
dbCreator: AppDbCreator;
appOptionsFactory: AppOptionsFactory;
}
export class ApplicationModel extends Model {
static getDatabaseConfig(app: Application): IDatabaseOptions {
const oldConfig =
app.options.database instanceof Database
? (app.options.database as Database).options
: (app.options.database as IDatabaseOptions);
return lodash.cloneDeep(lodash.omit(oldConfig, ['migrator']));
}
static async handleAppStart(app: Application, options: registerAppOptions) {
await app.load();
if (!options?.skipInstall) {
if (!(await app.isInstalled())) {
await app.db.sync({
force: false,
alter: {
drop: false,
},
});
await app.install();
}
await app.start();
}
@ -31,47 +32,26 @@ export class ApplicationModel extends Model {
const AppModel = this.constructor as typeof ApplicationModel;
const createDatabase = async () => {
const database = appName;
const { host, port, username, password, dialect } = mainApp.db.options;
if (dialect === 'mysql') {
const mysql = require('mysql2/promise');
const connection = await mysql.createConnection({ host, port, user: username, password });
await connection.query(`CREATE DATABASE IF NOT EXISTS \`${database}\`;`);
await connection.close();
}
if (dialect === 'postgres') {
const { Client } = require('pg');
const client = new Client({
host,
port,
user: username,
password,
database: 'postgres',
});
await client.connect();
try {
await client.query(`CREATE DATABASE "${database}"`);
} catch (e) {}
await client.end();
}
};
if (!options?.skipInstall) {
await createDatabase();
}
const app = mainApp.appManager.createApplication(appName, {
...AppModel.initOptions(appName, mainApp),
...options.appOptionsFactory(appName, mainApp),
...appOptions,
});
const isInstalled = await (async () => {
try {
return await app.isInstalled();
} catch (e) {
if (e.message.includes('does not exist') || e.message.includes('Unknown database')) {
return false;
}
throw e;
}
})();
if (!isInstalled) {
await options.dbCreator(app);
}
await AppModel.handleAppStart(app, options);
await AppModel.update(
@ -87,25 +67,4 @@ export class ApplicationModel extends Model {
},
);
}
static initOptions(appName: string, mainApp: Application) {
const rawDatabaseOptions = this.getDatabaseConfig(mainApp);
if (rawDatabaseOptions.dialect === 'sqlite') {
const mainAppStorage = rawDatabaseOptions.storage;
if (mainAppStorage !== ':memory:') {
const mainStorageDir = path.dirname(mainAppStorage);
rawDatabaseOptions.storage = path.join(mainStorageDir, `${appName}.sqlite`);
}
} else {
rawDatabaseOptions.database = appName;
}
return {
database: {
...rawDatabaseOptions,
tablePrefix: '',
},
};
}
}

View File

@ -1,8 +1,91 @@
import { AppManager, InstallOptions, Plugin } from '@nocobase/server';
import Application, { AppManager, InstallOptions, Plugin } from '@nocobase/server';
import { resolve } from 'path';
import * as path from 'path';
import { ApplicationModel } from './models/application';
import Database, { IDatabaseOptions, Model, Transactionable } from '@nocobase/database';
import lodash from 'lodash';
export type AppDbCreator = (app: Application) => Promise<void>;
export type AppOptionsFactory = (appName: string, mainApp: Application) => any;
const defaultDbCreator = async (app: Application) => {
const databaseOptions = app.options.database as any;
const { host, port, username, password, dialect, database } = databaseOptions;
if (dialect === 'mysql') {
const mysql = require('mysql2/promise');
const connection = await mysql.createConnection({ host, port, user: username, password });
await connection.query(`CREATE DATABASE IF NOT EXISTS \`${database}\`;`);
await connection.close();
}
if (dialect === 'postgres') {
const { Client } = require('pg');
const client = new Client({
host,
port,
user: username,
password,
database: 'postgres',
});
await client.connect();
try {
await client.query(`CREATE DATABASE "${database}"`);
} catch (e) {}
await client.end();
}
};
const defaultAppOptionsFactory = (appName: string, mainApp: Application) => {
const rawDatabaseOptions = PluginMultiAppManager.getDatabaseConfig(mainApp);
if (rawDatabaseOptions.dialect === 'sqlite') {
const mainAppStorage = rawDatabaseOptions.storage;
if (mainAppStorage !== ':memory:') {
const mainStorageDir = path.dirname(mainAppStorage);
rawDatabaseOptions.storage = path.join(mainStorageDir, `${appName}.sqlite`);
}
} else {
rawDatabaseOptions.database = appName;
}
return {
database: {
...rawDatabaseOptions,
tablePrefix: '',
},
plugins: ['nocobase'],
resourcer: {
prefix: '/api',
},
};
};
export class PluginMultiAppManager extends Plugin {
appDbCreator: AppDbCreator = defaultDbCreator;
appOptionsFactory: AppOptionsFactory = defaultAppOptionsFactory;
setAppOptionsFactory(factory: AppOptionsFactory) {
this.appOptionsFactory = factory;
}
setAppDbCreator(appDbCreator: AppDbCreator) {
this.appDbCreator = appDbCreator;
}
static getDatabaseConfig(app: Application): IDatabaseOptions {
const oldConfig =
app.options.database instanceof Database
? (app.options.database as Database).options
: (app.options.database as IDatabaseOptions);
return lodash.cloneDeep(lodash.omit(oldConfig, ['migrator']));
}
async install(options?: InstallOptions) {
const repo = this.db.getRepository<any>('collections');
if (repo) {
@ -10,6 +93,12 @@ export class PluginMultiAppManager extends Plugin {
}
}
beforeLoad(): void {
this.app.appManager.setAppSelector((req) => {
return (req.headers['x-app'] || null) as any;
});
}
async load() {
this.db.registerModels({
ApplicationModel,
@ -22,7 +111,11 @@ export class PluginMultiAppManager extends Plugin {
this.db.on('applications.afterCreateWithAssociations', async (model: ApplicationModel, options) => {
const { transaction } = options;
await model.registerToMainApp(this.app, { transaction });
await model.registerToMainApp(this.app, {
transaction,
dbCreator: this.appDbCreator,
appOptionsFactory: this.appOptionsFactory,
});
});
this.db.on('applications.afterDestroy', async (model: ApplicationModel) => {
@ -31,7 +124,7 @@ export class PluginMultiAppManager extends Plugin {
this.app.appManager.on(
'beforeGetApplication',
async function lazyLoadApplication({ appManager, name }: { appManager: AppManager; name: string }) {
async ({ appManager, name }: { appManager: AppManager; name: string }) => {
if (!appManager.applications.has(name)) {
const existsApplication = (await this.app.db.getRepository('applications').findOne({
filter: {
@ -40,7 +133,10 @@ export class PluginMultiAppManager extends Plugin {
})) as ApplicationModel | null;
if (existsApplication) {
await existsApplication.registerToMainApp(this.app, { skipInstall: true });
await existsApplication.registerToMainApp(this.app, {
dbCreator: this.appDbCreator,
appOptionsFactory: this.appOptionsFactory,
});
}
}
},
@ -48,9 +144,7 @@ export class PluginMultiAppManager extends Plugin {
this.app.acl.registerSnippet({
name: `pm.${this.name}.applications`,
actions: [
'applications:*',
],
actions: ['applications:*'],
});
}
}

View File

@ -16,6 +16,7 @@
"@nocobase/plugin-export": "0.9.0-alpha.2",
"@nocobase/plugin-file-manager": "0.9.0-alpha.2",
"@nocobase/plugin-graph-collection-manager": "0.9.0-alpha.2",
"@nocobase/plugin-multi-app-manager": "0.9.0-alpha.2",
"@nocobase/plugin-iframe-block": "0.9.0-alpha.2",
"@nocobase/plugin-import": "0.9.0-alpha.2",
"@nocobase/plugin-map": "0.9.0-alpha.2",

View File

@ -32,7 +32,15 @@ export class PresetNocoBase extends Plugin {
}
getLocalPlugins() {
const localPlugins = ['sample-hello', 'oidc', 'saml', 'map', 'snapshot-field', 'graph-collection-manager'];
const localPlugins = [
'sample-hello',
'multi-app-manager',
'oidc',
'saml',
'map',
'snapshot-field',
'graph-collection-manager',
];
return localPlugins;
}