feat(gateway): response cli result when run nocobase command (#2563)

* chore(gateway): refresh message in websocket

* chore(gateway): throw error when cli error

* chore(gateway): await ipc server response

* chore: notification message

* fix: build

* chore: notification type

* feat: notification

---------

Co-authored-by: chenos <chenlinxh@gmail.com>
This commit is contained in:
ChengLei Shao 2023-09-01 16:11:27 +08:00 committed by GitHub
parent 1694eb6d73
commit 797f566d70
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 189 additions and 46 deletions

View File

@ -51,6 +51,7 @@ export class Application {
public pm: PluginManager;
public devDynamicImport: DevDynamicImport;
public requirejs: RequireJS;
public notification;
loading = true;
maintained = false;
maintaining = false;
@ -128,14 +129,18 @@ export class Application {
this.ws.on('message', (event) => {
const data = JSON.parse(event.data);
console.log(data.payload);
if (data?.payload?.refresh) {
window.location.reload();
return;
}
if (data.type === 'notification') {
this.notification[data.payload?.type || 'info']({ message: data.payload?.message });
return;
}
const maintaining = data.type === 'maintaining' && data.payload.code !== 'APP_RUNNING';
if (maintaining) {
this.maintaining = true;
this.error = data.payload;
} else if (this.maintaining) {
// && !this.maintained
window.location.reload();
return;
} else {
this.maintaining = false;
this.maintained = true;

View File

@ -1,13 +1,16 @@
import { App } from 'antd';
import React, { memo, useEffect } from 'react';
import { useAPIClient } from '../api-client';
import { useApp } from '../application';
const AppInner = memo(({ children }: { children: React.ReactNode }) => {
const app = useApp();
const { notification } = App.useApp();
const apiClient = useAPIClient();
useEffect(() => {
apiClient.notification = notification;
app.notification = notification;
}, [notification]);
return <>{children}</>;

View File

@ -260,22 +260,11 @@ export const PluginCard = (props: { data: IPluginData }) => {
size={'small'}
onChange={async (checked, e) => {
e.stopPropagation();
// modal.warning({
// title: checked ? t('Plugin starting') : t('Plugin stopping'),
// content: t('The application is reloading, please do not close the page.'),
// okButtonProps: {
// style: {
// display: 'none',
// },
// },
// });
await api.request({
url: `pm:${checked ? 'enable' : 'disable'}/${name}`,
});
// window.location.reload();
// message.success(checked ? t('插件激活成功') : t('插件禁用成功'));
}}
defaultChecked={enabled}
checked={enabled}
></Switch>,
].filter(Boolean),
[api, enabled, navigate, id, name, t],

View File

@ -253,6 +253,31 @@ describe('gateway', () => {
});
});
it('should receive refresh true when app installed', async () => {
await connectClient(port);
const app = new Application({
database: {
dialect: 'sqlite',
storage: ':memory:',
},
});
await waitSecond();
await app.runCommand('start');
await app.runCommand('install');
await waitSecond();
expect(getLastMessage()).toMatchObject({
type: 'maintaining',
payload: {
code: 'APP_RUNNING',
refresh: true,
},
});
});
it('should receive app running message when command end', async () => {
await connectClient(port);
const app = new Application({

View File

@ -2,6 +2,7 @@ import { applyMixins, AsyncEmitter } from '@nocobase/utils';
import { Mutex } from 'async-mutex';
import { EventEmitter } from 'events';
import Application, { ApplicationOptions, MaintainingCommandStatus } from './application';
import { getErrorLevel } from './errors/handler';
type BootOptions = {
appName: string;
@ -90,7 +91,7 @@ export class AppSupervisor extends EventEmitter implements AsyncEmitter {
AppSupervisor.instance = null;
}
setAppStatus(appName: string, status: AppStatus) {
setAppStatus(appName: string, status: AppStatus, options = {}) {
if (this.appStatus[appName] === status) {
return;
}
@ -100,6 +101,7 @@ export class AppSupervisor extends EventEmitter implements AsyncEmitter {
this.emit('appStatusChanged', {
appName,
status,
options,
});
}
@ -257,8 +259,18 @@ export class AppSupervisor extends EventEmitter implements AsyncEmitter {
});
});
app.on('__started', async () => {
this.setAppStatus(app.name, 'running');
app.on('__started', async (_app, options) => {
const { maintainingStatus } = options;
if (
maintainingStatus &&
['install', 'upgrade', 'pm.enable', 'pm.disable'].includes(maintainingStatus.command.name)
) {
this.setAppStatus(app.name, 'running', {
refresh: true,
});
} else {
this.setAppStatus(app.name, 'running');
}
});
app.on('afterStop', async () => {
@ -266,7 +278,7 @@ export class AppSupervisor extends EventEmitter implements AsyncEmitter {
});
app.on('maintaining', (maintainingStatus: MaintainingCommandStatus) => {
const { status } = maintainingStatus;
const { status, command } = maintainingStatus;
switch (status) {
case 'command_begin':
@ -294,8 +306,22 @@ export class AppSupervisor extends EventEmitter implements AsyncEmitter {
break;
case 'command_error':
{
this.setAppError(app.name, maintainingStatus.error);
this.setAppStatus(app.name, 'error');
const errorLevel = getErrorLevel(maintainingStatus.error);
if (errorLevel === 'fatal') {
this.setAppError(app.name, maintainingStatus.error);
this.setAppStatus(app.name, 'error');
break;
}
if (errorLevel === 'warn') {
this.emit('appError', {
appName: app.name,
error: maintainingStatus.error,
});
}
this.setAppStatus(app.name, this.statusBeforeCommanding[app.name]);
}
break;
}

View File

@ -360,7 +360,7 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
}
createCli() {
return new Command('nocobase')
const command = new Command('nocobase')
.usage('[command] [options]')
.hook('preAction', async (_, actionCommand) => {
this.activatedCommand = {
@ -385,9 +385,15 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
await this.restart();
}
});
command.exitOverride((err) => {
throw err;
});
return command;
}
async runAsCLI(argv = process.argv, options?: ParseOptions) {
async runAsCLI(argv = process.argv, options?: ParseOptions & { throwError?: boolean }) {
if (this.activatedCommand) {
return;
}
@ -404,12 +410,21 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
return command;
} catch (error) {
console.log(`run command ${this.activatedCommand.name} error:`, error);
if (!this.activatedCommand) {
this.activatedCommand = {
name: 'unknown',
};
}
this.setMaintaining({
status: 'command_error',
command: this.activatedCommand,
error,
});
if (options?.throwError) {
throw error;
}
} finally {
this.activatedCommand = null;
}
@ -439,7 +454,10 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
this.setMaintainingMessage('emit afterStart');
await this.emitAsync('afterStart', this, options);
await this.emitAsync('__started', this, options);
await this.emitAsync('__started', this, {
maintainingStatus: lodash.cloneDeep(this._maintainingCommandStatus),
});
this.stopped = false;
}

View File

@ -1,4 +1,5 @@
import Application from '../application';
import { PluginCommandError } from '../errors/plugin-command-error';
export default (app: Application) => {
const pm = app.command('pm');
@ -21,11 +22,7 @@ export default (app: Application) => {
try {
await app.pm.enable(plugins);
} catch (error) {
app.log.debug(`Failed to enable plugin: ${error.message}`);
app.setMaintainingMessage(`Failed to enable plugin: ${error.message}`);
await new Promise((resolve) => {
setTimeout(() => resolve(null), 10000);
});
throw new PluginCommandError(`Failed to enable plugin: ${error.message}`);
}
});
@ -35,11 +32,7 @@ export default (app: Application) => {
try {
await app.pm.disable(plugins);
} catch (error) {
app.log.debug(`Failed to disable plugin: ${error.message}`);
app.setMaintainingMessage(`Failed to disable plugin: ${error.message}`);
await new Promise((resolve) => {
setTimeout(() => resolve(null), 10000);
});
throw new PluginCommandError(`Failed to disable plugin: ${error.message}`);
}
});

View File

@ -0,0 +1,16 @@
import { PluginCommandError } from './plugin-command-error';
type ErrorLevel = 'fatal' | 'silly' | 'warn';
export function getErrorLevel(e: Error): ErrorLevel {
// @ts-ignore
if (e.code === 'commander.unknownCommand') {
return 'silly';
}
if (e instanceof PluginCommandError) {
return 'warn';
}
return 'fatal';
}

View File

@ -0,0 +1 @@
export class PluginCommandError extends Error {}

View File

@ -207,7 +207,8 @@ export class Gateway extends EventEmitter {
const ipcClient = await this.tryConnectToIPCServer();
if (ipcClient) {
ipcClient.write({ type: 'passCliArgv', payload: { argv: process.argv } });
await ipcClient.write({ type: 'passCliArgv', payload: { argv: process.argv } });
// should wait for server response
ipcClient.close();
return;
}

View File

@ -1,21 +1,38 @@
import net from 'net';
import * as events from 'events';
const writeJSON = (socket: net.Socket, data: object) => {
export const writeJSON = (socket: net.Socket, data: object) => {
socket.write(JSON.stringify(data) + '\n', 'utf8');
};
export class IPCSocketClient {
export class IPCSocketClient extends events.EventEmitter {
client: net.Socket;
constructor(client: net.Socket) {
super();
this.client = client;
this.client.on('data', (data) => {
const dataAsString = data.toString();
const messages = dataAsString.split('\n');
for (const message of messages) {
if (message.length === 0) {
continue;
}
const dataObj = JSON.parse(message);
this.handleServerMessage(dataObj);
}
});
}
static async getConnection(serverPath: string) {
return new Promise<IPCSocketClient>((resolve, reject) => {
const client = net.createConnection({ path: serverPath }, () => {
// 'connect' listener.
console.log('connected to server!');
resolve(new IPCSocketClient(client));
});
client.on('error', (err) => {
@ -24,11 +41,29 @@ export class IPCSocketClient {
});
}
async handleServerMessage({ type, payload }) {
switch (type) {
case 'error':
console.error(payload.message);
break;
case 'success':
console.log('success');
break;
default:
console.log({ type, payload });
break;
}
this.emit('response', { type, payload });
}
close() {
this.client.end();
}
write(data: any) {
writeJSON(this.client, data);
return new Promise((resolve) => this.once('response', resolve));
}
}

View File

@ -1,7 +1,7 @@
import net from 'net';
import fs from 'fs';
import { Gateway } from '../gateway';
import { AppSupervisor } from '../app-supervisor';
import { writeJSON } from './ipc-socket-client';
export class IPCSocketServer {
socketServer: net.Server;
@ -34,7 +34,20 @@ export class IPCSocketServer {
const dataObj = JSON.parse(message);
IPCSocketServer.handleClientMessage(dataObj);
IPCSocketServer.handleClientMessage(dataObj)
.then(() => {
writeJSON(c, {
type: 'success',
});
})
.catch((err) => {
writeJSON(c, {
type: 'error',
payload: {
message: err.message,
},
});
});
}
});
});
@ -53,8 +66,14 @@ export class IPCSocketServer {
const argv = payload.argv;
const mainApp = await AppSupervisor.getInstance().getApp('main');
mainApp.runAsCLI(argv);
return mainApp.runAsCLI(argv, {
from: 'node',
throwError: true,
});
}
throw new Error(`Unknown message type ${type}`);
}
close() {

View File

@ -59,6 +59,16 @@ export class WSServer {
});
});
AppSupervisor.getInstance().on('appError', async ({ appName, error }) => {
this.sendToConnectionsByTag('app', appName, {
type: 'notification',
payload: {
message: error.message,
type: 'error',
},
});
});
AppSupervisor.getInstance().on('appMaintainingMessageChanged', async ({ appName, message, command, status }) => {
const app = await AppSupervisor.getInstance().getApp(appName, {
withOutBootStrap: true,
@ -76,16 +86,18 @@ export class WSServer {
});
});
AppSupervisor.getInstance().on('appStatusChanged', async ({ appName, status }) => {
AppSupervisor.getInstance().on('appStatusChanged', async ({ appName, status, options }) => {
const app = await AppSupervisor.getInstance().getApp(appName, {
withOutBootStrap: true,
});
const payload = getPayloadByErrorCode(status, { app, appName });
console.log(`send payload ${JSON.stringify(payload)}`);
this.sendToConnectionsByTag('app', appName, {
type: 'maintaining',
payload,
payload: {
...payload,
...options,
},
});
});
}