From f8e8eac9ecb5b5ce4ff5b152e48b6c4dfbf5f57d Mon Sep 17 00:00:00 2001 From: PauI Ostrovckij Date: Thu, 22 Jan 2026 12:53:04 +0300 Subject: [PATCH] [fix] Add shutdown requests proxing; Refactor settings --- AdminPanel/client/src/api/index.js | 6 +- .../client/src/components/Button/Button.js | 2 + .../components/ConfigViewer/ConfigViewer.js | 17 +- .../ConfigViewer/ConfigViewer.module.scss | 30 ---- AdminPanel/client/src/components/Note/Note.js | 18 +- .../src/components/Note/Note.module.scss | 9 + .../src/components/ShutdownTab/ShutdownTab.js | 44 +++-- .../ShutdownTab/ShutdownTab.module.scss | 31 +--- .../sources/routes/docservice/router.js | 169 ++++++++++++++++++ AdminPanel/server/sources/server.js | 2 + 10 files changed, 235 insertions(+), 93 deletions(-) create mode 100644 AdminPanel/server/sources/routes/docservice/router.js diff --git a/AdminPanel/client/src/api/index.js b/AdminPanel/client/src/api/index.js index 9403d5e2..8f0a6a67 100644 --- a/AdminPanel/client/src/api/index.js +++ b/AdminPanel/client/src/api/index.js @@ -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' }); diff --git a/AdminPanel/client/src/components/Button/Button.js b/AdminPanel/client/src/components/Button/Button.js index a3ad0dca..48d9951c 100644 --- a/AdminPanel/client/src/components/Button/Button.js +++ b/AdminPanel/client/src/components/Button/Button.js @@ -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; }; diff --git a/AdminPanel/client/src/components/ConfigViewer/ConfigViewer.js b/AdminPanel/client/src/components/ConfigViewer/ConfigViewer.js index 7b7bbc12..770c596a 100644 --- a/AdminPanel/client/src/components/ConfigViewer/ConfigViewer.js +++ b/AdminPanel/client/src/components/ConfigViewer/ConfigViewer.js @@ -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 = () => {

Sensitive parameters (passwords, keys, secrets) are shown as REDACTED.

- +
{jsonString}
diff --git a/AdminPanel/client/src/components/ConfigViewer/ConfigViewer.module.scss b/AdminPanel/client/src/components/ConfigViewer/ConfigViewer.module.scss index 05ff2a1b..c123e261 100644 --- a/AdminPanel/client/src/components/ConfigViewer/ConfigViewer.module.scss +++ b/AdminPanel/client/src/components/ConfigViewer/ConfigViewer.module.scss @@ -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; diff --git a/AdminPanel/client/src/components/Note/Note.js b/AdminPanel/client/src/components/Note/Note.js index 99ce4358..f806eabe 100644 --- a/AdminPanel/client/src/components/Note/Note.js +++ b/AdminPanel/client/src/components/Note/Note.js @@ -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}) { /> ) + }, + success: { + title: 'Success', + className: styles.success, + icon: ( + + + + + ) } }; const config = typeConfig[type] || typeConfig.note; + const displayTitle = title || config.title; return (
{config.icon} - {config.title} + {displayTitle}
{children}
diff --git a/AdminPanel/client/src/components/Note/Note.module.scss b/AdminPanel/client/src/components/Note/Note.module.scss index 57bfc0eb..7a1370bc 100644 --- a/AdminPanel/client/src/components/Note/Note.module.scss +++ b/AdminPanel/client/src/components/Note/Note.module.scss @@ -99,3 +99,12 @@ color: #262ba5; } } + +/* Success variant - Green */ +.success { + border-left: 2px solid #007b14; + + .title { + color: #007b14; + } +} diff --git a/AdminPanel/client/src/components/ShutdownTab/ShutdownTab.js b/AdminPanel/client/src/components/ShutdownTab/ShutdownTab.js index bd0ba0c3..aa646828 100644 --- a/AdminPanel/client/src/components/ShutdownTab/ShutdownTab.js +++ b/AdminPanel/client/src/components/ShutdownTab/ShutdownTab.js @@ -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 ( +
+ + Unable to connect to DocService. {error?.message || 'Please check if the service is running.'} + +
+ ); + } + return (
-
-
Current Status:
-
- {maintenanceStatus.shutdown ? ( - ⚠️ Shutdown Mode Active - New connections blocked - ) : ( - ✓ Normal Operation - Accepting new connections - )} -
+
+ {maintenanceStatus?.shutdown ? ( + + New connections are blocked and existing sessions are being closed. + + ) : ( + + Server is accepting new connections. + + )}
-

@@ -79,7 +95,7 @@ const ShutdownTab = () => {

-

Returns server to normal mode and allows new editor connections.

diff --git a/AdminPanel/client/src/components/ShutdownTab/ShutdownTab.module.scss b/AdminPanel/client/src/components/ShutdownTab/ShutdownTab.module.scss index ede11b26..6dbefa0f 100644 --- a/AdminPanel/client/src/components/ShutdownTab/ShutdownTab.module.scss +++ b/AdminPanel/client/src/components/ShutdownTab/ShutdownTab.module.scss @@ -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; diff --git a/AdminPanel/server/sources/routes/docservice/router.js b/AdminPanel/server/sources/routes/docservice/router.js new file mode 100644 index 00000000..9b50655c --- /dev/null +++ b/AdminPanel/server/sources/routes/docservice/router.js @@ -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; diff --git a/AdminPanel/server/sources/server.js b/AdminPanel/server/sources/server.js index ed19564f..3a07542e 100644 --- a/AdminPanel/server/sources/server.js +++ b/AdminPanel/server/sources/server.js @@ -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);