feat: support reboot application manually (#1889)

* feat(reload-btn): reload application manually

* feat(app): support reboot

* feat: collections load after upgrade

* feat(reboot): support reboot manually

* chore(reboot): some typo

* fix(reboot): fix storage path

* fix(reboot): change restart file path

* fix: menu divider

---------

Co-authored-by: chenos <chenlinxh@gmail.com>
This commit is contained in:
YANG QIA 2023-05-19 20:34:22 +08:00 committed by GitHub
parent c0ef071baf
commit eac034cb23
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 295 additions and 3241 deletions

View File

@ -74,11 +74,22 @@ module.exports = (cli) => {
if (opts.dbSync) { if (opts.dbSync) {
argv.push('--db-sync'); argv.push('--db-sync');
} }
run('ts-node-dev', argv, { const runDevServer = () => {
env: { run('ts-node-dev', argv, {
APP_PORT: serverPort, env: {
}, APP_PORT: serverPort,
}); },
}).catch((err) => {
if (err.exitCode == 100) {
console.log('Restarting server...');
runDevServer();
} else {
console.error(err);
}
});
};
runDevServer();
} }
if (client || !server) { if (client || !server) {
console.log('starting client', 1 * clientPort); console.log('starting client', 1 * clientPort);

View File

@ -1,6 +1,6 @@
const { Command } = require('commander'); const { Command } = require('commander');
const { isDev, run, postCheck, runInstall, promptForTs } = require('../util'); const { isDev, run, postCheck, runInstall, promptForTs } = require('../util');
const { existsSync } = require('fs'); const { existsSync, unlink } = require('fs');
const { resolve } = require('path'); const { resolve } = require('path');
const chalk = require('chalk'); const chalk = require('chalk');
@ -41,12 +41,15 @@ module.exports = (cli) => {
return; return;
} }
await postCheck(opts); await postCheck(opts);
if (opts.quickstart) { const restartMark = resolve(process.cwd(), 'storage', 'restart');
await run('node', [`./packages/${APP_PACKAGE_ROOT}/server/lib/index.js`, 'install', '--ignore-installed']); if (!existsSync(restartMark)) {
await run('node', [`./packages/${APP_PACKAGE_ROOT}/server/lib/index.js`, 'upgrade']); if (opts.quickstart) {
} await run('node', [`./packages/${APP_PACKAGE_ROOT}/server/lib/index.js`, 'install', '--ignore-installed']);
if (opts.dbSync) { await run('node', [`./packages/${APP_PACKAGE_ROOT}/server/lib/index.js`, 'upgrade']);
await run('node', [`./packages/${APP_PACKAGE_ROOT}/server/lib/index.js`, 'db:sync']); }
if (opts.dbSync) {
await run('node', [`./packages/${APP_PACKAGE_ROOT}/server/lib/index.js`, 'db:sync']);
}
} }
if (opts.daemon) { if (opts.daemon) {
run('pm2', ['start', `packages/${APP_PACKAGE_ROOT}/server/lib/index.js`, '--', ...process.argv.slice(2)]); run('pm2', ['start', `packages/${APP_PACKAGE_ROOT}/server/lib/index.js`, '--', ...process.argv.slice(2)]);

View File

@ -17,18 +17,17 @@ const SnippetCheckboxGroup = connect((props) => {
}} }}
value={props.value} value={props.value}
onChange={(values) => { onChange={(values) => {
const snippets = ['ui.*', 'pm', 'pm.*', 'app'];
const disallowSnippets = snippets.map((key) => `!${key}`);
const value = uniq([...(props.value || []), ...values]) const value = uniq([...(props.value || []), ...values])
.filter((key) => key && !['!ui.*', '!pm', '!pm.*'].includes(key)) .filter((key) => key && !disallowSnippets.includes(key))
.map((key) => { .map((key) => {
if (!['ui.*', 'pm', 'pm.*'].includes(key)) { if (!snippets.includes(key) || values?.includes(key)) {
return key;
}
if (values?.includes(key)) {
return key; return key;
} }
return `!${key}`; return `!${key}`;
}); });
for (const key of ['ui.*', 'pm', 'pm.*']) { for (const key of snippets) {
if (!value.includes(key) && !value.includes(`!${key}`)) { if (!value.includes(key) && !value.includes(`!${key}`)) {
value.push(`!${key}`); value.push(`!${key}`);
} }
@ -45,6 +44,9 @@ const SnippetCheckboxGroup = connect((props) => {
<div style={{ marginTop: 8 }}> <div style={{ marginTop: 8 }}>
<Checkbox value="pm.*">{t('Allows to configure plugins')}</Checkbox> <Checkbox value="pm.*">{t('Allows to configure plugins')}</Checkbox>
</div> </div>
<div style={{ marginTop: 8 }}>
<Checkbox value="app">{t('Allows to reboot application')}</Checkbox>
</div>
</Checkbox.Group> </Checkbox.Group>
); );
}); });

View File

@ -16,6 +16,7 @@ const handleErrorMessage = (error) => {
}; };
export class APIClient extends APIClientSDK { export class APIClient extends APIClientSDK {
services: Record<string, Result<any, any>> = {}; services: Record<string, Result<any, any>> = {};
silence = false;
service(uid: string) { service(uid: string) {
return this.services[uid]; return this.services[uid];
@ -38,6 +39,9 @@ export class APIClient extends APIClientSDK {
this.axios.interceptors.response.use( this.axios.interceptors.response.use(
(response) => response, (response) => response,
(error) => { (error) => {
if (this.silence) {
throw error;
}
const redirectTo = error?.response?.data?.redirectTo; const redirectTo = error?.response?.data?.redirectTo;
if (redirectTo) { if (redirectTo) {
return (window.location.href = redirectTo); return (window.location.href = redirectTo);
@ -55,4 +59,9 @@ export class APIClient extends APIClientSDK {
}, },
); );
} }
silent() {
this.silence = true;
return this;
}
} }

View File

@ -777,4 +777,12 @@ export default {
'Add template': '添加模板', 'Add template': '添加模板',
'Display data template selector': '显示数据模板选择框', 'Display data template selector': '显示数据模板选择框',
'Form data templates': '表单数据模板', 'Form data templates': '表单数据模板',
'Reload Application': '重载应用',
'The application is reloading, please do not close the page.': '应用正在重新加载,请勿关闭页面。',
'Application reloading': '应用重新加载中',
'Reboot Application': '重启应用',
"Allows to reboot application": "允许重启应用",
'The will interrupt service, it may take a few seconds to restart. Are you sure to continue?': '重启将会中断当前服务,这个过程可能需要一点时间,确定要继续吗?',
'Reboot': '重启',
} }

View File

@ -1,9 +1,9 @@
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { Dropdown, Menu } from 'antd'; import { Dropdown, Menu, Modal } from 'antd';
import React, { createContext, useState } from 'react'; import React, { createContext, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import { useAPIClient, useCurrentUserContext } from '..'; import { useACLRoleContext, useAPIClient, useCurrentUserContext } from '..';
import { useCurrentAppInfo } from '../appInfo/CurrentAppInfoProvider'; import { useCurrentAppInfo } from '../appInfo/CurrentAppInfoProvider';
import { ChangePassword } from './ChangePassword'; import { ChangePassword } from './ChangePassword';
import { EditProfile } from './EditProfile'; import { EditProfile } from './EditProfile';
@ -28,6 +28,30 @@ export const CurrentUser = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const { data } = useCurrentUserContext(); const { data } = useCurrentUserContext();
const { allowAll, snippets } = useACLRoleContext();
const allowReboot = allowAll || snippets?.includes('app');
const silenceApi = useAPIClient();
const check = async () => {
return await new Promise((resolve) => {
const heartbeat = setInterval(() => {
silenceApi
.silent()
.resource('app')
.getInfo()
.then((res) => {
console.log(res);
if (res?.status === 200) {
resolve('ok');
clearInterval(heartbeat);
}
return res;
})
.catch(() => {
// ignore
});
}, 3000);
});
};
return ( return (
<div style={{ display: 'inline-flex', verticalAlign: 'top' }}> <div style={{ display: 'inline-flex', verticalAlign: 'top' }}>
<DropdownVisibleContext.Provider value={{ visible, setVisible }}> <DropdownVisibleContext.Provider value={{ visible, setVisible }}>
@ -42,10 +66,36 @@ export const CurrentUser = () => {
<Menu.Divider /> <Menu.Divider />
<EditProfile /> <EditProfile />
<ChangePassword /> <ChangePassword />
<Menu.Divider />
<SwitchRole /> <SwitchRole />
<LanguageSettings /> <LanguageSettings />
<ThemeSettings /> <ThemeSettings />
<Menu.Divider /> <Menu.Divider />
{allowReboot && (
<Menu.Item
key="reload"
onClick={async () => {
Modal.confirm({
title: t('Reboot Application'),
content: t(
'The will interrupt service, it may take a few seconds to restart. Are you sure to continue?',
),
okText: t('Reboot'),
okButtonProps: {
danger: true,
},
onOk: async () => {
await api.resource('app').reboot();
await check();
window.location.reload();
},
});
}}
>
{t('Reboot Application')}
</Menu.Item>
)}
<Menu.Divider />
<Menu.Item <Menu.Item
key="signout" key="signout"
onClick={async () => { onClick={async () => {

View File

@ -506,6 +506,7 @@
"Allows to configure interface": "允许配置界面", "Allows to configure interface": "允许配置界面",
"Allows to install, activate, disable plugins": "允许安装、激活、禁用插件", "Allows to install, activate, disable plugins": "允许安装、激活、禁用插件",
"Allows to configure plugins": "允许配置插件", "Allows to configure plugins": "允许配置插件",
"Allows to reload application": "允许重新加载应用",
"Action display name": "操作名称", "Action display name": "操作名称",
"Allow": "允许", "Allow": "允许",
"Data scope": "数据范围", "Data scope": "数据范围",
@ -993,11 +994,26 @@
"quarterPlaceholder": "请选择季度", "quarterPlaceholder": "请选择季度",
"monthPlaceholder": "请选择月份", "monthPlaceholder": "请选择月份",
"weekPlaceholder": "请选择周", "weekPlaceholder": "请选择周",
"rangePlaceholder": ["开始日期", "结束日期"], "rangePlaceholder": [
"rangeYearPlaceholder": ["开始年份", "结束年份"], "开始日期",
"rangeMonthPlaceholder": ["开始月份", "结束月份"], "结束日期"
"rangeQuarterPlaceholder": ["开始季度", "结束季度"], ],
"rangeWeekPlaceholder": ["开始周", "结束周"], "rangeYearPlaceholder": [
"开始年份",
"结束年份"
],
"rangeMonthPlaceholder": [
"开始月份",
"结束月份"
],
"rangeQuarterPlaceholder": [
"开始季度",
"结束季度"
],
"rangeWeekPlaceholder": [
"开始周",
"结束周"
],
"locale": "zh_CN", "locale": "zh_CN",
"today": "今天", "today": "今天",
"now": "此刻", "now": "此刻",
@ -1025,9 +1041,21 @@
"previousCentury": "上一世纪", "previousCentury": "上一世纪",
"nextCentury": "下一世纪" "nextCentury": "下一世纪"
}, },
"timePickerLocale": { "placeholder": "请选择时间", "rangePlaceholder": ["开始时间", "结束时间"] } "timePickerLocale": {
"placeholder": "请选择时间",
"rangePlaceholder": [
"开始时间",
"结束时间"
]
}
},
"TimePicker": {
"placeholder": "请选择时间",
"rangePlaceholder": [
"开始时间",
"结束时间"
]
}, },
"TimePicker": { "placeholder": "请选择时间", "rangePlaceholder": ["开始时间", "结束时间"] },
"Calendar": { "Calendar": {
"lang": { "lang": {
"placeholder": "请选择日期", "placeholder": "请选择日期",
@ -1035,11 +1063,26 @@
"quarterPlaceholder": "请选择季度", "quarterPlaceholder": "请选择季度",
"monthPlaceholder": "请选择月份", "monthPlaceholder": "请选择月份",
"weekPlaceholder": "请选择周", "weekPlaceholder": "请选择周",
"rangePlaceholder": ["开始日期", "结束日期"], "rangePlaceholder": [
"rangeYearPlaceholder": ["开始年份", "结束年份"], "开始日期",
"rangeMonthPlaceholder": ["开始月份", "结束月份"], "结束日期"
"rangeQuarterPlaceholder": ["开始季度", "结束季度"], ],
"rangeWeekPlaceholder": ["开始周", "结束周"], "rangeYearPlaceholder": [
"开始年份",
"结束年份"
],
"rangeMonthPlaceholder": [
"开始月份",
"结束月份"
],
"rangeQuarterPlaceholder": [
"开始季度",
"结束季度"
],
"rangeWeekPlaceholder": [
"开始周",
"结束周"
],
"locale": "zh_CN", "locale": "zh_CN",
"today": "今天", "today": "今天",
"now": "此刻", "now": "此刻",
@ -1067,9 +1110,17 @@
"previousCentury": "上一世纪", "previousCentury": "上一世纪",
"nextCentury": "下一世纪" "nextCentury": "下一世纪"
}, },
"timePickerLocale": { "placeholder": "请选择时间", "rangePlaceholder": ["开始时间", "结束时间"] } "timePickerLocale": {
"placeholder": "请选择时间",
"rangePlaceholder": [
"开始时间",
"结束时间"
]
}
},
"global": {
"placeholder": "请选择"
}, },
"global": { "placeholder": "请选择" },
"Table": { "Table": {
"filterTitle": "筛选", "filterTitle": "筛选",
"filterConfirm": "确定", "filterConfirm": "确定",
@ -1088,8 +1139,15 @@
"triggerAsc": "点击升序", "triggerAsc": "点击升序",
"cancelSort": "取消排序" "cancelSort": "取消排序"
}, },
"Modal": { "okText": "确定", "cancelText": "取消", "justOkText": "知道了" }, "Modal": {
"Popconfirm": { "cancelText": "取消", "okText": "确定" }, "okText": "确定",
"cancelText": "取消",
"justOkText": "知道了"
},
"Popconfirm": {
"cancelText": "取消",
"okText": "确定"
},
"Transfer": { "Transfer": {
"searchPlaceholder": "请输入搜索内容", "searchPlaceholder": "请输入搜索内容",
"itemUnit": "项", "itemUnit": "项",
@ -1108,10 +1166,21 @@
"previewFile": "预览文件", "previewFile": "预览文件",
"downloadFile": "下载文件" "downloadFile": "下载文件"
}, },
"Empty": { "description": "暂无数据" }, "Empty": {
"Icon": { "icon": "图标" }, "description": "暂无数据"
"Text": { "edit": "编辑", "copy": "复制", "copied": "复制成功", "expand": "展开" }, },
"PageHeader": { "back": "返回" }, "Icon": {
"icon": "图标"
},
"Text": {
"edit": "编辑",
"copy": "复制",
"copied": "复制成功",
"expand": "展开"
},
"PageHeader": {
"back": "返回"
},
"Form": { "Form": {
"optional": "(可选)", "optional": "(可选)",
"defaultValidateMessages": { "defaultValidateMessages": {
@ -1157,10 +1226,14 @@
"max": "最多${max}个${label}", "max": "最多${max}个${label}",
"range": "${label}数量须在${min}-${max}之间" "range": "${label}数量须在${min}-${max}之间"
}, },
"pattern": { "mismatch": "${label}与模式不匹配${pattern}" } "pattern": {
"mismatch": "${label}与模式不匹配${pattern}"
}
} }
}, },
"Image": { "preview": "预览" } "Image": {
"preview": "预览"
}
}, },
"cronstrue": { "cronstrue": {
"atX0SecondsPastTheMinuteGt20": null, "atX0SecondsPastTheMinuteGt20": null,
@ -1213,7 +1286,15 @@
"commaOnDayX0OfTheMonth": ", 限每月%s", "commaOnDayX0OfTheMonth": ", 限每月%s",
"commaEveryX0Years": ", 每隔 %s 年", "commaEveryX0Years": ", 每隔 %s 年",
"commaStartingX0": ", %s开始", "commaStartingX0": ", %s开始",
"daysOfTheWeek": ["星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"], "daysOfTheWeek": [
"星期日",
"星期一",
"星期二",
"星期三",
"星期四",
"星期五",
"星期六"
],
"monthsOfTheYear": [ "monthsOfTheYear": [
"一月", "一月",
"二月", "二月",
@ -1257,9 +1338,51 @@
"suffixMinutesForHourPeriod": "分钟", "suffixMinutesForHourPeriod": "分钟",
"errorInvalidCron": "不符合 cron 规则的表达式", "errorInvalidCron": "不符合 cron 规则的表达式",
"clearButtonText": "清空", "clearButtonText": "清空",
"weekDays": ["周日", "周一", "周二", "周三", "周四", "周五", "周六"], "weekDays": [
"months": ["一月", "二月", "三月", "四月", "五月", "六月", "七月", "八月", "九月", "十月", "十一月", "十二月"], "周日",
"altWeekDays": ["周日", "周一", "周二", "周三", "周四", "周五", "周六"], "周一",
"altMonths": ["一月", "二月", "三月", "四月", "五月", "六月", "七月", "八月", "九月", "十月", "十一月", "十二月"] "周二",
"周三",
"周四",
"周五",
"周六"
],
"months": [
"一月",
"二月",
"三月",
"四月",
"五月",
"六月",
"七月",
"八月",
"九月",
"十月",
"十一月",
"十二月"
],
"altWeekDays": [
"周日",
"周一",
"周二",
"周三",
"周四",
"周五",
"周六"
],
"altMonths": [
"一月",
"二月",
"三月",
"四月",
"五月",
"六月",
"七月",
"八月",
"九月",
"十月",
"十一月",
"十二月"
]
} }
} }

View File

@ -94,8 +94,18 @@ export class ClientPlugin extends Plugin {
this.app.acl.allow('app', 'getInfo'); this.app.acl.allow('app', 'getInfo');
this.app.acl.allow('app', 'getPlugins'); this.app.acl.allow('app', 'getPlugins');
this.app.acl.allow('plugins', '*', 'public'); this.app.acl.allow('plugins', '*', 'public');
this.app.acl.registerSnippet({
name: 'app',
actions: ['app:reload', 'app:reboot'],
});
const dialect = this.app.db.sequelize.getDialect(); const dialect = this.app.db.sequelize.getDialect();
const locales = require('./locale').default; const locales = require('./locale').default;
const restartMark = resolve(process.cwd(), 'storage', 'restart');
this.app.on('beforeStart', async () => {
if (fs.existsSync(restartMark)) {
fs.unlinkSync(restartMark);
}
});
this.app.resource({ this.app.resource({
name: 'app', name: 'app',
actions: { actions: {
@ -161,6 +171,24 @@ export class ClientPlugin extends Plugin {
.map((item) => item.name); .map((item) => item.name);
await next(); await next();
}, },
async reload(ctx, next) {
await ctx.app.reload();
await next();
},
reboot(ctx) {
const RESTART_CODE = 100;
process.on('exit', (code) => {
if (code === RESTART_CODE && process.env.APP_ENV === 'production') {
fs.writeFileSync(restartMark, '1');
console.log('Restart mark created.');
}
});
ctx.app.on('afterStop', () => {
// Exit with code 100 will restart the process
process.exit(RESTART_CODE);
});
ctx.app.stop();
},
}, },
}); });
this.app.resource({ this.app.resource({

View File

@ -1,7 +1,7 @@
import Database, { Collection, MagicAttributeModel } from '@nocobase/database'; import Database, { Collection, MagicAttributeModel } from '@nocobase/database';
import lodash from 'lodash';
import { SyncOptions, Transactionable } from 'sequelize'; import { SyncOptions, Transactionable } from 'sequelize';
import { FieldModel } from './field'; import { FieldModel } from './field';
import lodash from 'lodash';
interface LoadOptions extends Transactionable { interface LoadOptions extends Transactionable {
// TODO // TODO
@ -50,8 +50,12 @@ export class CollectionModel extends MagicAttributeModel {
} }
async loadFields(options: Transactionable = {}) { async loadFields(options: Transactionable = {}) {
let fields = this.get('fields') || [];
if (!fields.length) {
fields = await this.getFields(options);
}
// @ts-ignore // @ts-ignore
const instances: FieldModel[] = await this.getFields(options); const instances: FieldModel[] = fields;
for (const instance of instances) { for (const instance of instances) {
await instance.load(options); await instance.load(options);

View File

@ -1,6 +1,6 @@
import { Repository } from '@nocobase/database'; import { Repository } from '@nocobase/database';
import { CollectionModel } from '../models/collection';
import { CollectionsGraph } from '@nocobase/utils'; import { CollectionsGraph } from '@nocobase/utils';
import { CollectionModel } from '../models/collection';
interface LoadOptions { interface LoadOptions {
filter?: any; filter?: any;
@ -10,7 +10,7 @@ interface LoadOptions {
export class CollectionRepository extends Repository { export class CollectionRepository extends Repository {
async load(options: LoadOptions = {}) { async load(options: LoadOptions = {}) {
const { filter, skipExist } = options; const { filter, skipExist } = options;
const instances = (await this.find({ filter })) as CollectionModel[]; const instances = (await this.find({ filter, appends: ['fields'] })) as CollectionModel[];
const graphlib = CollectionsGraph.graphlib(); const graphlib = CollectionsGraph.graphlib();
@ -33,7 +33,7 @@ export class CollectionRepository extends Repository {
nameMap[collectionName] = instance; nameMap[collectionName] = instance;
// @ts-ignore // @ts-ignore
const fields = await instance.getFields(); const fields = instance.get('fields') || [];
for (const field of fields) { for (const field of fields) {
if (field['type'] === 'belongsToMany') { if (field['type'] === 'belongsToMany') {
const throughName = field.options.through; const throughName = field.options.through;

View File

@ -216,6 +216,9 @@ export class CollectionManagerPlugin extends Plugin {
if (options?.method === 'install') { if (options?.method === 'install') {
return; return;
} }
if (options?.method === 'upgrade') {
return;
}
const exists = await this.app.db.collectionExistsInDb('collections'); const exists = await this.app.db.collectionExistsInDb('collections');
if (exists) { if (exists) {
try { try {

View File

@ -102,6 +102,7 @@ export class PresetNocoBase extends Plugin {
); );
await this.app.reload({ method: 'upgrade' }); await this.app.reload({ method: 'upgrade' });
await this.app.db.sync(); await this.app.db.sync();
await this.app.db.getRepository<any>('collections').load();
}); });
this.app.on('beforeInstall', async () => { this.app.on('beforeInstall', async () => {

3188
yarn.lock

File diff suppressed because it is too large Load Diff