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> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script>
var match = location.pathname.match(/^\/apps\/([^/]*)\//);
window.routerBase = match ? match[0] : "/";
</script>
</body> </body>
</html> </html>

View File

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

View File

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

View File

@ -41,6 +41,7 @@ export interface ApplicationOptions {
acl?: boolean; acl?: boolean;
logger?: AppLoggerOptions; logger?: AppLoggerOptions;
pmSock?: string; pmSock?: string;
name?: string;
} }
export interface DefaultState extends KoaDefaultState { export interface DefaultState extends KoaDefaultState {
@ -220,6 +221,10 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
return this._logger; return this._logger;
} }
get name() {
return this.options.name || 'main';
}
protected init() { protected init() {
const options = this.options; const options = this.options;
const logger = createAppLogger(options.logger); 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>; declare emitAsync: (event: string | symbol, ...args: any[]) => Promise<boolean>;
toJSON() {
return {
appName: this.name,
};
}
} }
applyMixins(Application, [AsyncEmitter]); 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(); 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 () => { it('should create application', async () => {
const name = `td_${uid()}`; const name = `td_${uid()}`;
const miniApp = await db.getRepository('applications').create({ const miniApp = await db.getRepository('applications').create({
values: { values: {
name, name,
options: {
plugins: [],
},
}, },
}); });
@ -37,6 +65,9 @@ describe('multiple apps create', () => {
await db.getRepository('applications').create({ await db.getRepository('applications').create({
values: { values: {
name, 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 { Application } from '@nocobase/server';
import lodash from 'lodash'; import { AppDbCreator, AppOptionsFactory } from '../server';
import * as path from 'path';
export interface registerAppOptions extends Transactionable { export interface registerAppOptions extends Transactionable {
skipInstall?: boolean; skipInstall?: boolean;
dbCreator: AppDbCreator;
appOptionsFactory: AppOptionsFactory;
} }
export class ApplicationModel extends Model { 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) { static async handleAppStart(app: Application, options: registerAppOptions) {
await app.load(); await app.load();
if (!options?.skipInstall) {
if (!(await app.isInstalled())) {
await app.db.sync({
force: false,
alter: {
drop: false,
},
});
await app.install(); await app.install();
} }
await app.start(); await app.start();
} }
@ -31,47 +32,26 @@ export class ApplicationModel extends Model {
const AppModel = this.constructor as typeof ApplicationModel; 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, { const app = mainApp.appManager.createApplication(appName, {
...AppModel.initOptions(appName, mainApp), ...options.appOptionsFactory(appName, mainApp),
...appOptions, ...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.handleAppStart(app, options);
await AppModel.update( 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 { resolve } from 'path';
import * as path from 'path';
import { ApplicationModel } from './models/application'; 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 { 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) { async install(options?: InstallOptions) {
const repo = this.db.getRepository<any>('collections'); const repo = this.db.getRepository<any>('collections');
if (repo) { 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() { async load() {
this.db.registerModels({ this.db.registerModels({
ApplicationModel, ApplicationModel,
@ -22,7 +111,11 @@ export class PluginMultiAppManager extends Plugin {
this.db.on('applications.afterCreateWithAssociations', async (model: ApplicationModel, options) => { this.db.on('applications.afterCreateWithAssociations', async (model: ApplicationModel, options) => {
const { transaction } = 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) => { this.db.on('applications.afterDestroy', async (model: ApplicationModel) => {
@ -31,7 +124,7 @@ export class PluginMultiAppManager extends Plugin {
this.app.appManager.on( this.app.appManager.on(
'beforeGetApplication', 'beforeGetApplication',
async function lazyLoadApplication({ appManager, name }: { appManager: AppManager; name: string }) { async ({ appManager, name }: { appManager: AppManager; name: string }) => {
if (!appManager.applications.has(name)) { if (!appManager.applications.has(name)) {
const existsApplication = (await this.app.db.getRepository('applications').findOne({ const existsApplication = (await this.app.db.getRepository('applications').findOne({
filter: { filter: {
@ -40,7 +133,10 @@ export class PluginMultiAppManager extends Plugin {
})) as ApplicationModel | null; })) as ApplicationModel | null;
if (existsApplication) { 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({ this.app.acl.registerSnippet({
name: `pm.${this.name}.applications`, name: `pm.${this.name}.applications`,
actions: [ actions: ['applications:*'],
'applications:*',
],
}); });
} }
} }

View File

@ -16,6 +16,7 @@
"@nocobase/plugin-export": "0.9.0-alpha.2", "@nocobase/plugin-export": "0.9.0-alpha.2",
"@nocobase/plugin-file-manager": "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-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-iframe-block": "0.9.0-alpha.2",
"@nocobase/plugin-import": "0.9.0-alpha.2", "@nocobase/plugin-import": "0.9.0-alpha.2",
"@nocobase/plugin-map": "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() { 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; return localPlugins;
} }