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:
parent
1694eb6d73
commit
797f566d70
@ -51,6 +51,7 @@ export class Application {
|
|||||||
public pm: PluginManager;
|
public pm: PluginManager;
|
||||||
public devDynamicImport: DevDynamicImport;
|
public devDynamicImport: DevDynamicImport;
|
||||||
public requirejs: RequireJS;
|
public requirejs: RequireJS;
|
||||||
|
public notification;
|
||||||
loading = true;
|
loading = true;
|
||||||
maintained = false;
|
maintained = false;
|
||||||
maintaining = false;
|
maintaining = false;
|
||||||
@ -128,14 +129,18 @@ export class Application {
|
|||||||
this.ws.on('message', (event) => {
|
this.ws.on('message', (event) => {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
console.log(data.payload);
|
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';
|
const maintaining = data.type === 'maintaining' && data.payload.code !== 'APP_RUNNING';
|
||||||
if (maintaining) {
|
if (maintaining) {
|
||||||
this.maintaining = true;
|
this.maintaining = true;
|
||||||
this.error = data.payload;
|
this.error = data.payload;
|
||||||
} else if (this.maintaining) {
|
|
||||||
// && !this.maintained
|
|
||||||
window.location.reload();
|
|
||||||
return;
|
|
||||||
} else {
|
} else {
|
||||||
this.maintaining = false;
|
this.maintaining = false;
|
||||||
this.maintained = true;
|
this.maintained = true;
|
||||||
|
@ -1,13 +1,16 @@
|
|||||||
import { App } from 'antd';
|
import { App } from 'antd';
|
||||||
import React, { memo, useEffect } from 'react';
|
import React, { memo, useEffect } from 'react';
|
||||||
import { useAPIClient } from '../api-client';
|
import { useAPIClient } from '../api-client';
|
||||||
|
import { useApp } from '../application';
|
||||||
|
|
||||||
const AppInner = memo(({ children }: { children: React.ReactNode }) => {
|
const AppInner = memo(({ children }: { children: React.ReactNode }) => {
|
||||||
|
const app = useApp();
|
||||||
const { notification } = App.useApp();
|
const { notification } = App.useApp();
|
||||||
const apiClient = useAPIClient();
|
const apiClient = useAPIClient();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
apiClient.notification = notification;
|
apiClient.notification = notification;
|
||||||
|
app.notification = notification;
|
||||||
}, [notification]);
|
}, [notification]);
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
|
@ -260,22 +260,11 @@ export const PluginCard = (props: { data: IPluginData }) => {
|
|||||||
size={'small'}
|
size={'small'}
|
||||||
onChange={async (checked, e) => {
|
onChange={async (checked, e) => {
|
||||||
e.stopPropagation();
|
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({
|
await api.request({
|
||||||
url: `pm:${checked ? 'enable' : 'disable'}/${name}`,
|
url: `pm:${checked ? 'enable' : 'disable'}/${name}`,
|
||||||
});
|
});
|
||||||
// window.location.reload();
|
|
||||||
// message.success(checked ? t('插件激活成功') : t('插件禁用成功'));
|
|
||||||
}}
|
}}
|
||||||
defaultChecked={enabled}
|
checked={enabled}
|
||||||
></Switch>,
|
></Switch>,
|
||||||
].filter(Boolean),
|
].filter(Boolean),
|
||||||
[api, enabled, navigate, id, name, t],
|
[api, enabled, navigate, id, name, t],
|
||||||
|
@ -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 () => {
|
it('should receive app running message when command end', async () => {
|
||||||
await connectClient(port);
|
await connectClient(port);
|
||||||
const app = new Application({
|
const app = new Application({
|
||||||
|
@ -2,6 +2,7 @@ import { applyMixins, AsyncEmitter } from '@nocobase/utils';
|
|||||||
import { Mutex } from 'async-mutex';
|
import { Mutex } from 'async-mutex';
|
||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
import Application, { ApplicationOptions, MaintainingCommandStatus } from './application';
|
import Application, { ApplicationOptions, MaintainingCommandStatus } from './application';
|
||||||
|
import { getErrorLevel } from './errors/handler';
|
||||||
|
|
||||||
type BootOptions = {
|
type BootOptions = {
|
||||||
appName: string;
|
appName: string;
|
||||||
@ -90,7 +91,7 @@ export class AppSupervisor extends EventEmitter implements AsyncEmitter {
|
|||||||
AppSupervisor.instance = null;
|
AppSupervisor.instance = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
setAppStatus(appName: string, status: AppStatus) {
|
setAppStatus(appName: string, status: AppStatus, options = {}) {
|
||||||
if (this.appStatus[appName] === status) {
|
if (this.appStatus[appName] === status) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -100,6 +101,7 @@ export class AppSupervisor extends EventEmitter implements AsyncEmitter {
|
|||||||
this.emit('appStatusChanged', {
|
this.emit('appStatusChanged', {
|
||||||
appName,
|
appName,
|
||||||
status,
|
status,
|
||||||
|
options,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -257,8 +259,18 @@ export class AppSupervisor extends EventEmitter implements AsyncEmitter {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on('__started', async () => {
|
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');
|
this.setAppStatus(app.name, 'running');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on('afterStop', async () => {
|
app.on('afterStop', async () => {
|
||||||
@ -266,7 +278,7 @@ export class AppSupervisor extends EventEmitter implements AsyncEmitter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.on('maintaining', (maintainingStatus: MaintainingCommandStatus) => {
|
app.on('maintaining', (maintainingStatus: MaintainingCommandStatus) => {
|
||||||
const { status } = maintainingStatus;
|
const { status, command } = maintainingStatus;
|
||||||
|
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'command_begin':
|
case 'command_begin':
|
||||||
@ -294,8 +306,22 @@ export class AppSupervisor extends EventEmitter implements AsyncEmitter {
|
|||||||
break;
|
break;
|
||||||
case 'command_error':
|
case 'command_error':
|
||||||
{
|
{
|
||||||
|
const errorLevel = getErrorLevel(maintainingStatus.error);
|
||||||
|
|
||||||
|
if (errorLevel === 'fatal') {
|
||||||
this.setAppError(app.name, maintainingStatus.error);
|
this.setAppError(app.name, maintainingStatus.error);
|
||||||
this.setAppStatus(app.name, '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;
|
break;
|
||||||
}
|
}
|
||||||
|
@ -360,7 +360,7 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
|||||||
}
|
}
|
||||||
|
|
||||||
createCli() {
|
createCli() {
|
||||||
return new Command('nocobase')
|
const command = new Command('nocobase')
|
||||||
.usage('[command] [options]')
|
.usage('[command] [options]')
|
||||||
.hook('preAction', async (_, actionCommand) => {
|
.hook('preAction', async (_, actionCommand) => {
|
||||||
this.activatedCommand = {
|
this.activatedCommand = {
|
||||||
@ -385,9 +385,15 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
|||||||
await this.restart();
|
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) {
|
if (this.activatedCommand) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -404,12 +410,21 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
|||||||
|
|
||||||
return command;
|
return command;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(`run command ${this.activatedCommand.name} error:`, error);
|
if (!this.activatedCommand) {
|
||||||
|
this.activatedCommand = {
|
||||||
|
name: 'unknown',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
this.setMaintaining({
|
this.setMaintaining({
|
||||||
status: 'command_error',
|
status: 'command_error',
|
||||||
command: this.activatedCommand,
|
command: this.activatedCommand,
|
||||||
error,
|
error,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (options?.throwError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
this.activatedCommand = null;
|
this.activatedCommand = null;
|
||||||
}
|
}
|
||||||
@ -439,7 +454,10 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
|||||||
|
|
||||||
this.setMaintainingMessage('emit afterStart');
|
this.setMaintainingMessage('emit afterStart');
|
||||||
await this.emitAsync('afterStart', this, options);
|
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;
|
this.stopped = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import Application from '../application';
|
import Application from '../application';
|
||||||
|
import { PluginCommandError } from '../errors/plugin-command-error';
|
||||||
|
|
||||||
export default (app: Application) => {
|
export default (app: Application) => {
|
||||||
const pm = app.command('pm');
|
const pm = app.command('pm');
|
||||||
@ -21,11 +22,7 @@ export default (app: Application) => {
|
|||||||
try {
|
try {
|
||||||
await app.pm.enable(plugins);
|
await app.pm.enable(plugins);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
app.log.debug(`Failed to enable plugin: ${error.message}`);
|
throw new PluginCommandError(`Failed to enable plugin: ${error.message}`);
|
||||||
app.setMaintainingMessage(`Failed to enable plugin: ${error.message}`);
|
|
||||||
await new Promise((resolve) => {
|
|
||||||
setTimeout(() => resolve(null), 10000);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -35,11 +32,7 @@ export default (app: Application) => {
|
|||||||
try {
|
try {
|
||||||
await app.pm.disable(plugins);
|
await app.pm.disable(plugins);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
app.log.debug(`Failed to disable plugin: ${error.message}`);
|
throw new PluginCommandError(`Failed to disable plugin: ${error.message}`);
|
||||||
app.setMaintainingMessage(`Failed to disable plugin: ${error.message}`);
|
|
||||||
await new Promise((resolve) => {
|
|
||||||
setTimeout(() => resolve(null), 10000);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
16
packages/core/server/src/errors/handler.ts
Normal file
16
packages/core/server/src/errors/handler.ts
Normal 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';
|
||||||
|
}
|
1
packages/core/server/src/errors/plugin-command-error.ts
Normal file
1
packages/core/server/src/errors/plugin-command-error.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export class PluginCommandError extends Error {}
|
@ -207,7 +207,8 @@ export class Gateway extends EventEmitter {
|
|||||||
const ipcClient = await this.tryConnectToIPCServer();
|
const ipcClient = await this.tryConnectToIPCServer();
|
||||||
|
|
||||||
if (ipcClient) {
|
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();
|
ipcClient.close();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -1,21 +1,38 @@
|
|||||||
import net from 'net';
|
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');
|
socket.write(JSON.stringify(data) + '\n', 'utf8');
|
||||||
};
|
};
|
||||||
|
|
||||||
export class IPCSocketClient {
|
export class IPCSocketClient extends events.EventEmitter {
|
||||||
client: net.Socket;
|
client: net.Socket;
|
||||||
|
|
||||||
constructor(client: net.Socket) {
|
constructor(client: net.Socket) {
|
||||||
|
super();
|
||||||
|
|
||||||
this.client = client;
|
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) {
|
static async getConnection(serverPath: string) {
|
||||||
return new Promise<IPCSocketClient>((resolve, reject) => {
|
return new Promise<IPCSocketClient>((resolve, reject) => {
|
||||||
const client = net.createConnection({ path: serverPath }, () => {
|
const client = net.createConnection({ path: serverPath }, () => {
|
||||||
// 'connect' listener.
|
// 'connect' listener.
|
||||||
console.log('connected to server!');
|
|
||||||
resolve(new IPCSocketClient(client));
|
resolve(new IPCSocketClient(client));
|
||||||
});
|
});
|
||||||
client.on('error', (err) => {
|
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() {
|
close() {
|
||||||
this.client.end();
|
this.client.end();
|
||||||
}
|
}
|
||||||
|
|
||||||
write(data: any) {
|
write(data: any) {
|
||||||
writeJSON(this.client, data);
|
writeJSON(this.client, data);
|
||||||
|
|
||||||
|
return new Promise((resolve) => this.once('response', resolve));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import net from 'net';
|
import net from 'net';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { Gateway } from '../gateway';
|
|
||||||
import { AppSupervisor } from '../app-supervisor';
|
import { AppSupervisor } from '../app-supervisor';
|
||||||
|
import { writeJSON } from './ipc-socket-client';
|
||||||
|
|
||||||
export class IPCSocketServer {
|
export class IPCSocketServer {
|
||||||
socketServer: net.Server;
|
socketServer: net.Server;
|
||||||
@ -34,7 +34,20 @@ export class IPCSocketServer {
|
|||||||
|
|
||||||
const dataObj = JSON.parse(message);
|
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 argv = payload.argv;
|
||||||
|
|
||||||
const mainApp = await AppSupervisor.getInstance().getApp('main');
|
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() {
|
close() {
|
||||||
|
@ -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 }) => {
|
AppSupervisor.getInstance().on('appMaintainingMessageChanged', async ({ appName, message, command, status }) => {
|
||||||
const app = await AppSupervisor.getInstance().getApp(appName, {
|
const app = await AppSupervisor.getInstance().getApp(appName, {
|
||||||
withOutBootStrap: true,
|
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, {
|
const app = await AppSupervisor.getInstance().getApp(appName, {
|
||||||
withOutBootStrap: true,
|
withOutBootStrap: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const payload = getPayloadByErrorCode(status, { app, appName });
|
const payload = getPayloadByErrorCode(status, { app, appName });
|
||||||
console.log(`send payload ${JSON.stringify(payload)}`);
|
|
||||||
this.sendToConnectionsByTag('app', appName, {
|
this.sendToConnectionsByTag('app', appName, {
|
||||||
type: 'maintaining',
|
type: 'maintaining',
|
||||||
payload,
|
payload: {
|
||||||
|
...payload,
|
||||||
|
...options,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user