[fix] Add shutdown requests proxing; Refactor settings

This commit is contained in:
PauI Ostrovckij
2026-01-22 12:53:04 +03:00
parent e9a5708681
commit f8e8eac9ec
10 changed files with 235 additions and 93 deletions

View File

@ -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'
});

View File

@ -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;
};

View File

@ -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
}
};
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>

View File

@ -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;

View File

@ -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>

View File

@ -99,3 +99,12 @@
color: #262ba5;
}
}
/* Success variant - Green */
.success {
border-left: 2px solid #007b14;
.title {
color: #007b14;
}
}

View File

@ -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>
<div className={styles.statusNote}>
{maintenanceStatus?.shutdown ? (
<Note type='warning' title='Shutdown Mode Active'>
New connections are blocked and existing sessions are being closed.
</Note>
) : (
<span className={styles.statusTextSuccess}> Normal Operation - Accepting new connections</span>
<Note type='success' title='Normal Operation'>
Server is accepting new connections.
</Note>
)}
</div>
</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>

View File

@ -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;

View 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;

View File

@ -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);