diff --git a/AdminPanel/client/src/App.js b/AdminPanel/client/src/App.js index 99702661..12a1329c 100644 --- a/AdminPanel/client/src/App.js +++ b/AdminPanel/client/src/App.js @@ -27,8 +27,8 @@ function AppContent() { - } /> - } /> + } /> + } /> {menuItems.map(item => ( } /> ))} diff --git a/AdminPanel/client/src/api/index.js b/AdminPanel/client/src/api/index.js index c78def23..248efd9f 100644 --- a/AdminPanel/client/src/api/index.js +++ b/AdminPanel/client/src/api/index.js @@ -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} 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(); +}; diff --git a/AdminPanel/client/src/assets/Tip.svg b/AdminPanel/client/src/assets/Tip.svg new file mode 100644 index 00000000..a3a172b0 --- /dev/null +++ b/AdminPanel/client/src/assets/Tip.svg @@ -0,0 +1,4 @@ + + + + diff --git a/AdminPanel/client/src/components/ComboBox/ComboBox.js b/AdminPanel/client/src/components/ComboBox/ComboBox.js new file mode 100644 index 00000000..2ad25e28 --- /dev/null +++ b/AdminPanel/client/src/components/ComboBox/ComboBox.js @@ -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 ( +
+
+ setQuery(e.target.value)} + onFocus={() => !isOpen && openDropdown()} + readOnly={disabled} + /> +
+ + + +
+
+ + {isOpen && ( +
+ {filteredOptions.length === 0 &&
No results
} + {filteredOptions.map((option, index) => { + const isSelected = option.value === value; + const isActive = index === activeIndex; + return ( +
e.preventDefault()} + onClick={() => handleSelect(option)} + onMouseEnter={() => setActiveIndex(index)} + > + {option.label} +
+ ); + })} +
+ )} +
+ ); +} + +export default ComboBox; diff --git a/AdminPanel/client/src/components/ComboBox/ComboBox.module.scss b/AdminPanel/client/src/components/ComboBox/ComboBox.module.scss new file mode 100644 index 00000000..82c412ca --- /dev/null +++ b/AdminPanel/client/src/components/ComboBox/ComboBox.module.scss @@ -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; + } +} diff --git a/AdminPanel/client/src/config/menuItems.js b/AdminPanel/client/src/config/menuItems.js index 20ebf235..a8763f84 100644 --- a/AdminPanel/client/src/config/menuItems.js +++ b/AdminPanel/client/src/config/menuItems.js @@ -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}, diff --git a/AdminPanel/client/src/pages/Dashboard/Dashboard.js b/AdminPanel/client/src/pages/Dashboard/Dashboard.js new file mode 100644 index 00000000..e00666d1 --- /dev/null +++ b/AdminPanel/client/src/pages/Dashboard/Dashboard.js @@ -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 = ( + +
Type: {packageTypeLabel}
+
+ Version: {serverInfo.buildVersion}.{serverInfo.buildNumber} +
+
Release date: {buildDate}
+
+ ); + + // License block (mirrors fillInfo license validity rendering) + const licenseBlock = (() => { + if (licenseInfo.endDate === null) { + return ( + +
No license
+
+ ); + } + 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 ( + + {startDateStr &&
Start date: {startDateStr}
} +
+ {licValidText} + {licEnd.toLocaleDateString()} +
+ {trialText &&
{trialText}
} +
+ ); + })(); + + // Limits block + const limitTitle = isUsersModel ? 'Users limit' : 'Connections limit'; + const limitsBlock = ( + +
Editors: {limitEdit}
+
Live Viewer: {limitView}
+
+ ); + + /** + * Render database info block + * @param {object|null} sql - services.CoAuthoring.sql config + * @returns {JSX.Element|null} + */ + const renderDatabaseBlock = sql => { + if (!sql) return null; + return ( + +
Type: {sql.type}
+
Host: {sql.dbHost}
+
Port: {sql.dbPort}
+
Name: {sql.dbName}
+
+ ); + }; + + // Show loading/error states + if (error) { + return ( +
+ Dashboard + Overview of your DocServer Admin Panel +
Error: {error.message}
+
+ ); + } + if (tenantsError) { + return ( +
+ Dashboard + Overview of your DocServer Admin Panel +
Error: {tenantsError.message}
+
+ ); + } + + if (isLoading || !data || !tenantsData || tenantsLoading) { + return ( +
+ Dashboard + Overview of your DocServer Admin Panel +
Please, wait...
+
+ ); + } + + return ( +
+ Dashboard + Overview of your DocServer Admin Panel +
+ {buildBlock} + {licenseBlock} + {limitsBlock} +
+ {renderDatabaseBlock(configData?.services?.CoAuthoring?.sql)} +
+ ); +} + +export default Dashboard; diff --git a/AdminPanel/client/src/pages/Statistics/TopBlock/index.js b/AdminPanel/client/src/pages/Dashboard/TopBlock/index.js similarity index 100% rename from AdminPanel/client/src/pages/Statistics/TopBlock/index.js rename to AdminPanel/client/src/pages/Dashboard/TopBlock/index.js diff --git a/AdminPanel/client/src/pages/Statistics/TopBlock/styles.module.css b/AdminPanel/client/src/pages/Dashboard/TopBlock/styles.module.css similarity index 100% rename from AdminPanel/client/src/pages/Statistics/TopBlock/styles.module.css rename to AdminPanel/client/src/pages/Dashboard/TopBlock/styles.module.css diff --git a/AdminPanel/client/src/pages/Statistics/ConnectionsCard/ConnectionsCard.js b/AdminPanel/client/src/pages/Statistics/ConnectionsCard/ConnectionsCard.js new file mode 100644 index 00000000..caedbede --- /dev/null +++ b/AdminPanel/client/src/pages/Statistics/ConnectionsCard/ConnectionsCard.js @@ -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 ( + + + + + + ); +} diff --git a/AdminPanel/client/src/pages/Statistics/InfoTable/index.js b/AdminPanel/client/src/pages/Statistics/InfoTable/index.js deleted file mode 100644 index 9137b602..00000000 --- a/AdminPanel/client/src/pages/Statistics/InfoTable/index.js +++ /dev/null @@ -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 ( -
- {caption &&
{caption}
} - - {mode !== 'view' && ( - <> -
EDITORS
-
-
- {[0, 1, 2, 3].map(i => ( -
- {editor[i] && editor[i][0] !== undefined ? editor[i][0] : ''} -
- ))} -
-
- {[0, 1, 2, 3].map(i => ( -
- {desc[i] || ''} -
- ))} -
- - )} - - {mode !== 'edit' && ( - <> -
LIVE VIEWER
-
-
- {[0, 1, 2, 3].map(i => ( -
- {viewer[i] && viewer[i][0] !== undefined ? viewer[i][0] : ''} -
- ))} -
-
- {[0, 1, 2, 3].map(i => ( -
- {desc[i] || ''} -
- ))} -
- - )} -
- ); -} diff --git a/AdminPanel/client/src/pages/Statistics/InfoTable/styles.module.css b/AdminPanel/client/src/pages/Statistics/InfoTable/styles.module.css deleted file mode 100644 index d187698d..00000000 --- a/AdminPanel/client/src/pages/Statistics/InfoTable/styles.module.css +++ /dev/null @@ -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; -} diff --git a/AdminPanel/client/src/pages/Statistics/MetricRow/MetricRow.js b/AdminPanel/client/src/pages/Statistics/MetricRow/MetricRow.js new file mode 100644 index 00000000..fffafe67 --- /dev/null +++ b/AdminPanel/client/src/pages/Statistics/MetricRow/MetricRow.js @@ -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 ( +
+ + + + + + + + + + + +
{title} + {count} +
{description}{label}
+
+ ); +} diff --git a/AdminPanel/client/src/pages/Statistics/MetricRow/MetricRow.module.css b/AdminPanel/client/src/pages/Statistics/MetricRow/MetricRow.module.css new file mode 100644 index 00000000..eb2b781a --- /dev/null +++ b/AdminPanel/client/src/pages/Statistics/MetricRow/MetricRow.module.css @@ -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; +} diff --git a/AdminPanel/client/src/pages/Statistics/ModeSwitcher.js b/AdminPanel/client/src/pages/Statistics/ModeSwitcher.js deleted file mode 100644 index de78f3df..00000000 --- a/AdminPanel/client/src/pages/Statistics/ModeSwitcher.js +++ /dev/null @@ -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 ( -
- setMode('all')}> - All - - | - setMode('edit')}> - Editors - - | - setMode('view')}> - Live Viewer - -
- ); -} diff --git a/AdminPanel/client/src/pages/Statistics/MonthlyStatistics.js b/AdminPanel/client/src/pages/Statistics/MonthlyStatistics.js deleted file mode 100644 index d77fab01..00000000 --- a/AdminPanel/client/src/pages/Statistics/MonthlyStatistics.js +++ /dev/null @@ -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} 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, 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 ( - <> -
Usage statistics for the reporting period
- {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 ; - })} - - ); -} - -export default memo(MonthlyStatistics); diff --git a/AdminPanel/client/src/pages/Statistics/ProgressBar/ProgressBar.js b/AdminPanel/client/src/pages/Statistics/ProgressBar/ProgressBar.js new file mode 100644 index 00000000..d826ebae --- /dev/null +++ b/AdminPanel/client/src/pages/Statistics/ProgressBar/ProgressBar.js @@ -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 ( +
+ + + + + + + +
+ {label} Usage + {isCritical && ( + + + + + + + )} + + {current} / {limit} +
+
+
+
+
+ ); +} diff --git a/AdminPanel/client/src/pages/Statistics/ProgressBar/ProgressBar.module.css b/AdminPanel/client/src/pages/Statistics/ProgressBar/ProgressBar.module.css new file mode 100644 index 00000000..aaf134ed --- /dev/null +++ b/AdminPanel/client/src/pages/Statistics/ProgressBar/ProgressBar.module.css @@ -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; +} diff --git a/AdminPanel/client/src/pages/Statistics/Statistics.module.scss b/AdminPanel/client/src/pages/Statistics/Statistics.module.scss deleted file mode 100644 index 49e6994d..00000000 --- a/AdminPanel/client/src/pages/Statistics/Statistics.module.scss +++ /dev/null @@ -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; -} diff --git a/AdminPanel/client/src/pages/Statistics/StatisticsCard/StatisticsCard.js b/AdminPanel/client/src/pages/Statistics/StatisticsCard/StatisticsCard.js new file mode 100644 index 00000000..9ab1d4a0 --- /dev/null +++ b/AdminPanel/client/src/pages/Statistics/StatisticsCard/StatisticsCard.js @@ -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 ( +
+

{title}

+

{description}

+ {children} +
+ ); +} diff --git a/AdminPanel/client/src/pages/Statistics/StatisticsCard/StatisticsCard.module.css b/AdminPanel/client/src/pages/Statistics/StatisticsCard/StatisticsCard.module.css new file mode 100644 index 00000000..caa5f626 --- /dev/null +++ b/AdminPanel/client/src/pages/Statistics/StatisticsCard/StatisticsCard.module.css @@ -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; +} diff --git a/AdminPanel/client/src/pages/Statistics/StatisticsContent/StatisticsContent.js b/AdminPanel/client/src/pages/Statistics/StatisticsContent/StatisticsContent.js new file mode 100644 index 00000000..6f678fae --- /dev/null +++ b/AdminPanel/client/src/pages/Statistics/StatisticsContent/StatisticsContent.js @@ -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 ( +
+ {/* Connections Cards Row */} + {(mode === 'all' || mode === 'edit' || mode === 'view') && ( +
+ {(mode === 'all' || mode === 'edit') && ( +
+ +
+ )} + {(mode === 'all' || mode === 'view') && ( +
+ +
+ )} +
+ )} + + {/* Peak and Average Row */} +
+
+ +
+
+ +
+
+
+ ); +} diff --git a/AdminPanel/client/src/pages/Statistics/StatisticsContent/StatisticsContent.module.css b/AdminPanel/client/src/pages/Statistics/StatisticsContent/StatisticsContent.module.css new file mode 100644 index 00000000..3cc275e4 --- /dev/null +++ b/AdminPanel/client/src/pages/Statistics/StatisticsContent/StatisticsContent.module.css @@ -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; + } +} diff --git a/AdminPanel/client/src/pages/Statistics/TimePeriodSection/TimePeriodSection.js b/AdminPanel/client/src/pages/Statistics/TimePeriodSection/TimePeriodSection.js new file mode 100644 index 00000000..57c64809 --- /dev/null +++ b/AdminPanel/client/src/pages/Statistics/TimePeriodSection/TimePeriodSection.js @@ -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} props.editorValues - Array of 4 values for editors + * @param {Array} 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 ( +
+

{cardTitle}

+

{cardDescription}

+
+ {TIME_LABELS.map((label, index) => ( +
+
{values[index] || 0}
+
{label}
+
+ ))} +
+
+ ); + }; + + const content = []; + if (mode === 'all' || mode === 'edit') { + content.push(
{renderTimePeriodCard('Editors', 'Active editing sessions and availability', editorValues)}
); + } + if (mode === 'all' || mode === 'view') { + if (mode === 'all') { + content.push(
); + } + content.push(
{renderTimePeriodCard('Live Viewer', 'Active read-only sessions and availability', viewerValues)}
); + } + + return ( + + {content} + + ); +} diff --git a/AdminPanel/client/src/pages/Statistics/TimePeriodSection/TimePeriodSection.module.css b/AdminPanel/client/src/pages/Statistics/TimePeriodSection/TimePeriodSection.module.css new file mode 100644 index 00000000..5329fb5b --- /dev/null +++ b/AdminPanel/client/src/pages/Statistics/TimePeriodSection/TimePeriodSection.module.css @@ -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; + } +} diff --git a/AdminPanel/client/src/pages/Statistics/generateStatisticsHtml.js b/AdminPanel/client/src/pages/Statistics/generateStatisticsHtml.js new file mode 100644 index 00000000..16ca46ea --- /dev/null +++ b/AdminPanel/client/src/pages/Statistics/generateStatisticsHtml.js @@ -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 = ``; + } + + return ` +
+ + + + + + + + +
${label} Usage${current} / ${limit}
+ + + + + ${filledPath} + +
+
+ `; + }; + + // Helper to generate Active metric row + const generateActiveRow = (count, description, label) => { + return ` +
+ + + + + + + + + +
Active${count}
${description}${label}
+
+ `; + }; + + // Helper to generate Remaining metric row + const generateRemainingRow = (count, description, label, color = '#333333') => { + return ` +
+ + + + + + + + + +
Remaining${count}
${description}${label}
+
+ `; + }; + + // 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 ` + + + + +
+

${title}

+

${description}

+ ${content} +
+ `; + }; + + // 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 = ` +
+

${title}

+

${description}

+ + + ${timeLabels + .map( + (label, index) => ` + + ` + ) + .join('')} + +
+
${values[index] || 0}
+
${label}
+
+
+ `; + 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 += '
'; + } + 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 = ` +
+

${title}

+

${description}

+ + + ${timeLabels + .map( + (label, index) => ` + + ` + ) + .join('')} + +
+
${values[index] || 0}
+
${label}
+
+
+ `; + 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 += '
'; + } + 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 += `
${generateEditorsCard()}
`; + } + if (mode === 'all' || mode === 'view') { + html += `
${generateLiveViewerCard()}
`; + if (mode === 'all') { + html += `



`; + } + } + html += `
${generatePeakSection()}
`; + if (mode !== 'all') { + html += `







`; + } + html += `
${generateAverageSection()}
`; + + const fullHtml = ` + + + + + Statistics Report + + + + ${html} + +`; + + return fullHtml; +} diff --git a/AdminPanel/client/src/pages/Statistics/index.js b/AdminPanel/client/src/pages/Statistics/index.js index ba78cb76..84b5b22e 100644 --- a/AdminPanel/client/src/pages/Statistics/index.js +++ b/AdminPanel/client/src/pages/Statistics/index.js @@ -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 = ( - -
Type: {packageTypeLabel}
-
- Version: {serverInfo.buildVersion}.{serverInfo.buildNumber} -
-
Release date: {buildDate}
-
- ); - - // License block (mirrors fillInfo license validity rendering) - const licenseBlock = (() => { - if (licenseInfo.endDate === null) { - return ( - -
No license
-
- ); - } - 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 ( - - {startDateStr &&
Start date: {startDateStr}
} -
- {licValidText} - {licEnd.toLocaleDateString()} -
- {trialText &&
{trialText}
} -
- ); - })(); - - // Limits block - const limitTitle = isUsersModel ? 'Users limit' : 'Connections limit'; - const limitsBlock = ( - -
Editors: {limitEdit}
-
Live Viewer: {limitView}
-
- ); - /** - * 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 ( - -
Type: {sql.type}
-
Host: {sql.dbHost}
-
Port: {sql.dbPort}
-
Name: {sql.dbName}
-
- ); + 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 ( - 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 ; - }, [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 ( - <> - - - - ); - }, [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
Error: {error.message}
; } - if (isLoading || !data) { + if (tenantsError) { + return
Error: {tenantsError.message}
; + } + if (isLoading || !data || !tenantsData || tenantsLoading) { return
Please, wait...
; } - // Common header blocks - const headerBlocks = ( - <> -
- {buildBlock} - {licenseBlock} - {limitsBlock} -
- - {renderDatabaseBlock(configData?.services?.CoAuthoring?.sql)} - - ); - - // Content based on license type - const statisticsContent = isOpenSource ? ( -
- Connection and unique user statistics are only available in the Enterprise Edition or the Developer Edition. -
- ) : ( - <> - - - {currentTable} - {peaksAverage} - {isUsersModel && } - - ); - + // Return the statistics page content return ( -
- {headerBlocks} - {statisticsContent} -
+ <> + Statistics + Real-time connection and session metrics + {isOpenSource && ( + Connection and unique user statistics are only available in the Enterprise Edition or the Developer Edition. + )} + {tenantsData && !isOpenSource && ( + <> + {tenantsData.tenants.length > 0 && ( +
+ + t !== tenantsData.baseTenant)].map(t => ({ + value: t, + label: t + }))} + placeholder='Select tenant' + /> +
+ )} + +

Current connections

+

Real-time active sessions and remaining capacity before limit.

+ +
+ + + )} + ); } diff --git a/AdminPanel/client/src/pages/Statistics/mockStatisticsData.js b/AdminPanel/client/src/pages/Statistics/mockStatisticsData.js new file mode 100644 index 00000000..0ae227f9 --- /dev/null +++ b/AdminPanel/client/src/pages/Statistics/mockStatisticsData.js @@ -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} + } + } +}; diff --git a/AdminPanel/client/src/pages/Statistics/styles.module.css b/AdminPanel/client/src/pages/Statistics/styles.module.css index c0c5ca13..4a51f53e 100644 --- a/AdminPanel/client/src/pages/Statistics/styles.module.css +++ b/AdminPanel/client/src/pages/Statistics/styles.module.css @@ -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; + } } diff --git a/AdminPanel/server/sources/server.js b/AdminPanel/server/sources/server.js index b4c59437..2117f072 100644 --- a/AdminPanel/server/sources/server.js +++ b/AdminPanel/server/sources/server.js @@ -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'); diff --git a/DocService/sources/routes/info.js b/DocService/sources/routes/info.js index faa9b67f..278865ed 100644 --- a/DocService/sources/routes/info.js +++ b/DocService/sources/routes/info.js @@ -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');