mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
V2 Show enterprise demo messages for audit and usage (#5226)
## Summary - show demo data for audit and usage sections when enterprise licensing is unavailable - add enterprise-required banner messaging and reuse demo content instead of erroring - update translations for the new enterprise notice ## Testing - Not run (not requested) ------ [Codex Task](https://chatgpt.com/codex/tasks/task_b_693af73ad9248328885eb8bb81ccf51a)
This commit is contained in:
@@ -4337,6 +4337,10 @@ title = "Login Mode Required"
|
|||||||
message = "Login mode must be enabled to modify admin settings. Please set SECURITY_ENABLELOGIN=true in your environment or security.enableLogin: true in settings.yml, then restart the server."
|
message = "Login mode must be enabled to modify admin settings. Please set SECURITY_ENABLELOGIN=true in your environment or security.enableLogin: true in settings.yml, then restart the server."
|
||||||
readOnly = "The settings below show example values for reference. Enable login mode to view and edit actual configuration."
|
readOnly = "The settings below show example values for reference. Enable login mode to view and edit actual configuration."
|
||||||
|
|
||||||
|
[admin.settings.enterpriseRequired]
|
||||||
|
title = "Enterprise License Required"
|
||||||
|
message = "An Enterprise license is required to access {{featureName}}. You are viewing demo data for reference."
|
||||||
|
|
||||||
[admin.settings.restart]
|
[admin.settings.restart]
|
||||||
title = "Restart Required"
|
title = "Restart Required"
|
||||||
message = "Settings have been saved successfully. A server restart is required for the changes to take effect."
|
message = "Settings have been saved successfully. A server restart is required for the changes to take effect."
|
||||||
@@ -6373,6 +6377,7 @@ endpoint = "Endpoint"
|
|||||||
visits = "Visits"
|
visits = "Visits"
|
||||||
percentage = "Percentage"
|
percentage = "Percentage"
|
||||||
noData = "No data available"
|
noData = "No data available"
|
||||||
|
unknownEndpoint = "Unknown endpoint"
|
||||||
|
|
||||||
[backendHealth]
|
[backendHealth]
|
||||||
checking = "Checking backend status..."
|
checking = "Checking backend status..."
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||||
import { alert } from '@app/components/toast';
|
import { alert } from '@app/components/toast';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -14,30 +15,30 @@ export function useLoginRequired() {
|
|||||||
/**
|
/**
|
||||||
* Show alert when user tries to modify settings with login disabled
|
* Show alert when user tries to modify settings with login disabled
|
||||||
*/
|
*/
|
||||||
const showLoginRequiredAlert = () => {
|
const showLoginRequiredAlert = useCallback(() => {
|
||||||
alert({
|
alert({
|
||||||
alertType: 'warning',
|
alertType: 'warning',
|
||||||
title: t('admin.error', 'Error'),
|
title: t('admin.error', 'Error'),
|
||||||
body: t('admin.settings.loginRequired', 'Login mode must be enabled to modify admin settings'),
|
body: t('admin.settings.loginRequired', 'Login mode must be enabled to modify admin settings'),
|
||||||
});
|
});
|
||||||
};
|
}, [t]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate that login is enabled before allowing action
|
* Validate that login is enabled before allowing action
|
||||||
* Returns true if login is enabled, false otherwise (and shows alert)
|
* Returns true if login is enabled, false otherwise (and shows alert)
|
||||||
*/
|
*/
|
||||||
const validateLoginEnabled = (): boolean => {
|
const validateLoginEnabled = useCallback((): boolean => {
|
||||||
if (!loginEnabled) {
|
if (!loginEnabled) {
|
||||||
showLoginRequiredAlert();
|
showLoginRequiredAlert();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
};
|
}, [loginEnabled, showLoginRequiredAlert]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrap an async handler to check login state before executing
|
* Wrap an async handler to check login state before executing
|
||||||
*/
|
*/
|
||||||
const withLoginCheck = <T extends (...args: any[]) => Promise<any>>(
|
const withLoginCheck = useCallback(<T extends (...args: any[]) => Promise<any>>(
|
||||||
handler: T
|
handler: T
|
||||||
): T => {
|
): T => {
|
||||||
return (async (...args: any[]) => {
|
return (async (...args: any[]) => {
|
||||||
@@ -46,12 +47,12 @@ export function useLoginRequired() {
|
|||||||
}
|
}
|
||||||
return handler(...args);
|
return handler(...args);
|
||||||
}) as T;
|
}) as T;
|
||||||
};
|
}, [validateLoginEnabled]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get styles for disabled inputs (cursor not-allowed)
|
* Get styles for disabled inputs (cursor not-allowed)
|
||||||
*/
|
*/
|
||||||
const getDisabledStyles = () => {
|
const getDisabledStyles = useCallback(() => {
|
||||||
if (!loginEnabled) {
|
if (!loginEnabled) {
|
||||||
return {
|
return {
|
||||||
input: { cursor: 'not-allowed' },
|
input: { cursor: 'not-allowed' },
|
||||||
@@ -60,12 +61,12 @@ export function useLoginRequired() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
};
|
}, [loginEnabled]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrap fetch function to skip API call when login disabled
|
* Wrap fetch function to skip API call when login disabled
|
||||||
*/
|
*/
|
||||||
const withLoginCheckForFetch = <T extends (...args: any[]) => Promise<any>>(
|
const withLoginCheckForFetch = useCallback(<T extends (...args: any[]) => Promise<any>>(
|
||||||
fetchHandler: T,
|
fetchHandler: T,
|
||||||
skipWhenDisabled: boolean = true
|
skipWhenDisabled: boolean = true
|
||||||
): T => {
|
): T => {
|
||||||
@@ -76,7 +77,7 @@ export function useLoginRequired() {
|
|||||||
}
|
}
|
||||||
return fetchHandler(...args);
|
return fetchHandler(...args);
|
||||||
}) as T;
|
}) as T;
|
||||||
};
|
}, [loginEnabled]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
loginEnabled,
|
loginEnabled,
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { Alert, Text } from '@mantine/core';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||||
|
|
||||||
|
interface EnterpriseRequiredBannerProps {
|
||||||
|
show: boolean;
|
||||||
|
featureName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Banner that explains enterprise-only features are in demo mode
|
||||||
|
*/
|
||||||
|
export default function EnterpriseRequiredBanner({ show, featureName }: EnterpriseRequiredBannerProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (!show) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
icon={<LocalIcon icon="workspace-premium-rounded" width={20} height={20} />}
|
||||||
|
title={t('admin.settings.enterpriseRequired.title', 'Enterprise License Required')}
|
||||||
|
color="yellow"
|
||||||
|
variant="light"
|
||||||
|
styles={{
|
||||||
|
root: {
|
||||||
|
borderLeft: '4px solid var(--mantine-color-yellow-6)'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="sm">
|
||||||
|
{t(
|
||||||
|
'admin.settings.enterpriseRequired.message',
|
||||||
|
'An Enterprise license is required to access {{featureName}}. You are viewing demo data for reference.',
|
||||||
|
{ featureName }
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
+22
-11
@@ -8,10 +8,16 @@ import AuditEventsTable from '@app/components/shared/config/configSections/audit
|
|||||||
import AuditExportSection from '@app/components/shared/config/configSections/audit/AuditExportSection';
|
import AuditExportSection from '@app/components/shared/config/configSections/audit/AuditExportSection';
|
||||||
import { useLoginRequired } from '@app/hooks/useLoginRequired';
|
import { useLoginRequired } from '@app/hooks/useLoginRequired';
|
||||||
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
|
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
|
||||||
|
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||||
|
import EnterpriseRequiredBanner from '@app/components/shared/config/EnterpriseRequiredBanner';
|
||||||
|
|
||||||
const AdminAuditSection: React.FC = () => {
|
const AdminAuditSection: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { loginEnabled } = useLoginRequired();
|
const { loginEnabled } = useLoginRequired();
|
||||||
|
const { config } = useAppConfig();
|
||||||
|
const licenseType = config?.license ?? 'NORMAL';
|
||||||
|
const hasEnterpriseLicense = licenseType === 'ENTERPRISE';
|
||||||
|
const showDemoData = !loginEnabled || !hasEnterpriseLicense;
|
||||||
const [systemStatus, setSystemStatus] = useState<AuditStatus | null>(null);
|
const [systemStatus, setSystemStatus] = useState<AuditStatus | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -36,10 +42,11 @@ const AdminAuditSection: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loginEnabled) {
|
if (!showDemoData) {
|
||||||
fetchSystemStatus();
|
fetchSystemStatus();
|
||||||
} else {
|
} else {
|
||||||
// Provide example audit system status when login is disabled
|
// Provide example audit system status when running in demo mode
|
||||||
|
setError(null);
|
||||||
setSystemStatus({
|
setSystemStatus({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
level: 'INFO',
|
level: 'INFO',
|
||||||
@@ -48,10 +55,10 @@ const AdminAuditSection: React.FC = () => {
|
|||||||
});
|
});
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [loginEnabled]);
|
}, [loginEnabled, showDemoData]);
|
||||||
|
|
||||||
// Override loading state when login is disabled
|
// Override loading state when showing demo data
|
||||||
const actualLoading = loginEnabled ? loading : false;
|
const actualLoading = showDemoData ? false : loading;
|
||||||
|
|
||||||
if (actualLoading) {
|
if (actualLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -90,32 +97,36 @@ const AdminAuditSection: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<LoginRequiredBanner show={!loginEnabled} />
|
<LoginRequiredBanner show={!loginEnabled} />
|
||||||
|
<EnterpriseRequiredBanner
|
||||||
|
show={!hasEnterpriseLicense}
|
||||||
|
featureName={t('settings.licensingAnalytics.audit', 'Audit')}
|
||||||
|
/>
|
||||||
<AuditSystemStatus status={systemStatus} />
|
<AuditSystemStatus status={systemStatus} />
|
||||||
|
|
||||||
{systemStatus.enabled ? (
|
{systemStatus.enabled ? (
|
||||||
<Tabs defaultValue="dashboard">
|
<Tabs defaultValue="dashboard">
|
||||||
<Tabs.List>
|
<Tabs.List>
|
||||||
<Tabs.Tab value="dashboard" disabled={!loginEnabled}>
|
<Tabs.Tab value="dashboard" disabled={!loginEnabled || !hasEnterpriseLicense}>
|
||||||
{t('audit.tabs.dashboard', 'Dashboard')}
|
{t('audit.tabs.dashboard', 'Dashboard')}
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
<Tabs.Tab value="events" disabled={!loginEnabled}>
|
<Tabs.Tab value="events" disabled={!loginEnabled || !hasEnterpriseLicense}>
|
||||||
{t('audit.tabs.events', 'Audit Events')}
|
{t('audit.tabs.events', 'Audit Events')}
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
<Tabs.Tab value="export" disabled={!loginEnabled}>
|
<Tabs.Tab value="export" disabled={!loginEnabled || !hasEnterpriseLicense}>
|
||||||
{t('audit.tabs.export', 'Export')}
|
{t('audit.tabs.export', 'Export')}
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
<Tabs.Panel value="dashboard" pt="md">
|
<Tabs.Panel value="dashboard" pt="md">
|
||||||
<AuditChartsSection loginEnabled={loginEnabled} />
|
<AuditChartsSection loginEnabled={loginEnabled && hasEnterpriseLicense} />
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
<Tabs.Panel value="events" pt="md">
|
<Tabs.Panel value="events" pt="md">
|
||||||
<AuditEventsTable loginEnabled={loginEnabled} />
|
<AuditEventsTable loginEnabled={loginEnabled && hasEnterpriseLicense} />
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
<Tabs.Panel value="export" pt="md">
|
<Tabs.Panel value="export" pt="md">
|
||||||
<AuditExportSection loginEnabled={loginEnabled} />
|
<AuditExportSection loginEnabled={loginEnabled && hasEnterpriseLicense} />
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
+68
-43
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Stack,
|
Stack,
|
||||||
Group,
|
Group,
|
||||||
@@ -16,41 +16,23 @@ import UsageAnalyticsTable from '@app/components/shared/config/configSections/us
|
|||||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||||
import { useLoginRequired } from '@app/hooks/useLoginRequired';
|
import { useLoginRequired } from '@app/hooks/useLoginRequired';
|
||||||
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
|
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
|
||||||
|
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||||
|
import EnterpriseRequiredBanner from '@app/components/shared/config/EnterpriseRequiredBanner';
|
||||||
|
|
||||||
const AdminUsageSection: React.FC = () => {
|
const AdminUsageSection: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { loginEnabled, validateLoginEnabled } = useLoginRequired();
|
const { loginEnabled } = useLoginRequired();
|
||||||
|
const { config } = useAppConfig();
|
||||||
|
const licenseType = config?.license ?? 'NORMAL';
|
||||||
|
const hasEnterpriseLicense = licenseType === 'ENTERPRISE';
|
||||||
|
const showDemoData = !loginEnabled || !hasEnterpriseLicense;
|
||||||
const [data, setData] = useState<EndpointStatisticsResponse | null>(null);
|
const [data, setData] = useState<EndpointStatisticsResponse | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [displayMode, setDisplayMode] = useState<'top10' | 'top20' | 'all'>('top10');
|
const [displayMode, setDisplayMode] = useState<'top10' | 'top20' | 'all'>('top10');
|
||||||
const [dataType, setDataType] = useState<'all' | 'api' | 'ui'>('all');
|
const [dataType, setDataType] = useState<'all' | 'api' | 'ui'>('all');
|
||||||
|
|
||||||
const fetchData = async () => {
|
const buildDemoUsageData = useCallback((): EndpointStatisticsResponse => {
|
||||||
if (!validateLoginEnabled()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const limit = displayMode === 'all' ? undefined : displayMode === 'top10' ? 10 : 20;
|
|
||||||
const response = await usageAnalyticsService.getEndpointStatistics(limit, dataType);
|
|
||||||
|
|
||||||
setData(response);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Failed to load usage statistics');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (loginEnabled) {
|
|
||||||
fetchData();
|
|
||||||
} else {
|
|
||||||
// Provide example usage analytics data when login is disabled
|
|
||||||
const totalVisits = 15847;
|
const totalVisits = 15847;
|
||||||
const allEndpoints = [
|
const allEndpoints = [
|
||||||
{ endpoint: 'merge-pdfs', visits: 3245, percentage: (3245 / totalVisits) * 100 },
|
{ endpoint: 'merge-pdfs', visits: 3245, percentage: (3245 / totalVisits) * 100 },
|
||||||
@@ -75,7 +57,6 @@ const AdminUsageSection: React.FC = () => {
|
|||||||
{ endpoint: 'compare-pdfs', visits: 42, percentage: (42 / totalVisits) * 100 },
|
{ endpoint: 'compare-pdfs', visits: 42, percentage: (42 / totalVisits) * 100 },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Filter based on display mode
|
|
||||||
let filteredEndpoints = allEndpoints;
|
let filteredEndpoints = allEndpoints;
|
||||||
if (displayMode === 'top10') {
|
if (displayMode === 'top10') {
|
||||||
filteredEndpoints = allEndpoints.slice(0, 10);
|
filteredEndpoints = allEndpoints.slice(0, 10);
|
||||||
@@ -83,19 +64,47 @@ const AdminUsageSection: React.FC = () => {
|
|||||||
filteredEndpoints = allEndpoints.slice(0, 20);
|
filteredEndpoints = allEndpoints.slice(0, 20);
|
||||||
}
|
}
|
||||||
|
|
||||||
setData({
|
return {
|
||||||
totalVisits: totalVisits,
|
totalVisits,
|
||||||
totalEndpoints: filteredEndpoints.length,
|
totalEndpoints: filteredEndpoints.length,
|
||||||
endpoints: filteredEndpoints,
|
endpoints: filteredEndpoints,
|
||||||
});
|
};
|
||||||
|
}, [displayMode]);
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const limit = displayMode === 'all' ? undefined : displayMode === 'top10' ? 10 : 20;
|
||||||
|
const response = await usageAnalyticsService.getEndpointStatistics(limit, dataType);
|
||||||
|
|
||||||
|
setData(response);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load usage statistics');
|
||||||
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [displayMode, dataType, loginEnabled]);
|
}, [dataType, displayMode]);
|
||||||
|
|
||||||
const handleRefresh = () => {
|
useEffect(() => {
|
||||||
if (!validateLoginEnabled()) {
|
if (!showDemoData) {
|
||||||
|
fetchData();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Provide example usage analytics data when running in demo mode
|
||||||
|
setError(null);
|
||||||
|
setData(buildDemoUsageData());
|
||||||
|
setLoading(false);
|
||||||
|
}, [buildDemoUsageData, fetchData, showDemoData]);
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
if (showDemoData) {
|
||||||
|
setData(buildDemoUsageData());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
fetchData();
|
fetchData();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -112,8 +121,8 @@ const AdminUsageSection: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Override loading state when login is disabled
|
// Override loading state when showing demo data
|
||||||
const actualLoading = loginEnabled ? loading : false;
|
const actualLoading = showDemoData ? false : loading;
|
||||||
|
|
||||||
// Early returns for loading/error states
|
// Early returns for loading/error states
|
||||||
if (actualLoading) {
|
if (actualLoading) {
|
||||||
@@ -140,12 +149,24 @@ const AdminUsageSection: React.FC = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const endpoints = data?.endpoints ?? [];
|
const endpoints = (data?.endpoints ?? []).map((endpoint) => ({
|
||||||
const chartData = endpoints.map((e) => ({ label: e.endpoint, value: e.visits }));
|
endpoint: endpoint.endpoint ?? t('usage.table.unknownEndpoint', 'Unknown endpoint'),
|
||||||
|
visits: Number.isFinite(endpoint.visits) ? Math.max(0, endpoint.visits) : 0,
|
||||||
|
percentage: Number.isFinite(endpoint.percentage) ? Math.max(0, endpoint.percentage) : 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const chartData = endpoints.map((e) => ({
|
||||||
|
label: e.endpoint,
|
||||||
|
value: Number.isFinite(e.visits) ? Math.max(0, e.visits) : 0,
|
||||||
|
}));
|
||||||
|
|
||||||
const displayedVisits = endpoints.reduce((sum, e) => sum + e.visits, 0);
|
const displayedVisits = endpoints.reduce((sum, e) => sum + e.visits, 0);
|
||||||
const totalVisits = data?.totalVisits ?? displayedVisits ?? 0;
|
const totalVisits = Number.isFinite(data?.totalVisits)
|
||||||
const totalEndpoints = data?.totalEndpoints ?? endpoints.length ?? 0;
|
? Math.max(0, data?.totalVisits as number)
|
||||||
|
: displayedVisits;
|
||||||
|
const totalEndpoints = Number.isFinite(data?.totalEndpoints)
|
||||||
|
? Math.max(0, data?.totalEndpoints as number)
|
||||||
|
: endpoints.length;
|
||||||
|
|
||||||
const displayedPercentage = totalVisits > 0
|
const displayedPercentage = totalVisits > 0
|
||||||
? ((displayedVisits / (totalVisits || 1)) * 100).toFixed(1)
|
? ((displayedVisits / (totalVisits || 1)) * 100).toFixed(1)
|
||||||
@@ -154,6 +175,10 @@ const AdminUsageSection: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<LoginRequiredBanner show={!loginEnabled} />
|
<LoginRequiredBanner show={!loginEnabled} />
|
||||||
|
<EnterpriseRequiredBanner
|
||||||
|
show={!hasEnterpriseLicense}
|
||||||
|
featureName={t('settings.licensingAnalytics.usageAnalytics', 'Usage Analytics')}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Controls */}
|
{/* Controls */}
|
||||||
<Card padding="lg" radius="md" withBorder>
|
<Card padding="lg" radius="md" withBorder>
|
||||||
@@ -163,7 +188,7 @@ const AdminUsageSection: React.FC = () => {
|
|||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
value={displayMode}
|
value={displayMode}
|
||||||
onChange={(value) => setDisplayMode(value as 'top10' | 'top20' | 'all')}
|
onChange={(value) => setDisplayMode(value as 'top10' | 'top20' | 'all')}
|
||||||
disabled={!loginEnabled}
|
disabled={showDemoData}
|
||||||
data={[
|
data={[
|
||||||
{
|
{
|
||||||
value: 'top10',
|
value: 'top10',
|
||||||
@@ -184,7 +209,7 @@ const AdminUsageSection: React.FC = () => {
|
|||||||
leftSection={<LocalIcon icon="refresh" width="1rem" height="1rem" />}
|
leftSection={<LocalIcon icon="refresh" width="1rem" height="1rem" />}
|
||||||
onClick={handleRefresh}
|
onClick={handleRefresh}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
disabled={!loginEnabled}
|
disabled={showDemoData}
|
||||||
>
|
>
|
||||||
{t('usage.controls.refresh', 'Refresh')}
|
{t('usage.controls.refresh', 'Refresh')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -198,7 +223,7 @@ const AdminUsageSection: React.FC = () => {
|
|||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
value={dataType}
|
value={dataType}
|
||||||
onChange={(value) => setDataType(value as 'all' | 'api' | 'ui')}
|
onChange={(value) => setDataType(value as 'all' | 'api' | 'ui')}
|
||||||
disabled={!loginEnabled}
|
disabled={showDemoData}
|
||||||
data={[
|
data={[
|
||||||
{
|
{
|
||||||
value: 'all',
|
value: 'all',
|
||||||
|
|||||||
+7
-1
@@ -73,13 +73,19 @@ interface UsageAnalyticsChartProps {
|
|||||||
const UsageAnalyticsChart: React.FC<UsageAnalyticsChartProps> = ({ data }) => {
|
const UsageAnalyticsChart: React.FC<UsageAnalyticsChartProps> = ({ data }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const safeMaxValue = Math.max(...data.map((d) => d.value).filter((value) => Number.isFinite(value)), 1);
|
||||||
|
const safeData = data.map((item) => ({
|
||||||
|
label: item.label,
|
||||||
|
value: Number.isFinite(item.value) ? Math.max(0, item.value) : 0,
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card padding="lg" radius="md" withBorder>
|
<Card padding="lg" radius="md" withBorder>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Text size="lg" fw={600}>
|
<Text size="lg" fw={600}>
|
||||||
{t('usage.chart.title', 'Endpoint Usage Chart')}
|
{t('usage.chart.title', 'Endpoint Usage Chart')}
|
||||||
</Text>
|
</Text>
|
||||||
<SimpleBarChart data={data} maxValue={Math.max(...data.map((d) => d.value), 1)} />
|
<SimpleBarChart data={safeData} maxValue={safeMaxValue} />
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
+39
-29
@@ -1,5 +1,15 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Card, Text, Stack, Table } from '@mantine/core';
|
import {
|
||||||
|
Card,
|
||||||
|
Text,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
TableThead,
|
||||||
|
TableTbody,
|
||||||
|
TableTr,
|
||||||
|
TableTh,
|
||||||
|
TableTd,
|
||||||
|
} from '@mantine/core';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { EndpointStatistic } from '@app/services/usageAnalyticsService';
|
import { EndpointStatistic } from '@app/services/usageAnalyticsService';
|
||||||
|
|
||||||
@@ -26,58 +36,58 @@ const UsageAnalyticsTable: React.FC<UsageAnalyticsTableProps> = ({ data }) => {
|
|||||||
'--table-border-color': 'var(--mantine-color-gray-3)',
|
'--table-border-color': 'var(--mantine-color-gray-3)',
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties}
|
||||||
>
|
>
|
||||||
<Table.Thead>
|
<TableThead>
|
||||||
<Table.Tr style={{ backgroundColor: 'var(--mantine-color-gray-0)' }}>
|
<TableTr style={{ backgroundColor: 'var(--mantine-color-gray-0)' }}>
|
||||||
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm" w="5%">
|
<TableTh style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm" w="5%">
|
||||||
#
|
#
|
||||||
</Table.Th>
|
</TableTh>
|
||||||
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm" w="55%">
|
<TableTh style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm" w="55%">
|
||||||
{t('usage.table.endpoint', 'Endpoint')}
|
{t('usage.table.endpoint', 'Endpoint')}
|
||||||
</Table.Th>
|
</TableTh>
|
||||||
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm" w="20%" ta="right">
|
<TableTh style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm" w="20%" ta="right">
|
||||||
{t('usage.table.visits', 'Visits')}
|
{t('usage.table.visits', 'Visits')}
|
||||||
</Table.Th>
|
</TableTh>
|
||||||
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm" w="20%" ta="right">
|
<TableTh style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm" w="20%" ta="right">
|
||||||
{t('usage.table.percentage', 'Percentage')}
|
{t('usage.table.percentage', 'Percentage')}
|
||||||
</Table.Th>
|
</TableTh>
|
||||||
</Table.Tr>
|
</TableTr>
|
||||||
</Table.Thead>
|
</TableThead>
|
||||||
<Table.Tbody>
|
<TableTbody>
|
||||||
{data.length === 0 ? (
|
{data.length === 0 ? (
|
||||||
<Table.Tr>
|
<TableTr>
|
||||||
<Table.Td colSpan={4}>
|
<TableTd colSpan={4}>
|
||||||
<Text ta="center" c="dimmed" py="xl">
|
<Text ta="center" c="dimmed" py="xl">
|
||||||
{t('usage.table.noData', 'No data available')}
|
{t('usage.table.noData', 'No data available')}
|
||||||
</Text>
|
</Text>
|
||||||
</Table.Td>
|
</TableTd>
|
||||||
</Table.Tr>
|
</TableTr>
|
||||||
) : (
|
) : (
|
||||||
data.map((stat, index) => (
|
data.map((stat, index) => (
|
||||||
<Table.Tr key={index}>
|
<TableTr key={index}>
|
||||||
<Table.Td>
|
<TableTd>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{index + 1}
|
{index + 1}
|
||||||
</Text>
|
</Text>
|
||||||
</Table.Td>
|
</TableTd>
|
||||||
<Table.Td>
|
<TableTd>
|
||||||
<Text size="sm" truncate>
|
<Text size="sm" truncate>
|
||||||
{stat.endpoint}
|
{stat.endpoint}
|
||||||
</Text>
|
</Text>
|
||||||
</Table.Td>
|
</TableTd>
|
||||||
<Table.Td ta="right">
|
<TableTd ta="right">
|
||||||
<Text size="sm" fw={600}>
|
<Text size="sm" fw={600}>
|
||||||
{stat.visits.toLocaleString()}
|
{stat.visits.toLocaleString()}
|
||||||
</Text>
|
</Text>
|
||||||
</Table.Td>
|
</TableTd>
|
||||||
<Table.Td ta="right">
|
<TableTd ta="right">
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{stat.percentage.toFixed(2)}%
|
{stat.percentage.toFixed(2)}%
|
||||||
</Text>
|
</Text>
|
||||||
</Table.Td>
|
</TableTd>
|
||||||
</Table.Tr>
|
</TableTr>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</Table.Tbody>
|
</TableTbody>
|
||||||
</Table>
|
</Table>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
Reference in New Issue
Block a user