Merge branch 'release/v9.1.0' into feature/admin-panel2

This commit is contained in:
PauI Ostrovckij
2025-09-12 16:18:49 +03:00
parent 51e5bf389b
commit be778e15c8
41 changed files with 2932 additions and 5021 deletions

View File

@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="/images/favicon.ico" />
<link rel="icon" href="images/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Document Server Admin Panel" />

View File

@ -1,7 +1,7 @@
const BACKEND_URL = process.env.REACT_APP_BACKEND_URL ?? '';
export const fetchStatistics = async () => {
const response = await fetch(`${BACKEND_URL}/info/info.json`);
const response = await fetch(`${BACKEND_URL}/api/v1/admin/stat`);
if (!response.ok) {
throw new Error('Failed to fetch statistics');
}
@ -9,7 +9,7 @@ export const fetchStatistics = async () => {
};
export const fetchConfiguration = async () => {
const response = await fetch(`${BACKEND_URL}/info/config`, {
const response = await fetch(`${BACKEND_URL}/api/v1/admin/config`, {
credentials: 'include'
});
if (!response.ok) {
@ -19,7 +19,7 @@ export const fetchConfiguration = async () => {
};
export const fetchConfigurationSchema = async () => {
const response = await fetch(`${BACKEND_URL}/info/config/schema`, {
const response = await fetch(`${BACKEND_URL}/api/v1/admin/config/schema`, {
credentials: 'include'
});
if (!response.ok) {
@ -29,7 +29,7 @@ export const fetchConfigurationSchema = async () => {
};
export const updateConfiguration = async configData => {
const response = await fetch(`${BACKEND_URL}/info/config`, {
const response = await fetch(`${BACKEND_URL}/api/v1/admin/config`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
@ -59,7 +59,7 @@ export const updateConfiguration = async configData => {
};
export const fetchCurrentUser = async () => {
const response = await fetch(`${BACKEND_URL}/info/adminpanel/me`, {
const response = await fetch(`${BACKEND_URL}/api/v1/admin/me`, {
method: 'GET',
credentials: 'include' // Include cookies in the request
});
@ -75,7 +75,7 @@ export const fetchCurrentUser = async () => {
};
export const login = async ({tenantName, secret}) => {
const response = await fetch(`${BACKEND_URL}/info/adminpanel/login`, {
const response = await fetch(`${BACKEND_URL}/api/v1/admin/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
@ -95,7 +95,7 @@ export const login = async ({tenantName, secret}) => {
};
export const logout = async () => {
const response = await fetch(`${BACKEND_URL}/info/adminpanel/logout`, {
const response = await fetch(`${BACKEND_URL}/api/v1/admin/logout`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'

View File

@ -1,43 +0,0 @@
import styles from './StatisticsInfoTable.module.scss';
function StatisticsInfoTable({caption, editor, viewer, desc}) {
return (
<div className={styles.container}>
{caption && <div className={styles.sectionHeader}>{caption}</div>}
<div className={styles.sectionLabel}>EDITORS</div>
<div className={styles.divider}></div>
<div className={styles.row}>
{editor.map((v, i) => (
<div key={i} className={`${styles.valueCell} ${desc[i] === 'Remaining' ? styles.remainingValue : ''}`}>
{v[0]}
</div>
))}
</div>
<div className={styles.row}>
{desc.map((d, i) => (
<div key={i} className={styles.labelCell}>
{d}
</div>
))}
</div>
<div className={styles.sectionLabel}>LIVE VIEWER</div>
<div className={styles.divider}></div>
<div className={styles.row}>
{viewer.map((v, i) => (
<div key={i} className={`${styles.valueCell} ${desc[i] === 'Remaining' ? styles.remainingValue : ''}`}>
{v[0]}
</div>
))}
</div>
<div className={styles.row}>
{desc.map((d, i) => (
<div key={i} className={styles.labelCell}>
{d}
</div>
))}
</div>
</div>
);
}
export default StatisticsInfoTable;

View File

@ -1,49 +0,0 @@
.container {
margin-bottom: 25px;
}
.sectionHeader {
background: #f5f5f5;
font-weight: 400;
padding: 6px 8px;
margin-bottom: 8px;
}
.row {
display: flex;
justify-content: start;
}
.valueCell {
font-size: 22px;
padding: 8px 0;
width: 25%;
padding-left: 10px;
}
.labelCell {
font-size: 13px;
color: #555;
padding-bottom: 8px;
width: 25%;
padding-left: 10px;
}
.sectionLabel {
font-weight: 600;
font-size: 12px;
padding: 8px 0 0 10px;
&:not(:first-child) {
margin-top: 15px;
}
}
.divider {
border-bottom: 1px solid #ddd;
margin: 2px 10px;
}
.remainingValue {
color: rgb(1, 125, 28);
}

View File

@ -4,7 +4,7 @@ import SecuritySettings from '../pages/SecuritySettings/SecuritySettings';
import EmailConfig from '../pages/EmailConfig/EmailConfig';
import FileLimits from '../pages/FileLimits/FileLimits';
import RequestFiltering from '../pages/RequestFiltering/RequestFiltering';
import Statistics from '../pages/Statistics/Statistics';
import Statistics from '../pages/Statistics';
// Generic component factory function for pages not yet implemented
const createMockComponent = label => () => <div>{label} Component</div>;

View File

@ -0,0 +1,64 @@
import styles from './styles.module.css';
/**
* Renders a two-section info table for Editor and Live Viewer values.
* Values can optionally include a status class in v[1] like 'critical' or 'normal'.
* Sections can be toggled via the `mode` prop: 'all' | 'edit' | 'view'.
*
* @param {{
* caption?: string,
* editor: Array<[number|string, ("critical"|"normal")?]>,
* viewer: Array<[number|string, ("critical"|"normal")?]>,
* desc: string[],
* mode?: 'all' | 'edit' | 'view'
* }} props
*/
export default function InfoTable({caption, editor, viewer, desc, mode = 'all'}) {
return (
<div className={styles.container}>
{caption && <div className={styles.sectionHeader}>{caption}</div>}
{mode !== 'view' && (
<>
<div className={styles.editorsLabel}>EDITORS</div>
<div className={styles.divider}></div>
<div className={styles.row}>
{[0, 1, 2, 3].map(i => (
<div key={i} className={`${styles.valueCell} ${editor[i] && editor[i][1] ? styles[editor[i][1]] : ''}`}>
{editor[i] && editor[i][0] !== undefined ? editor[i][0] : ''}
</div>
))}
</div>
<div className={styles.row}>
{[0, 1, 2, 3].map(i => (
<div key={i} className={styles.labelCell}>
{desc[i] || ''}
</div>
))}
</div>
</>
)}
{mode !== 'edit' && (
<>
<div className={styles.viewerLabel}>LIVE VIEWER</div>
<div className={styles.divider}></div>
<div className={styles.row}>
{[0, 1, 2, 3].map(i => (
<div key={i} className={`${styles.valueCell} ${viewer[i] && viewer[i][1] ? styles[viewer[i][1]] : ''}`}>
{viewer[i] && viewer[i][0] !== undefined ? viewer[i][0] : ''}
</div>
))}
</div>
<div className={styles.row}>
{[0, 1, 2, 3].map(i => (
<div key={i} className={styles.labelCell}>
{desc[i] || ''}
</div>
))}
</div>
</>
)}
</div>
);
}

View File

@ -0,0 +1,57 @@
.container {
margin-bottom: 25px;
}
.sectionHeader {
background: #efefef;
font-size: 16px;
font-weight: 400;
padding: 6px 8px;
margin-bottom: 8px;
border-radius: 1px;
}
.row {
display: flex;
justify-content: start;
}
.valueCell {
font-size: 26px;
line-height: 28px;
padding: 8px 0;
width: 25%;
padding-left: 10px;
}
.labelCell {
font-size: 12px;
color: #555;
padding-bottom: 8px;
width: 25%;
padding-left: 10px;
}
.editorsLabel,
.viewerLabel {
font-weight: 600;
font-size: 12px;
padding: 8px 0 0 10px;
}
.viewerLabel {
margin-top: 15px;
}
.divider {
border-bottom: 1px solid #dadada;
margin: 2px 10px;
}
.remainingValue {
color: rgb(1, 125, 28);
}
/* match branding coloring */
.critical {
color: #ff0000;
}
.normal {
color: #017d1c;
}

View File

@ -0,0 +1,28 @@
import styles from './styles.module.css';
/**
* Mode switcher component for statistics view.
* Persists selected mode to localStorage via parent.
*
* @param {{
* mode: 'all'|'edit'|'view',
* setMode: (mode: 'all'|'edit'|'view') => void
* }} props
*/
export default function ModeSwitcher({mode, setMode}) {
return (
<div className={styles.modeBar}>
<span className={`${styles.modeLink} ${mode === 'all' ? styles.current : ''}`} onClick={() => setMode('all')}>
All
</span>
<span className={styles.modeSeparator}>|</span>
<span className={`${styles.modeLink} ${mode === 'edit' ? styles.current : ''}`} onClick={() => setMode('edit')}>
Editors
</span>
<span className={styles.modeSeparator}>|</span>
<span className={`${styles.modeLink} ${mode === 'view' ? styles.current : ''}`} onClick={() => setMode('view')}>
Live Viewer
</span>
</div>
);
}

View File

@ -0,0 +1,86 @@
import {memo, useMemo} from 'react';
import InfoTable from './InfoTable/index';
const MILLISECONDS_PER_DAY = 86400000;
/**
* Count internal/external users.
* @param {Record<string, { anonym?: boolean }>} users
* @returns {{internal: number, external: number}}
*/
function countUsers(users = {}) {
let internal = 0;
let external = 0;
for (const uid in users) {
if (Object.prototype.hasOwnProperty.call(users, uid)) {
users[uid]?.anonym ? external++ : internal++;
}
}
return {internal, external};
}
/**
* MonthlyStatistics - renders usage statistics by month.
* Mirrors logic from branding/info/index.html fillStatistic().
*
* @param {{ byMonth?: Array<any>, mode: 'all'|'edit'|'view' }} props
*/
function MonthlyStatistics({byMonth, mode}) {
const periods = useMemo(() => {
if (!Array.isArray(byMonth) || byMonth.length < 1) return [];
// Build periods in chronological order, then reverse for display.
const mapped = byMonth
.map((item, index) => {
const date = item?.date ? new Date(item.date) : null;
if (!date) return null;
const editCounts = countUsers(item?.users);
const viewCounts = countUsers(item?.usersView);
const nextDate = index + 1 < byMonth.length ? new Date(byMonth[index + 1].date) : null;
return {
startDate: date,
endDate: nextDate ? new Date(nextDate.getTime() - MILLISECONDS_PER_DAY) : null,
internalEdit: editCounts.internal,
externalEdit: editCounts.external,
internalView: viewCounts.internal,
externalView: viewCounts.external
};
})
.filter(Boolean)
.reverse();
return mapped;
}, [byMonth]);
if (periods.length < 1) return null;
return (
<>
<div style={{textAlign: 'center', fontWeight: 600, margin: '16px 0'}}>Usage statistics for the reporting period</div>
{periods.map((p, idx) => {
const caption = p.endDate
? `${p.startDate.toLocaleDateString()} - ${p.endDate.toLocaleDateString()}`
: `From ${p.startDate.toLocaleDateString()}`;
const editor = [
[p.internalEdit, ''],
[p.externalEdit, ''],
[p.internalEdit + p.externalEdit, '']
];
const viewer = [
[p.internalView, ''],
[p.externalView, ''],
[p.internalView + p.externalView, '']
];
const desc = ['Internal', 'External', 'Active', ''];
return <InfoTable key={idx} mode={mode} caption={caption} editor={editor} viewer={viewer} desc={desc} />;
})}
</>
);
}
export default memo(MonthlyStatistics);

View File

@ -1,81 +0,0 @@
import {useQuery} from '@tanstack/react-query';
import {fetchStatistics} from '../../api';
import StatisticsTopBlock from '../../components/StatisticsTopBlock/StatisticsTopBlock';
import StatisticsInfoTable from '../../components/StatisticsInfoTable/StatisticsInfoTable';
import styles from './Statistics.module.scss';
function Statistics() {
const {data, isLoading, error} = useQuery({
queryKey: ['statistics'],
queryFn: fetchStatistics
});
if (isLoading) return <div>Loading statistics...</div>;
if (error) return <div style={{color: 'red'}}>Error: {error.message}</div>;
if (!data) return null;
const {licenseInfo, quota, connectionsStat, serverInfo} = data;
const buildDate = licenseInfo.buildDate ? new Date(licenseInfo.buildDate).toLocaleDateString() : '';
const buildBlock = (
<StatisticsTopBlock title='Build'>
<div>Type: {licenseInfo.packageType === 0 ? 'Open source' : licenseInfo.packageType === 1 ? 'Enterprise Edition' : 'Developer Edition'}</div>
<div>
Version: {serverInfo.buildVersion}.{serverInfo.buildNumber}
</div>
<div>Release date: {buildDate}</div>
</StatisticsTopBlock>
);
const licenseBlock = (
<StatisticsTopBlock title='License'>
{licenseInfo.startDate === null ? (
'No license'
) : (
<div>Start date: {licenseInfo.startDate ? new Date(licenseInfo.startDate).toLocaleDateString() : ''}</div>
)}
</StatisticsTopBlock>
);
const connectionsBlock = (
<StatisticsTopBlock title='Connections limit'>
<div>Editors: {licenseInfo.connections}</div>
<div>Live Viewer: {licenseInfo.connectionsView}</div>
</StatisticsTopBlock>
);
const valueEdit = licenseInfo.connections - (quota.edit.connectionsCount || 0);
const valueView = licenseInfo.connectionsView - (quota.view.connectionsCount || 0);
const editor = [
[quota.edit.connectionsCount || 0, ''],
[valueEdit, valueEdit > licenseInfo.connections * 0.1 ? 'normal' : 'critical']
];
const viewer = [
[quota.view.connectionsCount || 0, ''],
[valueView, valueView > licenseInfo.connectionsView * 0.1 ? 'normal' : 'critical']
];
const desc = ['Active', 'Remaining'];
const peaksDesc = ['Last Hour', '24 Hours', 'Week', 'Month'];
const peaksEditor = ['hour', 'day', 'week', 'month'].map(k => [connectionsStat?.[k]?.edit?.max || 0]);
const peaksViewer = ['hour', 'day', 'week', 'month'].map(k => [connectionsStat?.[k]?.liveview?.max || 0]);
const avrEditor = ['hour', 'day', 'week', 'month'].map(k => [connectionsStat?.[k]?.edit?.avr || 0]);
const avrViewer = ['hour', 'day', 'week', 'month'].map(k => [connectionsStat?.[k]?.liveview?.avr || 0]);
return (
<div>
<div className={styles.topRow}>
{buildBlock}
{licenseBlock}
{connectionsBlock}
</div>
<StatisticsInfoTable caption='Current connections' editor={editor} viewer={viewer} desc={desc} />
<StatisticsInfoTable caption='Peaks' editor={peaksEditor} viewer={peaksViewer} desc={peaksDesc} />
<StatisticsInfoTable caption='Average' editor={avrEditor} viewer={avrViewer} desc={peaksDesc} />
</div>
);
}
export default Statistics;

View File

@ -3,3 +3,18 @@
margin-bottom: 24px;
gap: 24px;
}
.modeBar {
margin: 8px 0 16px;
font-size: 14px;
}
.modeLink {
cursor: pointer;
padding: 0 6px;
}
.modeSeparator {
color: #777;
}
.current {
font-weight: 700;
}

View File

@ -0,0 +1,10 @@
import styles from './styles.module.css';
export default function TopBlock({title, children}) {
return (
<div className={styles.block}>
<div className={styles.title}>{title}</div>
<div className={styles.content}>{children}</div>
</div>
);
}

View File

@ -0,0 +1,19 @@
.block {
flex: 1;
min-width: 180px;
}
.title {
font-weight: 400;
font-size: 18px;
border-bottom: 1px solid #ddd;
padding: 0 0 5px 10px;
}
.content {
font-size: 12px;
display: flex;
flex-direction: column;
gap: 16px;
margin-top: 16px;
padding-left: 10px;
}

View File

@ -0,0 +1,256 @@
import {useEffect, useMemo, useState} from 'react';
import {useQuery} from '@tanstack/react-query';
import TopBlock from './TopBlock/index';
import InfoTable from './InfoTable/index';
import ModeSwitcher from './ModeSwitcher';
import MonthlyStatistics from './MonthlyStatistics';
import styles from './styles.module.css';
import {fetchStatistics, fetchConfiguration} from '../../api';
// Constants
const CRITICAL_COLOR = '#ff0000';
const CRITICAL_THRESHOLD = 0.1;
const TIME_PERIODS = ['hour', 'day', 'week', 'month'];
const TIME_PERIOD_LABELS = ['Last Hour', '24 Hours', 'Week', 'Month'];
const SECONDS_PER_DAY = 86400;
/**
* Calculate critical status for remaining values
* @param {number} remaining - Remaining count
* @param {number} limit - Total limit
* @returns {string} 'normal' | 'critical'
*/
const getCriticalStatus = (remaining, limit) => (remaining > limit * CRITICAL_THRESHOLD ? 'normal' : 'critical');
// ModeSwitcher moved to ./ModeSwitcher (kept behavior, simplified markup/styles)
/**
* Statistics component - renders Document Server statistics
* Mirrors branding/info/index.html rendering logic with mode toggling
*/
export default function Statistics() {
const {data, isLoading, error} = useQuery({
queryKey: ['statistics'],
queryFn: fetchStatistics
});
// Fetch configuration to display DB info
const {data: configData} = useQuery({
queryKey: ['configuration'],
queryFn: fetchConfiguration
});
const [mode, setMode] = useState(() => {
try {
const saved = window.localStorage?.getItem('server-info-display-mode');
return saved || 'all';
} catch {
return 'all';
}
});
useEffect(() => {
try {
window.localStorage?.setItem('server-info-display-mode', mode);
} catch {
/* ignore */
}
}, [mode]);
// Safe defaults to maintain hook order consistency (memoized to avoid dependency changes)
const licenseInfo = useMemo(() => data?.licenseInfo ?? {}, [data?.licenseInfo]);
const quota = useMemo(() => data?.quota ?? {}, [data?.quota]);
const connectionsStat = useMemo(() => data?.connectionsStat ?? {}, [data?.connectionsStat]);
const serverInfo = useMemo(() => data?.serverInfo ?? {}, [data?.serverInfo]);
// Derived values used across multiple components
const isUsersModel = licenseInfo.usersCount > 0;
const limitEdit = isUsersModel ? licenseInfo.usersCount : licenseInfo.connections;
const limitView = isUsersModel ? licenseInfo.usersViewCount : licenseInfo.connectionsView;
// Build block
const buildDate = licenseInfo.buildDate ? new Date(licenseInfo.buildDate).toLocaleDateString() : '';
const packageTypeLabel = licenseInfo.packageType === 0 ? 'Open source' : licenseInfo.packageType === 1 ? 'Enterprise Edition' : 'Developer Edition';
const buildBlock = (
<TopBlock title='Build'>
<div>Type: {packageTypeLabel}</div>
<div>
Version: {serverInfo.buildVersion}.{serverInfo.buildNumber}
</div>
<div>Release date: {buildDate}</div>
</TopBlock>
);
// License block (mirrors fillInfo license validity rendering)
const licenseBlock = (() => {
if (licenseInfo.endDate === null) {
return (
<TopBlock title='License'>
<div>No license</div>
</TopBlock>
);
}
const isLimited = licenseInfo.mode & 1 || licenseInfo.mode & 4;
const licEnd = new Date(licenseInfo.endDate);
const srvDate = new Date(serverInfo.date);
const licType = licenseInfo.type;
const isInvalid = licType === 2 || licType === 1 || licType === 6 || licType === 11;
const isUpdateUnavailable = !isLimited && srvDate > licEnd;
const licValidText = isLimited ? 'Valid: ' : 'Updates available: ';
const licValidColor = isInvalid || isUpdateUnavailable ? CRITICAL_COLOR : undefined;
const startDateStr = licenseInfo.startDate ? new Date(licenseInfo.startDate).toLocaleDateString() : '';
const isStartCritical = licType === 16 || (licenseInfo.startDate ? new Date(licenseInfo.startDate) > srvDate : false);
const trialText = licenseInfo.mode & 1 ? 'Trial' : '';
return (
<TopBlock title='License'>
{startDateStr && <div style={isStartCritical ? {color: CRITICAL_COLOR} : undefined}>Start date: {startDateStr}</div>}
<div>
<span>{licValidText}</span>
<span style={licValidColor ? {color: licValidColor} : undefined}>{licEnd.toLocaleDateString()}</span>
</div>
{trialText && <div>{trialText}</div>}
</TopBlock>
);
})();
// Limits block
const limitTitle = isUsersModel ? 'Users limit' : 'Connections limit';
const limitsBlock = (
<TopBlock title={limitTitle}>
<div>Editors: {limitEdit}</div>
<div>Live Viewer: {limitView}</div>
</TopBlock>
);
/**
* Render database info block
* @param {object|null} sql - services.CoAuthoring.sql config
* @returns {JSX.Element|null}
*/
const renderDatabaseBlock = sql => {
if (!sql) return null;
return (
<TopBlock title='Database'>
<div>Type: {sql.type}</div>
<div>Host: {sql.dbHost}</div>
<div>Port: {sql.dbPort}</div>
<div>Name: {sql.dbName}</div>
</TopBlock>
);
};
// Current activity/usage table
const currentTable = useMemo(() => {
if (isUsersModel) {
// Users model
const days = parseInt(licenseInfo.usersExpire / SECONDS_PER_DAY, 10) || 1;
const qEditUnique = quota?.edit?.usersCount?.unique || 0;
const qEditAnon = quota?.edit?.usersCount?.anonymous || 0;
const qViewUnique = quota?.view?.usersCount?.unique || 0;
const qViewAnon = quota?.view?.usersCount?.anonymous || 0;
const remainingEdit = limitEdit - qEditUnique;
const remainingView = limitView - qViewUnique;
const editor = [
[qEditUnique, ''],
[qEditUnique - qEditAnon, ''],
[qEditAnon, ''],
[remainingEdit, getCriticalStatus(remainingEdit, limitEdit)]
];
const viewer = [
[qViewUnique, ''],
[qViewUnique - qViewAnon, ''],
[qViewAnon, ''],
[remainingView, getCriticalStatus(remainingView, limitView)]
];
const desc = ['Active', 'Internal', 'External', 'Remaining'];
return (
<InfoTable
mode={mode}
caption={`User activity in the last ${days} ${days > 1 ? 'days' : 'day'}`}
editor={editor}
viewer={viewer}
desc={desc}
/>
);
}
// Connections model
const activeEdit = quota?.edit?.connectionsCount || 0;
const activeView = quota?.view?.connectionsCount || 0;
const remainingEdit = limitEdit - activeEdit;
const remainingView = limitView - activeView;
const editor = [
[activeEdit, ''],
[remainingEdit, getCriticalStatus(remainingEdit, limitEdit)]
];
const viewer = [
[activeView, ''],
[remainingView, getCriticalStatus(remainingView, limitView)]
];
const desc = ['Active', 'Remaining'];
return <InfoTable mode={mode} caption='Current connections' editor={editor} viewer={viewer} desc={desc} />;
}, [isUsersModel, licenseInfo, quota, limitEdit, limitView, mode]);
// Peaks and Averages (only for connections model)
const peaksAverage = useMemo(() => {
if (isUsersModel) return null;
const editorPeaks = [];
const viewerPeaks = [];
const editorAvr = [];
const viewerAvr = [];
TIME_PERIODS.forEach((k, index) => {
const item = connectionsStat?.[k];
if (item?.edit) {
let value = item.edit.max || 0;
editorPeaks[index] = [value, value >= limitEdit ? 'critical' : ''];
value = item.edit.avr || 0;
editorAvr[index] = [value, value >= limitEdit ? 'critical' : ''];
}
if (item?.liveview) {
let value = item.liveview.max || 0;
viewerPeaks[index] = [value, value >= limitView ? 'critical' : ''];
value = item.liveview.avr || 0;
viewerAvr[index] = [value, value >= limitView ? 'critical' : ''];
}
});
return (
<>
<InfoTable mode={mode} caption='Peaks' editor={editorPeaks} viewer={viewerPeaks} desc={TIME_PERIOD_LABELS} />
<InfoTable mode={mode} caption='Average' editor={editorAvr} viewer={viewerAvr} desc={TIME_PERIOD_LABELS} />
</>
);
}, [isUsersModel, connectionsStat, limitEdit, limitView, mode]);
// MonthlyStatistics moved to ./MonthlyStatistics for clarity and to keep this file concise
// After hooks and memos: show loading/error states
if (error) {
return <div style={{color: 'red'}}>Error: {error.message}</div>;
}
if (isLoading || !data) {
return <div>Please, wait...</div>;
}
return (
<div>
<div className={styles.topRow}>
{buildBlock}
{licenseBlock}
{limitsBlock}
</div>
{renderDatabaseBlock(configData?.services?.CoAuthoring?.sql)}
<ModeSwitcher mode={mode} setMode={setMode} />
{currentTable}
{peaksAverage}
{isUsersModel && <MonthlyStatistics byMonth={quota?.byMonth} mode={mode} />}
</div>
);
}

View File

@ -0,0 +1,21 @@
.topRow {
display: flex;
margin-bottom: 24px;
gap: 24px;
}
.modeBar {
margin: 8px 0 16px;
font-size: 14px;
}
.modeLink {
cursor: pointer;
padding: 0 6px;
}
.modeSeparator {
color: #777;
}
.current {
font-weight: 700;
}

View File

@ -8,7 +8,8 @@ module.exports = {
output: {
filename: 'main.[contenthash].js',
path: path.resolve(__dirname, 'build'),
publicPath: '/'
// Use relative URLs so assets load under any prefix (e.g., /admin)
publicPath: ''
},
devServer: {

View File

@ -78,24 +78,31 @@ const corsWithCredentials = cors({
operationContext.global.logger.warn('AdminPanel server starting...');
app.use('/info/config', corsWithCredentials, utils.checkClientIp, configRouter);
app.use('/info/adminpanel', corsWithCredentials, utils.checkClientIp, adminpanelRouter);
// Shared Info router (provides /info.json)
app.use('/info', infoRouter());
app.use('/api/v1/admin/config', corsWithCredentials, utils.checkClientIp, configRouter);
app.use('/api/v1/admin', corsWithCredentials, utils.checkClientIp, adminpanelRouter);
app.get('/api/v1/admin/stat', corsWithCredentials, utils.checkClientIp, infoRouter.licenseInfo);
// todo config or _dirname. Serve AdminPanel client build as static assets
// Serve AdminPanel client build as static assets.
// Use admin prefix in production (or ADMIN_PREFIX env), no prefix locally.
const clientBuildPath = path.resolve('client/build');
app.use(express.static(clientBuildPath));
// Normalize admin prefix: default '/admin' in production, '' otherwise.
const rawAdminPrefix = process.env.ADMIN_PREFIX || (process.env.NODE_ENV === 'production' ? '/admin' : '');
const adminPrefix = rawAdminPrefix && rawAdminPrefix !== '/' ? rawAdminPrefix : '';
app.use(adminPrefix || '/', express.static(clientBuildPath));
function serveSpaIndex(req, res, next) {
if (req.path.startsWith('/info/')) return next();
if (req.path.startsWith('/api')) return next();
res.sendFile(path.join(clientBuildPath, 'index.html'));
}
// client SPA routes
app.get('*', serveSpaIndex);
// client SPA routes (prefix in prod, root locally)
if (adminPrefix) {
app.get(`${adminPrefix}/*`, serveSpaIndex);
} else {
app.get('*', serveSpaIndex);
}
app.use((err, req, res) => {
app.use((err, req, res, _next) => {
const ctx = new operationContext.Context();
ctx.initFromRequest(req);
ctx.logger.error('default error handler:%s', err.stack);