Merge remote-tracking branch 'remotes/origin/feature/statistics-design' into develop

# Conflicts:
#	AdminPanel/client/src/pages/Statistics/index.js
#	AdminPanel/client/src/pages/Statistics/styles.module.css
This commit is contained in:
Sergey Konovalov
2025-12-03 20:36:07 +03:00
31 changed files with 1598 additions and 485 deletions

View File

@ -27,8 +27,8 @@ function AppContent() {
<ScrollToTop />
<ConfigLoader>
<Routes>
<Route path='/' element={<Navigate to='/statistics' replace />} />
<Route path='/index.html' element={<Navigate to='/statistics' replace />} />
<Route path='/' element={<Navigate to='/dashboard' replace />} />
<Route path='/index.html' element={<Navigate to='/dashboard' replace />} />
{menuItems.map(item => (
<Route key={item.key} path={item.path} element={<item.component />} />
))}

View File

@ -22,12 +22,19 @@ const safeFetch = async (url, options = {}) => {
}
};
export const fetchStatistics = async () => {
const response = await safeFetch(`${API_BASE_PATH}/stat`, {credentials: 'include'});
export const fetchStatistics = async tenant => {
const url = tenant ? `${API_BASE_PATH}/stat?tenant=${encodeURIComponent(tenant)}` : `${API_BASE_PATH}/stat`;
const response = await safeFetch(url, {credentials: 'include'});
if (!response.ok) throw new Error('Failed to fetch statistics');
return response.json();
};
export const fetchTenants = async () => {
const response = await safeFetch(`${API_BASE_PATH}/tenants`, {credentials: 'include'});
if (!response.ok) throw new Error('Failed to fetch tenants');
return response.json();
};
export const fetchConfiguration = async () => {
const response = await safeFetch(`${API_BASE_PATH}/config`, {credentials: 'include'});
if (response.status === 401) throw new Error('UNAUTHORIZED');
@ -259,3 +266,31 @@ export const getForgotten = async docId => {
name: docId.split('/').pop() || docId
};
};
/**
* Convert HTML to PDF using the FileConverter service
* @param {string} htmlContent - HTML content to convert
* @returns {Promise<Blob>} PDF blob
*/
export const convertHtmlToPdf = async htmlContent => {
// Create a Blob from HTML content
const htmlBlob = new Blob([htmlContent], {type: 'text/html'});
const htmlFile = new File([htmlBlob], 'statistics.html', {type: 'text/html'});
// Create FormData
const formData = new FormData();
formData.append('file', htmlFile);
formData.append('format', 'pdf');
const response = await safeFetch(`${DOCSERVICE_URL}/lool/convert-to/pdf`, {
method: 'POST',
credentials: 'include',
body: formData
});
if (!response.ok) {
throw new Error('Failed to convert HTML to PDF');
}
return await response.blob();
};

View File

@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="8" r="7.25" stroke="#666666" stroke-width="1.5"/>
<path d="M8.18 10.9002C8.17333 10.9335 8.17 10.9769 8.17 11.0302C8.17 11.1902 8.23 11.2702 8.35 11.2702C8.40333 11.2702 8.46 11.2535 8.52 11.2202C8.58667 11.1869 8.68 11.1302 8.8 11.0502L8.93 11.3902C8.81667 11.5435 8.64 11.7002 8.4 11.8602C8.16667 12.0202 7.87 12.1002 7.51 12.1002C7.20333 12.1002 6.96333 12.0435 6.79 11.9302C6.62333 11.8102 6.54 11.6569 6.54 11.4702C6.54 11.4302 6.54333 11.3969 6.55 11.3702L6.81 9.4002L7.09 7.3102L6.53 7.0102L6.61 6.5802L8.54 6.3202L8.8 6.4402L8.18 10.9002ZM8.17 5.4602C7.97 5.4602 7.79 5.3802 7.63 5.2202C7.47667 5.0602 7.4 4.8802 7.4 4.6802C7.4 4.40686 7.49333 4.17686 7.68 3.9902C7.86667 3.79686 8.11333 3.7002 8.42 3.7002C8.65333 3.7002 8.84 3.77686 8.98 3.9302C9.12 4.08353 9.19 4.2602 9.19 4.4602C9.19 4.75353 9.10333 4.99353 8.93 5.1802C8.75667 5.36686 8.50333 5.4602 8.17 5.4602Z" fill="#666666"/>
</svg>

After

Width:  |  Height:  |  Size: 1017 B

View File

@ -0,0 +1,141 @@
import {useEffect, useMemo, useRef, useState} from 'react';
import styles from './ComboBox.module.scss';
function ComboBox({value, onChange, options = [], placeholder = '', disabled = false, className = '', ...props}) {
const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState('');
const [activeIndex, setActiveIndex] = useState(-1);
const wrapperRef = useRef(null);
const inputRef = useRef(null);
const selectedOption = useMemo(() => options.find(o => o.value === value) || null, [options, value]);
const filteredOptions = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return options;
return options.filter(o => String(o.label).toLowerCase().includes(q));
}, [options, query]);
useEffect(() => {
const handleClickOutside = event => {
if (wrapperRef.current && !wrapperRef.current.contains(event.target)) {
setIsOpen(false);
setActiveIndex(-1);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const openDropdown = () => {
if (disabled) return;
setIsOpen(true);
setActiveIndex(-1);
// Initialize query with current selection label for quick refinement
setQuery(selectedOption ? String(selectedOption.label) : '');
requestAnimationFrame(() => {
inputRef.current?.focus();
inputRef.current?.select();
});
};
const closeDropdown = () => {
setIsOpen(false);
setActiveIndex(-1);
};
const handleSelect = option => {
onChange(option.value);
setQuery(String(option.label));
closeDropdown();
};
const handleKeyDown = e => {
if (!isOpen) {
if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openDropdown();
}
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveIndex(prev => {
const next = prev + 1;
return next >= filteredOptions.length ? 0 : next;
});
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIndex(prev => {
const next = prev - 1;
return next < 0 ? Math.max(filteredOptions.length - 1, 0) : next;
});
} else if (e.key === 'Enter') {
e.preventDefault();
if (activeIndex >= 0 && activeIndex < filteredOptions.length) {
handleSelect(filteredOptions[activeIndex]);
} else if (filteredOptions.length === 1) {
handleSelect(filteredOptions[0]);
}
} else if (e.key === 'Escape') {
e.preventDefault();
closeDropdown();
}
};
return (
<div className={`${styles.comboWrapper} ${className || ''}`} ref={wrapperRef} {...props}>
<div
className={`${styles.control} ${disabled ? styles['control--disabled'] : ''}`}
onClick={openDropdown}
role='combobox'
aria-expanded={isOpen}
aria-haspopup='listbox'
tabIndex={0}
onKeyDown={handleKeyDown}
>
<input
ref={inputRef}
className={styles.input}
type='text'
placeholder={placeholder}
value={isOpen ? query : selectedOption?.label || ''}
onChange={e => setQuery(e.target.value)}
onFocus={() => !isOpen && openDropdown()}
readOnly={disabled}
/>
<div className={`${styles.arrow} ${isOpen ? styles['arrow--open'] : ''}`} aria-hidden>
<svg width='12' height='7' viewBox='0 0 12 7' fill='none' xmlns='http://www.w3.org/2000/svg'>
<path d='M1 1L6 6L11 1' stroke='#808080' strokeWidth='1.5' strokeLinecap='round' strokeLinejoin='round' />
</svg>
</div>
</div>
{isOpen && (
<div className={styles.dropdown} role='listbox'>
{filteredOptions.length === 0 && <div className={styles.empty}>No results</div>}
{filteredOptions.map((option, index) => {
const isSelected = option.value === value;
const isActive = index === activeIndex;
return (
<div
key={`${option.value}-${index}`}
className={`${styles.option} ${isSelected ? styles['option--selected'] : ''} ${isActive ? styles['option--active'] : ''}`}
role='option'
aria-selected={isSelected}
onMouseDown={e => e.preventDefault()}
onClick={() => handleSelect(option)}
onMouseEnter={() => setActiveIndex(index)}
>
{option.label}
</div>
);
})}
</div>
)}
</div>
);
}
export default ComboBox;

View File

@ -0,0 +1,103 @@
.comboWrapper {
position: relative;
display: inline-block;
}
.control {
position: relative;
display: flex;
align-items: center;
height: 48px;
background: #f1f1f1;
border-radius: 4px;
box-sizing: border-box;
cursor: text;
&--disabled {
cursor: not-allowed;
opacity: 0.6;
}
}
.input {
flex: 1;
width: 100%;
min-width: 0;
border: none;
outline: none;
background: transparent;
padding: 12px 16px;
padding-right: 52px; // space for arrow
font-family: 'Open Sans', sans-serif;
font-weight: 400;
font-style: normal;
font-size: 16px;
line-height: 150%;
color: #333333;
}
.arrow {
position: absolute;
right: 16px;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
display: flex;
align-items: center;
justify-content: center;
width: 12px;
height: 7px;
transition: transform 0.2s ease;
&--open {
transform: translateY(-50%) rotate(180deg);
}
}
.dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: #ffffff;
border: 1px solid #e2e2e2;
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
z-index: 1000;
margin-top: 4px;
max-height: 320px;
overflow: auto;
}
.empty {
padding: 12px 16px;
color: #808080;
font-size: 14px;
}
.option {
display: flex;
align-items: center;
height: 40px;
padding: 0 16px;
font-family: 'Open Sans', sans-serif;
font-weight: 400;
font-size: 14px;
line-height: 150%;
color: #333333;
cursor: pointer;
transition: background-color 0.2s ease;
&:hover {
background: #f9f9f9;
}
&--selected {
color: #ff6f3d;
font-weight: 600;
}
&--active {
background: #f3f3f3;
}
}

View File

@ -1,3 +1,4 @@
import Dashboard from '../pages/Dashboard/Dashboard';
import WOPISettings from '../pages/WOPISettings/WOPISettings';
import Expiration from '../pages/Expiration/Expiration';
import SecuritySettings from '../pages/SecuritySettings/SecuritySettings';
@ -13,6 +14,7 @@ import Example from '../pages/Example/Example';
import Forgotten from '../pages/Forgotten/Forgotten';
export const menuItems = [
{key: 'dashboard', label: 'Dashboard', path: '/dashboard', component: Dashboard, iconIndex: 0},
{key: 'statistics', label: 'Statistics', path: '/statistics', component: Statistics, iconIndex: 1},
{key: 'ai-integration', label: 'AI Integration', path: '/ai-integration', component: AiIntegration, iconIndex: 2},
{key: 'example', label: 'Example', path: '/example', component: Example, iconIndex: 3},

View File

@ -0,0 +1,167 @@
import {useEffect, useMemo, useState} from 'react';
import {useQuery} from '@tanstack/react-query';
import TopBlock from './TopBlock/index';
import PageHeader from '../../components/PageHeader/PageHeader';
import PageDescription from '../../components/PageDescription/PageDescription';
import {fetchStatistics, fetchConfiguration, fetchTenants} from '../../api';
const CRITICAL_COLOR = '#ff0000';
function Dashboard() {
const [selectedTenant, setSelectedTenant] = useState('');
const {
data: tenantsData,
isLoading: tenantsLoading,
error: tenantsError
} = useQuery({
queryKey: ['tenants'],
queryFn: fetchTenants
});
useEffect(() => {
if (tenantsData?.baseTenant && !selectedTenant) {
setSelectedTenant(tenantsData.baseTenant);
}
}, [tenantsData, selectedTenant]);
const {data, isLoading, error} = useQuery({
queryKey: ['statistics', selectedTenant],
queryFn: () => fetchStatistics(selectedTenant),
enabled: !!selectedTenant
});
// Fetch configuration to display DB info
const {data: configData} = useQuery({
queryKey: ['configuration'],
queryFn: fetchConfiguration
});
// Safe defaults to maintain hook order consistency (memoized to avoid dependency changes)
const licenseInfo = useMemo(() => data?.licenseInfo ?? {}, [data?.licenseInfo]);
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 isOpenSource = licenseInfo.packageType === 0;
const packageTypeLabel = isOpenSource ? '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>
);
};
// Show loading/error states
if (error) {
return (
<div>
<PageHeader>Dashboard</PageHeader>
<PageDescription>Overview of your DocServer Admin Panel</PageDescription>
<div style={{color: 'red'}}>Error: {error.message}</div>
</div>
);
}
if (tenantsError) {
return (
<div>
<PageHeader>Dashboard</PageHeader>
<PageDescription>Overview of your DocServer Admin Panel</PageDescription>
<div style={{color: 'red'}}>Error: {tenantsError.message}</div>
</div>
);
}
if (isLoading || !data || !tenantsData || tenantsLoading) {
return (
<div>
<PageHeader>Dashboard</PageHeader>
<PageDescription>Overview of your DocServer Admin Panel</PageDescription>
<div>Please, wait...</div>
</div>
);
}
return (
<div>
<PageHeader>Dashboard</PageHeader>
<PageDescription>Overview of your DocServer Admin Panel</PageDescription>
<div style={{display: 'flex', gap: '24px', marginBottom: '32px', flexWrap: 'wrap'}}>
{buildBlock}
{licenseBlock}
{limitsBlock}
</div>
{renderDatabaseBlock(configData?.services?.CoAuthoring?.sql)}
</div>
);
}
export default Dashboard;

View File

@ -0,0 +1,39 @@
import StatisticsCard from '../StatisticsCard/StatisticsCard';
import ProgressBar from '../ProgressBar/ProgressBar';
import MetricRow from '../MetricRow/MetricRow';
/**
* Helper function to get usage color based on percentage
*/
const getUsageColor = percent => {
if (percent >= 90) return '#CB0000'; // Critical - Red
if (percent >= 70) return '#FF6F3D'; // Warning - Orange
return '#007B14'; // Normal - Green
};
/**
* ConnectionsCard component for Editors or Live Viewer
* @param {Object} props
* @param {number} props.active - Active connections count
* @param {number} props.limit - Maximum limit
* @param {number} props.remaining - Remaining connections
* @param {string} props.type - 'Editor' or 'Viewer'
*/
export default function ConnectionsCard({active, limit, remaining, type}) {
const percent = limit > 0 ? (active / limit) * 100 : 0;
const color = getUsageColor(percent);
const remainingColor = getUsageColor(percent); // Use same color logic for remaining
const title = type === 'Editor' ? 'Editors' : 'Live Viewer';
const description = type === 'Editor' ? 'Active editing sessions and availability' : 'Active read-only sessions and availability';
const activeDescription = type === 'Editor' ? 'Users currently editing documents' : 'Users currently viewing documents';
const remainingDescription = type === 'Editor' ? 'Editor sessions before limit' : 'Viewer sessions before limit';
return (
<StatisticsCard title={title} description={description} additionalClass='connections-card'>
<ProgressBar current={active} limit={limit} percent={percent} color={color} label={type} />
<MetricRow count={active} description={activeDescription} label='Sessions' title='Active' />
<MetricRow count={remaining} description={remainingDescription} label='Available' title='Remaining' color={remainingColor} />
</StatisticsCard>
);
}

View File

@ -1,64 +0,0 @@
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

@ -1,56 +0,0 @@
.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,31 @@
import styles from './MetricRow.module.css';
/**
* MetricRow component for displaying Active or Remaining metrics
* @param {Object} props
* @param {number} props.count - The count value
* @param {string} props.description - Description text
* @param {string} props.label - Label text (e.g., 'Sessions', 'Available')
* @param {string} props.title - Title (e.g., 'Active', 'Remaining')
* @param {string} props.color - Optional color for the count value
*/
export default function MetricRow({count, description, label, title, color = '#333333'}) {
return (
<div className={styles.container}>
<table className={styles.table}>
<tbody>
<tr>
<td className={styles.titleCell}>{title}</td>
<td className={styles.countCell} style={{color}}>
{count}
</td>
</tr>
<tr>
<td className={styles.descriptionCell}>{description}</td>
<td className={styles.labelCell}>{label}</td>
</tr>
</tbody>
</table>
</div>
);
}

View File

@ -0,0 +1,45 @@
.container {
margin-bottom: 24px;
}
.table {
width: 100%;
border-collapse: collapse;
}
.titleCell {
font-weight: 700;
font-size: 18px;
line-height: 130%;
color: #444444;
padding: 0;
vertical-align: top;
}
.countCell {
font-weight: 700;
font-size: 20px;
line-height: 130%;
text-align: right;
padding: 0;
vertical-align: top;
}
.descriptionCell {
font-weight: 600;
font-size: 14px;
line-height: 150%;
color: #666666;
padding: 0;
padding-top: 4px;
}
.labelCell {
font-weight: 600;
font-size: 14px;
line-height: 150%;
color: #666666;
text-align: right;
padding: 0;
padding-top: 4px;
}

View File

@ -1,28 +0,0 @@
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

@ -1,86 +0,0 @@
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

@ -0,0 +1,53 @@
import styles from './ProgressBar.module.css';
/**
* ProgressBar component for displaying usage percentage
* @param {Object} props
* @param {number} props.current - Current usage count
* @param {number} props.limit - Maximum limit
* @param {number} props.percent - Usage percentage (0-100)
* @param {string} props.color - Color for the progress bar
* @param {string} props.label - Label for the progress bar (e.g., 'Editor', 'Viewer')
*/
export default function ProgressBar({current, limit, percent, color, label}) {
const isCritical = percent >= 90;
const actualPercent = Math.max(0, Math.min(100, percent));
const tooltipText = isCritical ? `Usage: ${Math.round(percent)}% — High load. Consider increasing ${label.toLowerCase()} session limit.` : '';
return (
<div className={styles.container}>
<table className={styles.labelTable}>
<tbody>
<tr>
<td className={styles.labelCell}>
{label} Usage
{isCritical && (
<span className={styles.infoIcon} title={tooltipText}>
<svg width='16' height='16' viewBox='0 0 16 16' fill='none' xmlns='http://www.w3.org/2000/svg'>
<circle cx='8' cy='8' r='7.25' stroke='#666666' strokeWidth='1.5' />
<path
d='M8.18 10.9002C8.17333 10.9335 8.17 10.9769 8.17 11.0302C8.17 11.1902 8.23 11.2702 8.35 11.2702C8.40333 11.2702 8.46 11.2535 8.52 11.2202C8.58667 11.1869 8.68 11.1302 8.8 11.0502L8.93 11.3902C8.81667 11.5435 8.64 11.7002 8.4 11.8602C8.16667 12.0202 7.87 12.1002 7.51 12.1002C7.20333 12.1002 6.96333 12.0435 6.79 11.9302C6.62333 11.8102 6.54 11.6569 6.54 11.4702C6.54 11.4302 6.54333 11.3969 6.55 11.3702L6.81 9.4002L7.09 7.3102L6.53 7.0102L6.61 6.5802L8.54 6.3202L8.8 6.4402L8.18 10.9002ZM8.17 5.4602C7.97 5.4602 7.79 5.3802 7.63 5.2202C7.47667 5.0602 7.4 4.8802 7.4 4.6802C7.4 4.40686 7.49333 4.17686 7.68 3.9902C7.86667 3.79686 8.11333 3.7002 8.42 3.7002C8.65333 3.7002 8.84 3.77686 8.98 3.9302C9.12 4.08353 9.19 4.2602 9.19 4.4602C9.19 4.75353 9.10333 4.99353 8.93 5.1802C8.75667 5.36686 8.50333 5.4602 8.17 5.4602Z'
fill='#666666'
/>
</svg>
</span>
)}
</td>
<td className={styles.valueCell}>
{current} / {limit}
</td>
</tr>
</tbody>
</table>
<div className={styles.barContainer}>
<div
className={styles.barFill}
style={{
width: `${actualPercent}%`,
backgroundColor: color
}}
/>
</div>
</div>
);
}

View File

@ -0,0 +1,58 @@
.container {
margin-bottom: 24px;
}
.labelTable {
width: 100%;
border-collapse: collapse;
margin-bottom: 8px;
}
.labelCell {
font-weight: 700;
font-size: 18px;
line-height: 130%;
color: #444444;
padding: 0;
}
.valueCell {
color: #333333;
font-weight: 700;
font-size: 20px;
line-height: 130%;
text-align: right;
padding: 0;
white-space: nowrap;
}
.infoIcon {
position: relative;
display: inline-block;
margin-left: 4px;
cursor: help;
vertical-align: middle;
}
.infoIcon svg {
width: 16px;
height: 16px;
display: block;
}
.barContainer {
width: 100%;
height: 8px;
background-color: #e0e0e0;
border-radius: 8px;
overflow: hidden;
position: relative;
}
.barFill {
height: 100%;
position: absolute;
left: 0;
top: 0;
border-radius: 8px;
}

View File

@ -1,20 +0,0 @@
.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

@ -0,0 +1,21 @@
import styles from './StatisticsCard.module.css';
/**
* StatisticsCard wrapper component
* @param {Object} props
* @param {string} props.title - Card title
* @param {string} props.description - Card description
* @param {React.ReactNode} props.children - Card content
* @param {string} props.additionalClass - Additional CSS class
*/
export default function StatisticsCard({title, description, children, additionalClass = ''}) {
const cardClass = additionalClass ? `${styles.card} ${styles[additionalClass]}` : styles.card;
return (
<div className={cardClass}>
<h3 className={styles.title}>{title}</h3>
<p className={styles.description}>{description}</p>
{children}
</div>
);
}

View File

@ -0,0 +1,33 @@
.card {
width: 100%;
max-width: 100%;
box-sizing: border-box;
background-color: #ffffff;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 32px;
margin: 0;
overflow: hidden;
}
.connections-card {
padding-bottom: 8px;
}
.title {
font-weight: 700;
font-size: 22px;
line-height: 150%;
color: #333333;
margin: 0;
text-align: left;
}
.description {
font-weight: 600;
font-size: 14px;
line-height: 150%;
color: #666666;
margin: 0 0 32px 0;
text-align: left;
}

View File

@ -0,0 +1,91 @@
import {useMemo} from 'react';
import ConnectionsCard from '../ConnectionsCard/ConnectionsCard';
import TimePeriodSection from '../TimePeriodSection/TimePeriodSection';
import styles from './StatisticsContent.module.css';
const TIME_PERIODS = ['hour', 'day', 'week', 'month'];
/**
* StatisticsContent component - main component for rendering statistics
* @param {Object} props
* @param {Object} props.data - Statistics data object
* @param {string} props.mode - Display mode: 'all' | 'edit' | 'view'
*/
export default function StatisticsContent({data, mode}) {
const {licenseInfo = {}, quota = {}, connectionsStat = {}} = data;
// Derived values
const isUsersModel = licenseInfo.usersCount > 0;
const limitEdit = isUsersModel ? licenseInfo.usersCount : licenseInfo.connections || 0;
const limitView = isUsersModel ? licenseInfo.usersViewCount : licenseInfo.connectionsView || 0;
// Current connections data
const activeEdit = quota?.edit?.connectionsCount || 0;
const activeView = quota?.view?.connectionsCount || 0;
const remainingEdit = limitEdit - activeEdit;
const remainingView = limitView - activeView;
// Calculate peak and average values
const {editorPeaks, viewerPeaks, editorAvr, viewerAvr} = useMemo(() => {
const editorPeaks = [];
const viewerPeaks = [];
const editorAvr = [];
const viewerAvr = [];
TIME_PERIODS.forEach((period, index) => {
const item = connectionsStat?.[period];
if (item?.edit) {
editorPeaks[index] = item.edit.max || 0;
editorAvr[index] = item.edit.avr || 0;
}
if (item?.liveview) {
viewerPeaks[index] = item.liveview.max || 0;
viewerAvr[index] = item.liveview.avr || 0;
}
});
return {editorPeaks, viewerPeaks, editorAvr, viewerAvr};
}, [connectionsStat]);
return (
<div className={styles.container}>
{/* Connections Cards Row */}
{(mode === 'all' || mode === 'edit' || mode === 'view') && (
<div className={styles.connectionsRow}>
{(mode === 'all' || mode === 'edit') && (
<div className={styles.connectionsCard}>
<ConnectionsCard active={activeEdit} limit={limitEdit} remaining={remainingEdit} type='Editor' />
</div>
)}
{(mode === 'all' || mode === 'view') && (
<div className={styles.connectionsCard}>
<ConnectionsCard active={activeView} limit={limitView} remaining={remainingView} type='Viewer' />
</div>
)}
</div>
)}
{/* Peak and Average Row */}
<div className={styles.peakAverageRow}>
<div className={styles.peakCard}>
<TimePeriodSection
title='Peak Concurrent Sessions'
description='Maximum concurrent connections during different time periods'
editorValues={editorPeaks}
viewerValues={viewerPeaks}
mode={mode}
/>
</div>
<div className={styles.averageCard}>
<TimePeriodSection
title='Average Concurrent Sessions'
description='Average concurrent connections during different time periods'
editorValues={editorAvr}
viewerValues={viewerAvr}
mode={mode}
/>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,96 @@
.container {
width: 100%;
}
.connectionsRow {
width: 100%;
display: flex;
margin-bottom: 0;
}
.connectionsCard {
width: 50%;
max-width: 50%;
box-sizing: border-box;
vertical-align: top;
min-width: 0;
margin-bottom: 56px;
}
.connectionsCard:first-child {
padding-right: 28px;
}
.connectionsCard:last-child {
padding-left: 28px;
}
/* When only one card, it should take 50% with gap preserved */
.connectionsCard:only-child {
padding-right: 28px;
padding-left: 0;
margin-bottom: 56px;
}
/* Remove margin-bottom only from last card in row when there are two cards */
.connectionsRow .connectionsCard:last-child:not(:only-child),
.peakAverageRow .peakCard:last-child,
.peakAverageRow .averageCard:last-child {
margin-bottom: 0;
}
.peakAverageRow {
width: 100%;
display: flex;
margin-bottom: 0;
}
.peakCard {
width: 50%;
max-width: 50%;
box-sizing: border-box;
padding-right: 28px;
vertical-align: top;
min-width: 0;
margin-bottom: 56px;
}
.averageCard {
width: 50%;
max-width: 50%;
box-sizing: border-box;
padding-left: 28px;
vertical-align: top;
min-width: 0;
margin-bottom: 56px;
}
/* Responsive: stack everything vertically on smaller screens */
@media (max-width: 1160px) {
.connectionsRow,
.peakAverageRow {
flex-direction: column;
margin-bottom: 0;
}
.connectionsCard,
.peakCard,
.averageCard {
width: 100% !important;
max-width: 100% !important;
margin-bottom: 56px !important;
padding-left: 0 !important;
padding-right: 0 !important;
}
/* Override the rule that removes margin from last child in row */
.connectionsRow .connectionsCard:last-child,
.peakAverageRow .peakCard:last-child {
margin-bottom: 56px !important;
}
/* Only the last card in the last row should have no margin */
.peakAverageRow .averageCard:last-child {
margin-bottom: 0 !important;
}
}

View File

@ -0,0 +1,49 @@
import StatisticsCard from '../StatisticsCard/StatisticsCard';
import styles from './TimePeriodSection.module.css';
const TIME_LABELS = ['Last Hour', '24 Hours', 'Week', 'Month'];
/**
* TimePeriodSection component for Peak or Average values
* @param {Object} props
* @param {string} props.title - Section title (e.g., 'Peak Concurrent Sessions')
* @param {string} props.description - Section description
* @param {Array<number>} props.editorValues - Array of 4 values for editors
* @param {Array<number>} props.viewerValues - Array of 4 values for viewers
* @param {string} props.mode - Display mode: 'all' | 'edit' | 'view'
*/
export default function TimePeriodSection({title, description, editorValues, viewerValues, mode}) {
const renderTimePeriodCard = (cardTitle, cardDescription, values) => {
return (
<div className={styles.timePeriodCard}>
<h4 className={styles.timePeriodTitle}>{cardTitle}</h4>
<p className={styles.timePeriodDescription}>{cardDescription}</p>
<div className={styles.valuesContainer}>
{TIME_LABELS.map((label, index) => (
<div key={index} className={styles.valueItem}>
<div className={styles.value}>{values[index] || 0}</div>
<div className={styles.label}>{label}</div>
</div>
))}
</div>
</div>
);
};
const content = [];
if (mode === 'all' || mode === 'edit') {
content.push(<div key='editors'>{renderTimePeriodCard('Editors', 'Active editing sessions and availability', editorValues)}</div>);
}
if (mode === 'all' || mode === 'view') {
if (mode === 'all') {
content.push(<div key='divider' className={styles.divider} />);
}
content.push(<div key='viewer'>{renderTimePeriodCard('Live Viewer', 'Active read-only sessions and availability', viewerValues)}</div>);
}
return (
<StatisticsCard title={title} description={description}>
{content}
</StatisticsCard>
);
}

View File

@ -0,0 +1,60 @@
.timePeriodCard {
margin-bottom: 8px;
}
.timePeriodTitle {
font-weight: 700;
font-size: 18px;
line-height: 130%;
color: #444444;
margin: 0 0 4px 0;
}
.timePeriodDescription {
font-weight: 600;
font-size: 14px;
line-height: 150%;
color: #666666;
margin: 0 0 28px 0;
}
.valuesContainer {
width: 100%;
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.valueItem {
text-align: right;
flex: 0 0 auto;
}
.value {
font-size: 18px;
font-weight: 600;
color: #333333;
margin-bottom: 4px;
}
.label {
font-size: 12px;
color: #666666;
}
.divider {
margin-top: 32px;
margin-bottom: 32px;
border-top: 1px solid #e0e0e0;
}
@media (max-width: 1160px) {
.valuesContainer {
display: flex !important;
justify-content: space-between !important;
}
.valueItem {
text-align: right !important;
}
}

View File

@ -0,0 +1,337 @@
/**
* Generate HTML string from statistics data for PDF export
* @param {Object} data - Statistics data object
* @param {string} mode - Display mode: 'all' | 'edit' | 'view'
* @returns {string} Full HTML string for PDF conversion
*/
export function generateStatisticsHtml(data, mode = 'all') {
const {licenseInfo = {}, quota = {}, connectionsStat = {}} = data;
// Derived values
const isUsersModel = licenseInfo.usersCount > 0;
const limitEdit = isUsersModel ? licenseInfo.usersCount : licenseInfo.connections || 0;
const limitView = isUsersModel ? licenseInfo.usersViewCount : licenseInfo.connectionsView || 0;
// Current connections data
const activeEdit = quota?.edit?.connectionsCount || 0;
const activeView = quota?.view?.connectionsCount || 0;
const remainingEdit = limitEdit - activeEdit;
const remainingView = limitView - activeView;
// Calculate usage percentages
const editUsagePercent = limitEdit > 0 ? (activeEdit / limitEdit) * 100 : 0;
const viewUsagePercent = limitView > 0 ? (activeView / limitView) * 100 : 0;
// Determine colors based on usage for progress bars
const getUsageColor = percent => {
if (percent >= 90) return '#CB0000'; // Critical - Red
if (percent >= 70) return '#FF6F3D'; // Warning - Orange
return '#007B14'; // Normal - Green
};
// Helper to generate progress bar HTML for PDF (using SVG)
const generateProgressBar = (current, limit, percent, color, label) => {
// Calculate actual percentage and ensure it's within bounds
const actualPercent = Math.max(0, Math.min(100, percent));
const filledWidth = actualPercent;
const pdfWidth = 560; // Standard PDF width in pixels
// Create simple rectangular path without rounded corners for PDF
let filledPath = '';
if (filledWidth > 0) {
const filledPx = (pdfWidth * filledWidth) / 100;
// Simple rectangle without rounded corners
filledPath = `<rect x="0" y="0" width="${filledPx}" height="8" fill="${color}"/>`;
}
return `
<div class="progress-bar-container" style="margin-bottom: 24px; margin-top: 0; padding-left: 0 !important; padding-right: 0 !important;">
<table class="usage-label-table" style="width: 100%; border-collapse: collapse; margin: 0 !important; padding: 0 !important; padding-left: 0 !important; padding-right: 0 !important; border-spacing: 0;">
<tr>
<td style="font-weight: 700; font-size: 18px; line-height: 1 !important; color: #444444; padding: 0 !important; padding-left: 0 !important; padding-right: 0 !important; margin: 0 !important; vertical-align: bottom;">${label} Usage</td>
<td style="color: #333333; font-weight: 700; font-size: 20px; line-height: 1 !important; text-align: right; padding: 0 !important; padding-left: 0 !important; padding-right: 0 !important; margin: 0 !important; white-space: nowrap; vertical-align: bottom;">${current} / ${limit}</td>
</tr>
<tr>
<td colspan="2" style="padding: 0 !important; padding-left: 0 !important; padding-right: 0 !important; margin: 0 !important; line-height: 0 !important; font-size: 0 !important;">
<svg width="${pdfWidth}" height="8" viewBox="0 0 ${pdfWidth} 8" style="display: block; margin: 0 !important; padding: 0 !important; vertical-align: top;">
<!-- Empty background (gray) without rounded corners -->
<rect x="0" y="0" width="${pdfWidth}" height="8" fill="#e0e0e0"/>
<!-- Filled portion (colored) -->
${filledPath}
</svg>
</td>
</tr>
</table>
</div>
`;
};
// Helper to generate Active metric row
const generateActiveRow = (count, description, label) => {
return `
<div style="margin-bottom: 24px;">
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="font-weight: 700; font-size: 18px; line-height: 130%; color: #444444; padding: 0; vertical-align: top;">Active</td>
<td style="color: #333333; font-weight: 700; font-size: 20px; line-height: 130%; text-align: right; padding: 0; vertical-align: top;">${count}</td>
</tr>
<tr>
<td style="font-weight: 600; font-size: 14px; line-height: 150%; color: #666666; padding: 0; padding-top: 4px;">${description}</td>
<td style="font-weight: 600; font-size: 14px; line-height: 150%; color: #666666; text-align: right; padding: 0; padding-top: 4px;">${label}</td>
</tr>
</table>
</div>
`;
};
// Helper to generate Remaining metric row
const generateRemainingRow = (count, description, label, color = '#333333') => {
return `
<div class="remaining-row">
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="font-weight: 700; font-size: 18px; line-height: 130%; color: #444444; padding: 0; vertical-align: top;">Remaining</td>
<td style="color: ${color}; font-weight: 700; font-size: 20px; line-height: 130%; text-align: right; padding: 0; vertical-align: top;">${count}</td>
</tr>
<tr>
<td style="font-weight: 600; font-size: 14px; line-height: 150%; color: #666666; padding: 0; padding-top: 4px;">${description}</td>
<td style="font-weight: 600; font-size: 14px; line-height: 150%; color: #666666; text-align: right; padding: 0; padding-top: 4px;">${label}</td>
</tr>
</table>
</div>
`;
};
// Helper to generate card
// Using table structure for better LibreOffice PDF compatibility
const generateCard = (title, description, content, additionalClass = '') => {
const cardClass = additionalClass ? `statistics-card ${additionalClass}` : 'statistics-card';
const paddingBottom = '32px';
return `
<table class="${cardClass}" cellpadding="0" cellspacing="0" style="width: 100%; border-collapse: separate; border-spacing: 0; background-color: #ffffff; border: 1px solid #e0e0e0; border-radius: 8px; margin: 0; overflow: hidden;">
<tr>
<td style="padding: 32px !important; padding-top: 23px !important; padding-right: 32px !important; padding-bottom: ${paddingBottom} !important; padding-left: 32px !important; border: none;">
<h3 class="card-title" style="font-weight: 700; font-size: 22px; line-height: 150%; color: #333333; margin: 0; text-align: left;">${title}</h3>
<p class="card-description" style="font-weight: 600; font-size: 14px; line-height: 150%; color: #666666; margin: 0 0 32px 0; text-align: left;">${description}</p>
${content}
</td>
</tr>
</table>
`;
};
// Generate Editors card
const generateEditorsCard = () => {
// Use the same usage percentage for color calculation (not remaining percentage)
// If usage is high (e.g., 90%), remaining is low (10%), both should be red
const remainingColor = getUsageColor(editUsagePercent);
const content = `
${generateProgressBar(activeEdit, limitEdit, editUsagePercent, getUsageColor(editUsagePercent), 'Editor')}
${generateActiveRow(activeEdit, 'Users currently editing documents', 'Sessions')}
${generateRemainingRow(remainingEdit, 'Editor sessions before limit', 'Available', remainingColor)}
`;
return generateCard('Editors', 'Active editing sessions and availability', content, 'connections-card');
};
// Generate Live Viewer card
const generateLiveViewerCard = () => {
// Use the same usage percentage for color calculation (not remaining percentage)
// If usage is high (e.g., 90%), remaining is low (10%), both should be red
const remainingColor = getUsageColor(viewUsagePercent);
const content = `
${generateProgressBar(activeView, limitView, viewUsagePercent, getUsageColor(viewUsagePercent), 'Viewer')}
${generateActiveRow(activeView, 'Users currently viewing documents', 'Sessions')}
${generateRemainingRow(remainingView, 'Viewer sessions before limit', 'Available', remainingColor)}
`;
return generateCard('Live Viewer', 'Active read-only sessions and availability', content, 'connections-card');
};
// Generate Peak Concurrent Sessions section
const generatePeakSection = () => {
const editorPeaks = [];
const viewerPeaks = [];
const timePeriods = ['hour', 'day', 'week', 'month'];
const timeLabels = ['Last Hour', '24 Hours', 'Week', 'Month'];
timePeriods.forEach((period, index) => {
const item = connectionsStat?.[period];
if (item?.edit) {
editorPeaks[index] = item.edit.max || 0;
}
if (item?.liveview) {
viewerPeaks[index] = item.liveview.max || 0;
}
});
const generatePeakCard = (title, description, values) => {
// Use table layout for PDF compatibility
const content = `
<div style="margin-bottom: 8px;">
<h4 class="time-period-title" style="font-weight: 700; font-size: 18px; line-height: 130%; color: #444444; margin: 0 0 4px 0;">${title}</h4>
<p class="time-period-description" style="font-weight: 600; font-size: 14px; line-height: 150%; color: #666666; margin: 0 0 28px 0;">${description}</p>
<table style="width: 100%; border-collapse: collapse;">
<tr>
${timeLabels
.map(
(label, index) => `
<td style="text-align: right; padding: 0; vertical-align: top; width: 25%;">
<div style="font-size: 18px; font-weight: 600; color: #333333; margin-bottom: 4px;">${values[index] || 0}</div>
<div style="font-size: 12px; color: #666666;">${label}</div>
</td>
`
)
.join('')}
</tr>
</table>
</div>
`;
return content;
};
let content = '';
if (mode === 'all' || mode === 'edit') {
content += generatePeakCard('Editors', 'Active editing sessions and availability', editorPeaks);
}
if (mode === 'all' || mode === 'view') {
if (mode === 'all') {
content += '<div style="margin-top: 32px; margin-bottom: 32px; border-top: 1px solid #e0e0e0;"></div>';
}
content += generatePeakCard('Live Viewer', 'Active read-only sessions and availability', viewerPeaks);
}
return generateCard('Peak Concurrent Sessions', 'Maximum concurrent connections during different time periods', content);
};
// Generate Average Concurrent Sessions section
const generateAverageSection = () => {
const editorAvr = [];
const viewerAvr = [];
const timePeriods = ['hour', 'day', 'week', 'month'];
const timeLabels = ['Last Hour', '24 Hours', 'Week', 'Month'];
timePeriods.forEach((period, index) => {
const item = connectionsStat?.[period];
if (item?.edit) {
editorAvr[index] = item.edit.avr || 0;
}
if (item?.liveview) {
viewerAvr[index] = item.liveview.avr || 0;
}
});
const generateAverageCard = (title, description, values) => {
// Use table layout for PDF compatibility
const content = `
<div style="margin-bottom: 8px;">
<h4 class="time-period-title" style="font-weight: 700; font-size: 18px; line-height: 130%; color: #444444; margin: 0 0 4px 0;">${title}</h4>
<p class="time-period-description" style="font-weight: 600; font-size: 14px; line-height: 150%; color: #666666; margin: 0 0 28px 0;">${description}</p>
<table style="width: 100%; border-collapse: collapse;">
<tr>
${timeLabels
.map(
(label, index) => `
<td style="text-align: right; padding: 0; vertical-align: top; width: 25%;">
<div style="font-size: 18px; font-weight: 600; color: #333333; margin-bottom: 4px;">${values[index] || 0}</div>
<div style="font-size: 12px; color: #666666;">${label}</div>
</td>
`
)
.join('')}
</tr>
</table>
</div>
`;
return content;
};
let content = '';
if (mode === 'all' || mode === 'edit') {
content += generateAverageCard('Editors', 'Active editing sessions and availability', editorAvr);
}
if (mode === 'all' || mode === 'view') {
if (mode === 'all') {
content += '<div style="margin-top: 32px; margin-bottom: 32px; border-top: 1px solid #e0e0e0;"></div>';
}
content += generateAverageCard('Live Viewer', 'Active read-only sessions and availability', viewerAvr);
}
return generateCard('Average Concurrent Sessions', 'Average concurrent connections during different time periods', content);
};
// Build HTML for PDF - everything stacked vertically (one card per row)
let html = '';
if (mode === 'all' || mode === 'edit') {
html += `<div style="margin-bottom: 56px;">${generateEditorsCard()}</div>`;
}
if (mode === 'all' || mode === 'view') {
html += `<div style="margin-bottom: 56px;">${generateLiveViewerCard()}</div>`;
if (mode === 'all') {
html += `<br><br><br><br>`;
}
}
html += `<div style="margin-bottom: 50px;">${generatePeakSection()}</div>`;
if (mode !== 'all') {
html += `<br><br><br><br><br><br><br><br>`;
}
html += `<div style="margin-bottom: 0;">${generateAverageSection()}</div>`;
const fullHtml = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>Statistics Report</title>
<style type="text/css">
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
}
table {
border-collapse: collapse;
}
td {
vertical-align: top;
}
/* Ensure card border radius and border are applied */
.statistics-card {
border-radius: 8px !important;
border: 1px solid #e0e0e0 !important;
overflow: hidden;
}
/* Remove all margins and padding from progress bar container and usage label table for PDF */
.progress-bar-container {
margin-top: 0 !important;
padding-left: 0 !important;
padding-right: 0 !important;
}
.usage-label-table {
margin: 0 !important;
margin-bottom: 0 !important;
margin-top: 0 !important;
padding: 0 !important;
padding-left: 0 !important;
padding-right: 0 !important;
border-spacing: 0 !important;
}
.usage-label-table tr {
margin: 0 !important;
padding: 0 !important;
}
.usage-label-table td {
margin: 0 !important;
padding: 0 !important;
padding-left: 0 !important;
padding-right: 0 !important;
line-height: 1 !important;
}
</style>
</head>
<body>
${html}
</body>
</html>`;
return fullHtml;
}

View File

@ -1,27 +1,21 @@
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 Tabs from '../../components/Tabs/Tabs';
import Note from '../../components/Note/Note';
import styles from './styles.module.css';
import {fetchStatistics, fetchConfiguration} from '../../api';
import ComboBox from '../../components/ComboBox/ComboBox';
import {fetchTenants, fetchStatistics, convertHtmlToPdf} from '../../api';
import PageHeader from '../../components/PageHeader/PageHeader';
import PageDescription from '../../components/PageDescription/PageDescription';
import Button from '../../components/Button/Button';
import {generateStatisticsHtml} from './generateStatisticsHtml';
import StatisticsContent from './StatisticsContent/StatisticsContent';
// 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');
const statisticsTabs = [
{key: 'all', label: 'ALL'},
{key: 'edit', label: 'EDITORS'},
{key: 'view', label: 'LIVE VIEWER'}
];
// ModeSwitcher moved to ./ModeSwitcher (kept behavior, simplified markup/styles)
@ -30,15 +24,27 @@ const getCriticalStatus = (remaining, limit) => (remaining > limit * CRITICAL_TH
* Mirrors branding/info/index.html rendering logic with mode toggling
*/
export default function Statistics() {
const {data, isLoading, error} = useQuery({
queryKey: ['statistics'],
queryFn: fetchStatistics
const [selectedTenant, setSelectedTenant] = useState('');
const {
data: tenantsData,
isLoading: tenantsLoading,
error: tenantsError
} = useQuery({
queryKey: ['tenants'],
queryFn: fetchTenants
});
// Fetch configuration to display DB info
const {data: configData} = useQuery({
queryKey: ['configuration'],
queryFn: fetchConfiguration
useEffect(() => {
if (tenantsData?.baseTenant && !selectedTenant) {
setSelectedTenant(tenantsData.baseTenant);
}
}, [tenantsData, selectedTenant]);
const {data, isLoading, error} = useQuery({
queryKey: ['statistics', selectedTenant],
queryFn: () => fetchStatistics(selectedTenant),
enabled: !!selectedTenant
});
const [mode, setMode] = useState(() => {
@ -58,218 +64,85 @@ export default function Statistics() {
}
}, [mode]);
// Safe defaults to maintain hook order consistency (memoized to avoid dependency changes)
// Check if open source to conditionally render content
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 isOpenSource = licenseInfo.packageType === 0;
const packageTypeLabel = isOpenSource ? '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}
* Handle PDF download
*/
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>
);
const handleDownloadPdf = async () => {
try {
if (!data) return;
const htmlContent = generateStatisticsHtml(data, mode);
const pdfBlob = await convertHtmlToPdf(htmlContent);
// Create download link
const url = window.URL.createObjectURL(pdfBlob);
const link = document.createElement('a');
link.href = url;
link.download = 'statistics.pdf';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (error) {
console.error('Failed to download PDF:', error);
alert('Failed to download PDF: ' + error.message);
}
};
// 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;
// Use React components for browser display instead of HTML string
// generateStatisticsHtml is now only used for PDF generation
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
// Show loading/error states
if (error) {
return <div style={{color: 'red'}}>Error: {error.message}</div>;
}
if (isLoading || !data) {
if (tenantsError) {
return <div style={{color: 'red'}}>Error: {tenantsError.message}</div>;
}
if (isLoading || !data || !tenantsData || tenantsLoading) {
return <div>Please, wait...</div>;
}
// Common header blocks
const headerBlocks = (
<>
<div className={styles.topRow}>
{buildBlock}
{licenseBlock}
{limitsBlock}
</div>
{renderDatabaseBlock(configData?.services?.CoAuthoring?.sql)}
</>
);
// Content based on license type
const statisticsContent = isOpenSource ? (
<div className={styles.noteWrapper}>
<Note type='note'>Connection and unique user statistics are only available in the Enterprise Edition or the Developer Edition.</Note>
</div>
) : (
<>
<ModeSwitcher mode={mode} setMode={setMode} />
{currentTable}
{peaksAverage}
{isUsersModel && <MonthlyStatistics byMonth={quota?.byMonth} mode={mode} />}
</>
);
// Return the statistics page content
return (
<div>
{headerBlocks}
{statisticsContent}
</div>
<>
<PageHeader>Statistics</PageHeader>
<PageDescription>Real-time connection and session metrics</PageDescription>
{isOpenSource && (
<Note type='note'>Connection and unique user statistics are only available in the Enterprise Edition or the Developer Edition.</Note>
)}
{tenantsData && !isOpenSource && (
<>
{tenantsData.tenants.length > 0 && (
<div className={styles.tenantGroup}>
<label htmlFor='tenant-combobox' className={styles.tenantLabel}>
Tenant:
</label>
<ComboBox
id='tenant-combobox'
className={styles.tenantSelect}
value={selectedTenant}
onChange={setSelectedTenant}
options={[tenantsData.baseTenant, ...tenantsData.tenants.filter(t => t !== tenantsData.baseTenant)].map(t => ({
value: t,
label: t
}))}
placeholder='Select tenant'
/>
</div>
)}
<Tabs tabs={statisticsTabs} activeTab={mode} onTabChange={setMode} />
<h2 className={styles.title}>Current connections</h2>
<p className={styles.description}>Real-time active sessions and remaining capacity before limit.</p>
<StatisticsContent data={data} mode={mode} />
<div className={styles.spacer} />
<Button onClick={handleDownloadPdf} disableResult={true} className={styles.buttonNoWidth}>
Download Report
</Button>
</>
)}
</>
);
}

View File

@ -0,0 +1,50 @@
/**
* Mock statistics data for testing
* Use this instead of API data for testing the HTML generation
*/
export const MOCK_STATISTICS_DATA = {
licenseInfo: {
packageType: 1, // 0 = Open Source, 1 = Enterprise, 2 = Developer
buildDate: '2025-08-20T00:00:00Z',
mode: 4,
endDate: '2026-08-20T00:00:00Z',
type: 0,
startDate: '2025-08-01T00:00:00Z',
connections: 100,
connectionsView: 500,
usersCount: 0,
usersViewCount: 0
},
serverInfo: {
buildVersion: '8.2',
buildNumber: '1234',
date: '2025-09-05T12:00:00Z'
},
quota: {
edit: {
connectionsCount: 24
},
view: {
connectionsCount: 496
},
byMonth: []
},
connectionsStat: {
hour: {
edit: {max: 12, avr: 12},
liveview: {max: 23, avr: 23}
},
day: {
edit: {max: 18, avr: 18},
liveview: {max: 42, avr: 42}
},
week: {
edit: {max: 20, avr: 20},
liveview: {max: 49, avr: 49}
},
month: {
edit: {max: 24, avr: 24},
liveview: {max: 56, avr: 56}
}
}
};

View File

@ -10,6 +10,29 @@
gap: 12px;
}
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 32px;
margin-left: 10px;
}
.tenantGroup {
display: flex;
align-items: center;
margin-bottom: 32px;
}
.tenantLabel {
margin-right: 8px;
font-size: 16px;
font-weight: 600;
}
.tenantSelect {
width: 400px;
}
.modeBar {
margin: 8px 0 16px;
@ -26,6 +49,33 @@
font-weight: 700;
}
.noteWrapper {
margin-top: 24px;
.title {
font-weight: 700;
font-size: 22px;
line-height: 150%;
color: #333333;
margin-bottom: 4px;
}
.description {
margin-top: 0;
font-weight: 600;
font-size: 16px;
color: #666666;
line-height: 150%;
margin-bottom: 32px;
}
.buttonNoWidth {
width: auto !important;
min-width: 154px;
}
/* .spacer {
margin-bottom: 56px;
} */
@media (max-width: 1160px) {
.spacer {
margin-bottom: 56px;
}
}

View File

@ -38,6 +38,7 @@ const operationContext = require('../../../Common/sources/operationContext');
const tenantManager = require('../../../Common/sources/tenantManager');
const license = require('../../../Common/sources/license');
const runtimeConfigManager = require('../../../Common/sources/runtimeConfigManager');
const {validateJWT} = require('./middleware/auth');
const express = require('express');
const http = require('http');
@ -134,6 +135,19 @@ app.use('/admin/api/v1', disableCache, adminpanelRouter);
app.get('/admin/api/v1/stat', disableCache, async (req, res) => {
await infoRouter.licenseInfo(req, res);
});
app.get('/admin/api/v1/tenants', disableCache, validateJWT, async (req, res) => {
const ctx = new operationContext.Context();
try {
ctx.initFromRequest(req);
await ctx.initTenantCache();
const tenants = await tenantManager.getAllTenants(ctx);
const baseTenant = tenantManager.getDefautTenant();
res.json({baseTenant, tenants});
} catch (e) {
ctx.logger.error('tenants list error: %s', e.stack);
res.status(500).json({error: 'Internal server error'});
}
});
// Serve AdminPanel client build as static assets under /admin
const clientBuildPath = path.resolve('client/build');

View File

@ -91,6 +91,21 @@ async function licenseInfo(req, res, getConnections = null) {
const ctx = new operationContext.Context();
try {
ctx.initFromRequest(req);
const requestedTenant = req.query && req.query.tenant;
if (requestedTenant) {
const userTenant = req.user && req.user.tenant;
if (userTenant && requestedTenant !== userTenant) {
const defaultTenant = tenantManager.getDefautTenant();
if (userTenant !== defaultTenant) {
if (!res.headersSent) {
res.status(403).json({error: 'Forbidden'});
}
return;
}
}
ctx.setTenant(requestedTenant);
}
await ctx.initTenantCache();
ctx.logger.debug('licenseInfo start');