mirror of
https://github.com/ONLYOFFICE/server.git
synced 2026-02-10 18:05:07 +08:00
[fix] Add shutdown requests proxing; Refactor settings
This commit is contained in:
@ -194,7 +194,7 @@ export const checkHealth = async () => {
|
||||
};
|
||||
|
||||
export const getMaintenanceStatus = async () => {
|
||||
const response = await safeFetch(`${DOCSERVICE_URL}/internal/cluster/inactive`, {
|
||||
const response = await safeFetch(`${API_BASE_PATH}/docservice/shutdown`, {
|
||||
method: 'GET',
|
||||
credentials: 'include'
|
||||
});
|
||||
@ -205,7 +205,7 @@ export const getMaintenanceStatus = async () => {
|
||||
};
|
||||
|
||||
export const enterMaintenanceMode = async () => {
|
||||
const response = await safeFetch(`${DOCSERVICE_URL}/internal/cluster/inactive`, {
|
||||
const response = await safeFetch(`${API_BASE_PATH}/docservice/shutdown`, {
|
||||
method: 'PUT',
|
||||
credentials: 'include'
|
||||
});
|
||||
@ -216,7 +216,7 @@ export const enterMaintenanceMode = async () => {
|
||||
};
|
||||
|
||||
export const exitMaintenanceMode = async () => {
|
||||
const response = await safeFetch(`${DOCSERVICE_URL}/internal/cluster/inactive`, {
|
||||
const response = await safeFetch(`${API_BASE_PATH}/docservice/shutdown`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
@ -39,10 +39,12 @@ const Button = forwardRef(({onClick, children = 'Save Changes', disabled = false
|
||||
|
||||
const getButtonClass = () => {
|
||||
let buttonClass = styles.button;
|
||||
|
||||
if (disabled && state === 'idle') buttonClass += ` ${styles['button--disabled']}`;
|
||||
if (state === 'loading') buttonClass += ` ${styles['button--loading']}`;
|
||||
if (state === 'success') buttonClass += ` ${styles['button--success']}`;
|
||||
if (state === 'error') buttonClass += ` ${styles['button--error']}`;
|
||||
|
||||
if (className) buttonClass += ` ${className}`;
|
||||
return buttonClass;
|
||||
};
|
||||
|
||||
@ -1,11 +1,10 @@
|
||||
import {useMemo, useState} from 'react';
|
||||
import {useMemo} from 'react';
|
||||
import {useQuery} from '@tanstack/react-query';
|
||||
import {fetchConfiguration} from '../../api';
|
||||
import Button from '../Button/Button';
|
||||
import styles from './ConfigViewer.module.scss';
|
||||
|
||||
const ConfigViewer = () => {
|
||||
const [copySuccess, setCopySuccess] = useState(false);
|
||||
|
||||
const {
|
||||
data: config,
|
||||
isLoading,
|
||||
@ -23,13 +22,7 @@ const ConfigViewer = () => {
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
if (!jsonString) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(jsonString);
|
||||
setCopySuccess(true);
|
||||
setTimeout(() => setCopySuccess(false), 2000);
|
||||
} catch {
|
||||
// Clipboard API may fail on HTTP or restricted contexts
|
||||
}
|
||||
await navigator.clipboard.writeText(jsonString);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
@ -50,9 +43,7 @@ const ConfigViewer = () => {
|
||||
<p className={styles.description}>
|
||||
Sensitive parameters (passwords, keys, secrets) are shown as <span className={styles.redactedBadge}>REDACTED</span>.
|
||||
</p>
|
||||
<button className={styles.copyButton} onClick={copyToClipboard}>
|
||||
{copySuccess ? '✓ Copied!' : 'Copy JSON'}
|
||||
</button>
|
||||
<Button onClick={copyToClipboard}>Copy JSON</Button>
|
||||
</div>
|
||||
<div className={styles.configContent}>
|
||||
<pre className={styles.jsonPre}>{jsonString}</pre>
|
||||
|
||||
@ -32,32 +32,6 @@
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.copyButton {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: background 0.2s;
|
||||
white-space: nowrap;
|
||||
min-width: 110px;
|
||||
height: 36px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: #004085;
|
||||
}
|
||||
}
|
||||
|
||||
.configContent {
|
||||
padding: 0;
|
||||
background: #fafafa;
|
||||
@ -100,10 +74,6 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.copyButton {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.jsonPre {
|
||||
padding: 16px;
|
||||
font-size: 12px;
|
||||
|
||||
@ -3,11 +3,12 @@ import styles from './Note.module.scss';
|
||||
/**
|
||||
* Note component for displaying different types of messages
|
||||
* @param {Object} props - Component properties
|
||||
* @param {('note'|'warning'|'tip'|'important')} props.type - Type of note to display
|
||||
* @param {('note'|'warning'|'tip'|'important'|'success')} props.type - Type of note to display
|
||||
* @param {string} [props.title] - Optional custom title to override default type title
|
||||
* @param {React.ReactNode} props.children - Content to display in the note
|
||||
* @returns {JSX.Element} Note component
|
||||
*/
|
||||
function Note({type = 'note', children}) {
|
||||
function Note({type = 'note', title, children}) {
|
||||
const typeConfig = {
|
||||
note: {
|
||||
title: 'Note',
|
||||
@ -61,16 +62,27 @@ function Note({type = 'note', children}) {
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
},
|
||||
success: {
|
||||
title: 'Success',
|
||||
className: styles.success,
|
||||
icon: (
|
||||
<svg className={styles.icon} width='16' height='16' viewBox='0 0 16 16' fill='none'>
|
||||
<circle cx='8' cy='8' r='7.25' stroke='#007B14' strokeWidth='1.5' />
|
||||
<path d='M4.66667 8L6.66667 10L11.3333 5.33333' stroke='#007B14' strokeWidth='1.5' strokeLinecap='round' strokeLinejoin='round' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
const config = typeConfig[type] || typeConfig.note;
|
||||
const displayTitle = title || config.title;
|
||||
|
||||
return (
|
||||
<div className={`${styles.noteContainer} ${config.className}`}>
|
||||
<div className={styles.header}>
|
||||
{config.icon}
|
||||
<span className={styles.title}>{config.title}</span>
|
||||
<span className={styles.title}>{displayTitle}</span>
|
||||
</div>
|
||||
<div className={styles.content}>{children}</div>
|
||||
</div>
|
||||
|
||||
@ -99,3 +99,12 @@
|
||||
color: #262ba5;
|
||||
}
|
||||
}
|
||||
|
||||
/* Success variant - Green */
|
||||
.success {
|
||||
border-left: 2px solid #007b14;
|
||||
|
||||
.title {
|
||||
color: #007b14;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query';
|
||||
import Button from '../Button/Button';
|
||||
import Section from '../Section/Section';
|
||||
import Note from '../Note/Note';
|
||||
import {enterMaintenanceMode, exitMaintenanceMode, getMaintenanceStatus} from '../../api';
|
||||
import styles from './ShutdownTab.module.scss';
|
||||
|
||||
@ -11,11 +12,15 @@ const SHUTDOWN_DESCRIPTION =
|
||||
const ShutdownTab = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const {data: maintenanceStatus = {shutdown: false}, isLoading: statusLoading} = useQuery({
|
||||
const {
|
||||
data: maintenanceStatus,
|
||||
isLoading: statusLoading,
|
||||
isError: statusError,
|
||||
error
|
||||
} = useQuery({
|
||||
queryKey: ['maintenanceStatus'],
|
||||
queryFn: getMaintenanceStatus,
|
||||
retry: false,
|
||||
onError: () => {}
|
||||
retry: false
|
||||
});
|
||||
|
||||
const shutdownMutation = useMutation({
|
||||
@ -56,22 +61,33 @@ const ShutdownTab = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (statusError) {
|
||||
return (
|
||||
<Section title={SHUTDOWN_TITLE} description={SHUTDOWN_DESCRIPTION}>
|
||||
<Note type='warning' title='Failed to Load Status'>
|
||||
Unable to connect to DocService. {error?.message || 'Please check if the service is running.'}
|
||||
</Note>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title={SHUTDOWN_TITLE} description={SHUTDOWN_DESCRIPTION}>
|
||||
<div className={styles.container}>
|
||||
<div className={`${styles.statusBadge} ${maintenanceStatus.shutdown ? styles.statusBadgeWarning : styles.statusBadgeSuccess}`}>
|
||||
<div className={styles.statusTitle}>Current Status:</div>
|
||||
<div className={styles.statusContent}>
|
||||
{maintenanceStatus.shutdown ? (
|
||||
<span className={styles.statusTextWarning}>⚠️ Shutdown Mode Active - New connections blocked</span>
|
||||
) : (
|
||||
<span className={styles.statusTextSuccess}>✓ Normal Operation - Accepting new connections</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.statusNote}>
|
||||
{maintenanceStatus?.shutdown ? (
|
||||
<Note type='warning' title='Shutdown Mode Active'>
|
||||
New connections are blocked and existing sessions are being closed.
|
||||
</Note>
|
||||
) : (
|
||||
<Note type='success' title='Normal Operation'>
|
||||
Server is accepting new connections.
|
||||
</Note>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button onClick={handleShutdown} disabled={maintenanceStatus.shutdown || shutdownMutation.isPending} disableResult={true}>
|
||||
<Button onClick={handleShutdown} disabled={maintenanceStatus?.shutdown || shutdownMutation.isPending}>
|
||||
Shutdown
|
||||
</Button>
|
||||
<p className={styles.buttonDescription}>
|
||||
@ -79,7 +95,7 @@ const ShutdownTab = () => {
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Button onClick={handleResume} disabled={!maintenanceStatus.shutdown || resumeMutation.isPending}>
|
||||
<Button onClick={handleResume} disabled={!maintenanceStatus?.shutdown || resumeMutation.isPending}>
|
||||
Resume
|
||||
</Button>
|
||||
<p className={styles.buttonDescription}>Returns server to normal mode and allows new editor connections.</p>
|
||||
|
||||
@ -10,39 +10,10 @@
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.statusBadge {
|
||||
padding: 12px 16px;
|
||||
border-radius: 4px;
|
||||
.statusNote {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.statusBadgeSuccess {
|
||||
background-color: #d4edda;
|
||||
border: 1px solid #28a745;
|
||||
}
|
||||
|
||||
.statusBadgeWarning {
|
||||
background-color: #fff3cd;
|
||||
border: 1px solid #ffc107;
|
||||
}
|
||||
|
||||
.statusTitle {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.statusContent {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.statusTextSuccess {
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.statusTextWarning {
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.buttonDescription {
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
|
||||
169
AdminPanel/server/sources/routes/docservice/router.js
Normal file
169
AdminPanel/server/sources/routes/docservice/router.js
Normal file
@ -0,0 +1,169 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const http = require('http');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const operationContext = require('../../../../../Common/sources/operationContext');
|
||||
const adminPanelJwtSecret = require('../../jwtSecret');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(express.json());
|
||||
router.use(cookieParser());
|
||||
|
||||
/**
|
||||
* Middleware to verify JWT token
|
||||
*/
|
||||
function requireAuth(req, res, next) {
|
||||
try {
|
||||
const token = req.cookies?.accessToken;
|
||||
if (!token) {
|
||||
return res.status(401).json({error: 'Unauthorized'});
|
||||
}
|
||||
const decoded = jwt.verify(token, adminPanelJwtSecret);
|
||||
req.user = decoded;
|
||||
next();
|
||||
} catch {
|
||||
res.status(401).json({error: 'Unauthorized'});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get DocService connection config
|
||||
*/
|
||||
function getDocServiceConfig(ctx) {
|
||||
const host = 'localhost';
|
||||
const port = parseInt(ctx.getCfg('services.CoAuthoring.server.port', 8000), 10);
|
||||
return {host, port};
|
||||
}
|
||||
|
||||
/**
|
||||
* Make HTTP request to DocService
|
||||
*/
|
||||
function makeDocServiceRequest(options, ctx) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request(options, res => {
|
||||
let data = '';
|
||||
|
||||
res.on('data', chunk => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const jsonData = data ? JSON.parse(data) : {};
|
||||
resolve({
|
||||
statusCode: res.statusCode,
|
||||
headers: res.headers,
|
||||
data: jsonData
|
||||
});
|
||||
} catch (err) {
|
||||
ctx.logger.error('Error parsing DocService response: %s', err.stack);
|
||||
reject(new Error('Invalid response from DocService'));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', err => {
|
||||
reject(err);
|
||||
});
|
||||
|
||||
req.setTimeout(120000, () => {
|
||||
req.destroy();
|
||||
reject(new Error('Request timeout'));
|
||||
});
|
||||
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /shutdown - Get shutdown status
|
||||
* Proxies to DocService GET /internal/cluster/inactive
|
||||
*/
|
||||
router.get('/shutdown', requireAuth, async (req, res) => {
|
||||
const ctx = new operationContext.Context();
|
||||
ctx.initFromRequest(req);
|
||||
const {host, port} = getDocServiceConfig(ctx);
|
||||
|
||||
try {
|
||||
const options = {
|
||||
hostname: host,
|
||||
port,
|
||||
path: '/internal/cluster/inactive',
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-Forwarded-For': req.ip
|
||||
}
|
||||
};
|
||||
|
||||
const response = await makeDocServiceRequest(options, ctx);
|
||||
res.status(response.statusCode).json(response.data);
|
||||
} catch (error) {
|
||||
ctx.logger.error('Error getting shutdown status: %s', error.stack);
|
||||
res.status(500).json({error: 'Failed to get shutdown status'});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /shutdown - Enter shutdown mode
|
||||
* Proxies to DocService PUT /internal/cluster/inactive
|
||||
*/
|
||||
router.put('/shutdown', requireAuth, async (req, res) => {
|
||||
const ctx = new operationContext.Context();
|
||||
ctx.initFromRequest(req);
|
||||
const {host, port} = getDocServiceConfig(ctx);
|
||||
|
||||
try {
|
||||
ctx.logger.info('Entering shutdown mode via AdminPanel');
|
||||
|
||||
const options = {
|
||||
hostname: host,
|
||||
port,
|
||||
path: '/internal/cluster/inactive',
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'X-Forwarded-For': req.ip
|
||||
}
|
||||
};
|
||||
|
||||
const response = await makeDocServiceRequest(options, ctx);
|
||||
res.status(response.statusCode).json(response.data);
|
||||
} catch (error) {
|
||||
ctx.logger.error('Error entering shutdown mode: %s', error.stack);
|
||||
res.status(500).json({error: 'Failed to enter shutdown mode'});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /shutdown - Exit shutdown mode
|
||||
* Proxies to DocService DELETE /internal/cluster/inactive
|
||||
*/
|
||||
router.delete('/shutdown', requireAuth, async (req, res) => {
|
||||
const ctx = new operationContext.Context();
|
||||
ctx.initFromRequest(req);
|
||||
const {host, port} = getDocServiceConfig(ctx);
|
||||
|
||||
try {
|
||||
ctx.logger.info('Exiting shutdown mode via AdminPanel');
|
||||
|
||||
const options = {
|
||||
hostname: host,
|
||||
port,
|
||||
path: '/internal/cluster/inactive',
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-Forwarded-For': req.ip
|
||||
}
|
||||
};
|
||||
|
||||
const response = await makeDocServiceRequest(options, ctx);
|
||||
res.status(response.statusCode).json(response.data);
|
||||
} catch (error) {
|
||||
ctx.logger.error('Error exiting shutdown mode: %s', error.stack);
|
||||
res.status(500).json({error: 'Failed to exit shutdown mode'});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@ -49,6 +49,7 @@ const infoRouter = require('../../../DocService/sources/routes/info');
|
||||
const configRouter = require('./routes/config/router');
|
||||
const adminpanelRouter = require('./routes/adminpanel/router');
|
||||
const wopiRouter = require('./routes/wopi/router');
|
||||
const docserviceRouter = require('./routes/docservice/router');
|
||||
const passwordManager = require('./passwordManager');
|
||||
const bootstrap = require('./bootstrap');
|
||||
const devProxy = require('./devProxy');
|
||||
@ -131,6 +132,7 @@ function disableCache(req, res, next) {
|
||||
// API routes under /admin prefix
|
||||
app.use('/admin/api/v1/config', disableCache, configRouter);
|
||||
app.use('/admin/api/v1/wopi', disableCache, wopiRouter);
|
||||
app.use('/admin/api/v1/docservice', disableCache, docserviceRouter);
|
||||
app.use('/admin/api/v1', disableCache, adminpanelRouter);
|
||||
app.get('/admin/api/v1/stat', disableCache, async (req, res) => {
|
||||
await infoRouter.licenseInfo(req, res);
|
||||
|
||||
Reference in New Issue
Block a user