mirror of
https://github.com/ONLYOFFICE/server.git
synced 2026-02-10 18:05:07 +08:00
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:
@ -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 />} />
|
||||
))}
|
||||
|
||||
@ -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();
|
||||
};
|
||||
|
||||
4
AdminPanel/client/src/assets/Tip.svg
Normal file
4
AdminPanel/client/src/assets/Tip.svg
Normal 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 |
141
AdminPanel/client/src/components/ComboBox/ComboBox.js
Normal file
141
AdminPanel/client/src/components/ComboBox/ComboBox.js
Normal 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;
|
||||
103
AdminPanel/client/src/components/ComboBox/ComboBox.module.scss
Normal file
103
AdminPanel/client/src/components/ComboBox/ComboBox.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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},
|
||||
|
||||
167
AdminPanel/client/src/pages/Dashboard/Dashboard.js
Normal file
167
AdminPanel/client/src/pages/Dashboard/Dashboard.js
Normal 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;
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
337
AdminPanel/client/src/pages/Statistics/generateStatisticsHtml.js
Normal file
337
AdminPanel/client/src/pages/Statistics/generateStatisticsHtml.js
Normal 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;
|
||||
}
|
||||
@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
50
AdminPanel/client/src/pages/Statistics/mockStatisticsData.js
Normal file
50
AdminPanel/client/src/pages/Statistics/mockStatisticsData.js
Normal 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}
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user