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