} 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) => `
+
+ ${values[index] || 0}
+ ${label}
+
+ `
+ )
+ .join('')}
+
+
+
+ `;
+ 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) => `
+
+ ${values[index] || 0}
+ ${label}
+
+ `
+ )
+ .join('')}
+
+
+
+ `;
+ 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 && (
+
+
+ Tenant:
+
+ t !== tenantsData.baseTenant)].map(t => ({
+ value: t,
+ label: t
+ }))}
+ placeholder='Select tenant'
+ />
+
+ )}
+
+ Current connections
+ Real-time active sessions and remaining capacity before limit.
+
+
+
+ Download Report
+
+ >
+ )}
+ >
);
}
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');