mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-12-18 20:04:17 +01:00
Merge 7e7bad4f86 into 3529849bca
This commit is contained in:
commit
7151ecf1da
@ -4206,6 +4206,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."
|
||||
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]
|
||||
title = "Restart Required"
|
||||
message = "Settings have been saved successfully. A server restart is required for the changes to take effect."
|
||||
@ -6032,6 +6036,7 @@ endpoint = "Endpoint"
|
||||
visits = "Visits"
|
||||
percentage = "Percentage"
|
||||
noData = "No data available"
|
||||
unknownEndpoint = "Unknown endpoint"
|
||||
|
||||
[backendHealth]
|
||||
checking = "Checking backend status..."
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -8,10 +8,16 @@ import AuditEventsTable from '@app/components/shared/config/configSections/audit
|
||||
import AuditExportSection from '@app/components/shared/config/configSections/audit/AuditExportSection';
|
||||
import { useLoginRequired } from '@app/hooks/useLoginRequired';
|
||||
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 { t } = useTranslation();
|
||||
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 [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@ -36,10 +42,11 @@ const AdminAuditSection: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
if (loginEnabled) {
|
||||
if (!showDemoData) {
|
||||
fetchSystemStatus();
|
||||
} else {
|
||||
// Provide example audit system status when login is disabled
|
||||
// Provide example audit system status when running in demo mode
|
||||
setError(null);
|
||||
setSystemStatus({
|
||||
enabled: true,
|
||||
level: 'INFO',
|
||||
@ -48,10 +55,10 @@ const AdminAuditSection: React.FC = () => {
|
||||
});
|
||||
setLoading(false);
|
||||
}
|
||||
}, [loginEnabled]);
|
||||
}, [loginEnabled, showDemoData]);
|
||||
|
||||
// Override loading state when login is disabled
|
||||
const actualLoading = loginEnabled ? loading : false;
|
||||
// Override loading state when showing demo data
|
||||
const actualLoading = showDemoData ? false : loading;
|
||||
|
||||
if (actualLoading) {
|
||||
return (
|
||||
@ -90,32 +97,36 @@ const AdminAuditSection: React.FC = () => {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<LoginRequiredBanner show={!loginEnabled} />
|
||||
<EnterpriseRequiredBanner
|
||||
show={!hasEnterpriseLicense}
|
||||
featureName={t('settings.licensingAnalytics.audit', 'Audit')}
|
||||
/>
|
||||
<AuditSystemStatus status={systemStatus} />
|
||||
|
||||
{systemStatus.enabled ? (
|
||||
<Tabs defaultValue="dashboard">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="dashboard" disabled={!loginEnabled}>
|
||||
<Tabs.Tab value="dashboard" disabled={!loginEnabled || !hasEnterpriseLicense}>
|
||||
{t('audit.tabs.dashboard', 'Dashboard')}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="events" disabled={!loginEnabled}>
|
||||
<Tabs.Tab value="events" disabled={!loginEnabled || !hasEnterpriseLicense}>
|
||||
{t('audit.tabs.events', 'Audit Events')}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="export" disabled={!loginEnabled}>
|
||||
<Tabs.Tab value="export" disabled={!loginEnabled || !hasEnterpriseLicense}>
|
||||
{t('audit.tabs.export', 'Export')}
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="dashboard" pt="md">
|
||||
<AuditChartsSection loginEnabled={loginEnabled} />
|
||||
<AuditChartsSection loginEnabled={loginEnabled && hasEnterpriseLicense} />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="events" pt="md">
|
||||
<AuditEventsTable loginEnabled={loginEnabled} />
|
||||
<AuditEventsTable loginEnabled={loginEnabled && hasEnterpriseLicense} />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="export" pt="md">
|
||||
<AuditExportSection loginEnabled={loginEnabled} />
|
||||
<AuditExportSection loginEnabled={loginEnabled && hasEnterpriseLicense} />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
) : (
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Stack,
|
||||
Group,
|
||||
@ -16,17 +16,62 @@ import UsageAnalyticsTable from '@app/components/shared/config/configSections/us
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { useLoginRequired } from '@app/hooks/useLoginRequired';
|
||||
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 { t } = useTranslation();
|
||||
const { loginEnabled, validateLoginEnabled } = 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 [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [displayMode, setDisplayMode] = useState<'top10' | 'top20' | 'all'>('top10');
|
||||
const [dataType, setDataType] = useState<'all' | 'api' | 'ui'>('all');
|
||||
|
||||
const fetchData = async () => {
|
||||
const buildDemoUsageData = useCallback((): EndpointStatisticsResponse => {
|
||||
const totalVisits = 15847;
|
||||
const allEndpoints = [
|
||||
{ endpoint: 'merge-pdfs', visits: 3245, percentage: (3245 / totalVisits) * 100 },
|
||||
{ endpoint: 'compress-pdf', visits: 2891, percentage: (2891 / totalVisits) * 100 },
|
||||
{ endpoint: 'pdf-to-img', visits: 2156, percentage: (2156 / totalVisits) * 100 },
|
||||
{ endpoint: 'split-pdf', visits: 1834, percentage: (1834 / totalVisits) * 100 },
|
||||
{ endpoint: 'rotate-pdf', visits: 1523, percentage: (1523 / totalVisits) * 100 },
|
||||
{ endpoint: 'ocr-pdf', visits: 1287, percentage: (1287 / totalVisits) * 100 },
|
||||
{ endpoint: 'add-watermark', visits: 945, percentage: (945 / totalVisits) * 100 },
|
||||
{ endpoint: 'extract-images', visits: 782, percentage: (782 / totalVisits) * 100 },
|
||||
{ endpoint: 'add-password', visits: 621, percentage: (621 / totalVisits) * 100 },
|
||||
{ endpoint: 'html-to-pdf', visits: 563, percentage: (563 / totalVisits) * 100 },
|
||||
{ endpoint: 'remove-password', visits: 487, percentage: (487 / totalVisits) * 100 },
|
||||
{ endpoint: 'pdf-to-pdfa', visits: 423, percentage: (423 / totalVisits) * 100 },
|
||||
{ endpoint: 'extract-pdf-metadata', visits: 356, percentage: (356 / totalVisits) * 100 },
|
||||
{ endpoint: 'add-page-numbers', visits: 298, percentage: (298 / totalVisits) * 100 },
|
||||
{ endpoint: 'crop', visits: 245, percentage: (245 / totalVisits) * 100 },
|
||||
{ endpoint: 'flatten', visits: 187, percentage: (187 / totalVisits) * 100 },
|
||||
{ endpoint: 'sanitize-pdf', visits: 134, percentage: (134 / totalVisits) * 100 },
|
||||
{ endpoint: 'auto-split-pdf', visits: 98, percentage: (98 / totalVisits) * 100 },
|
||||
{ endpoint: 'scale-pages', visits: 76, percentage: (76 / totalVisits) * 100 },
|
||||
{ endpoint: 'compare-pdfs', visits: 42, percentage: (42 / totalVisits) * 100 },
|
||||
];
|
||||
|
||||
let filteredEndpoints = allEndpoints;
|
||||
if (displayMode === 'top10') {
|
||||
filteredEndpoints = allEndpoints.slice(0, 10);
|
||||
} else if (displayMode === 'top20') {
|
||||
filteredEndpoints = allEndpoints.slice(0, 20);
|
||||
}
|
||||
|
||||
return {
|
||||
totalVisits,
|
||||
totalEndpoints: filteredEndpoints.length,
|
||||
endpoints: filteredEndpoints,
|
||||
};
|
||||
}, [displayMode]);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
if (!validateLoginEnabled()) {
|
||||
return;
|
||||
}
|
||||
@ -44,58 +89,29 @@ const AdminUsageSection: React.FC = () => {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [dataType, displayMode, validateLoginEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loginEnabled) {
|
||||
if (!showDemoData) {
|
||||
fetchData();
|
||||
} else {
|
||||
// Provide example usage analytics data when login is disabled
|
||||
const totalVisits = 15847;
|
||||
const allEndpoints = [
|
||||
{ endpoint: 'merge-pdfs', visits: 3245, percentage: (3245 / totalVisits) * 100 },
|
||||
{ endpoint: 'compress-pdf', visits: 2891, percentage: (2891 / totalVisits) * 100 },
|
||||
{ endpoint: 'pdf-to-img', visits: 2156, percentage: (2156 / totalVisits) * 100 },
|
||||
{ endpoint: 'split-pdf', visits: 1834, percentage: (1834 / totalVisits) * 100 },
|
||||
{ endpoint: 'rotate-pdf', visits: 1523, percentage: (1523 / totalVisits) * 100 },
|
||||
{ endpoint: 'ocr-pdf', visits: 1287, percentage: (1287 / totalVisits) * 100 },
|
||||
{ endpoint: 'add-watermark', visits: 945, percentage: (945 / totalVisits) * 100 },
|
||||
{ endpoint: 'extract-images', visits: 782, percentage: (782 / totalVisits) * 100 },
|
||||
{ endpoint: 'add-password', visits: 621, percentage: (621 / totalVisits) * 100 },
|
||||
{ endpoint: 'html-to-pdf', visits: 563, percentage: (563 / totalVisits) * 100 },
|
||||
{ endpoint: 'remove-password', visits: 487, percentage: (487 / totalVisits) * 100 },
|
||||
{ endpoint: 'pdf-to-pdfa', visits: 423, percentage: (423 / totalVisits) * 100 },
|
||||
{ endpoint: 'extract-pdf-metadata', visits: 356, percentage: (356 / totalVisits) * 100 },
|
||||
{ endpoint: 'add-page-numbers', visits: 298, percentage: (298 / totalVisits) * 100 },
|
||||
{ endpoint: 'crop', visits: 245, percentage: (245 / totalVisits) * 100 },
|
||||
{ endpoint: 'flatten', visits: 187, percentage: (187 / totalVisits) * 100 },
|
||||
{ endpoint: 'sanitize-pdf', visits: 134, percentage: (134 / totalVisits) * 100 },
|
||||
{ endpoint: 'auto-split-pdf', visits: 98, percentage: (98 / totalVisits) * 100 },
|
||||
{ endpoint: 'scale-pages', visits: 76, percentage: (76 / totalVisits) * 100 },
|
||||
{ endpoint: 'compare-pdfs', visits: 42, percentage: (42 / totalVisits) * 100 },
|
||||
];
|
||||
|
||||
// Filter based on display mode
|
||||
let filteredEndpoints = allEndpoints;
|
||||
if (displayMode === 'top10') {
|
||||
filteredEndpoints = allEndpoints.slice(0, 10);
|
||||
} else if (displayMode === 'top20') {
|
||||
filteredEndpoints = allEndpoints.slice(0, 20);
|
||||
}
|
||||
|
||||
setData({
|
||||
totalVisits: totalVisits,
|
||||
totalEndpoints: filteredEndpoints.length,
|
||||
endpoints: filteredEndpoints,
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
}, [displayMode, dataType, loginEnabled]);
|
||||
|
||||
// Provide example usage analytics data when running in demo mode
|
||||
setError(null);
|
||||
setData(buildDemoUsageData());
|
||||
setLoading(false);
|
||||
}, [buildDemoUsageData, fetchData, showDemoData]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
if (!validateLoginEnabled()) {
|
||||
return;
|
||||
}
|
||||
if (showDemoData) {
|
||||
setData(buildDemoUsageData());
|
||||
return;
|
||||
}
|
||||
|
||||
fetchData();
|
||||
};
|
||||
|
||||
@ -112,8 +128,8 @@ const AdminUsageSection: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Override loading state when login is disabled
|
||||
const actualLoading = loginEnabled ? loading : false;
|
||||
// Override loading state when showing demo data
|
||||
const actualLoading = showDemoData ? false : loading;
|
||||
|
||||
// Early returns for loading/error states
|
||||
if (actualLoading) {
|
||||
@ -140,12 +156,24 @@ const AdminUsageSection: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const endpoints = data?.endpoints ?? [];
|
||||
const chartData = endpoints.map((e) => ({ label: e.endpoint, value: e.visits }));
|
||||
const endpoints = (data?.endpoints ?? []).map((endpoint) => ({
|
||||
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 totalVisits = data?.totalVisits ?? displayedVisits ?? 0;
|
||||
const totalEndpoints = data?.totalEndpoints ?? endpoints.length ?? 0;
|
||||
const totalVisits = Number.isFinite(data?.totalVisits)
|
||||
? 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
|
||||
? ((displayedVisits / (totalVisits || 1)) * 100).toFixed(1)
|
||||
@ -154,6 +182,10 @@ const AdminUsageSection: React.FC = () => {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<LoginRequiredBanner show={!loginEnabled} />
|
||||
<EnterpriseRequiredBanner
|
||||
show={!hasEnterpriseLicense}
|
||||
featureName={t('settings.licensingAnalytics.usageAnalytics', 'Usage Analytics')}
|
||||
/>
|
||||
|
||||
{/* Controls */}
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
@ -163,7 +195,7 @@ const AdminUsageSection: React.FC = () => {
|
||||
<SegmentedControl
|
||||
value={displayMode}
|
||||
onChange={(value) => setDisplayMode(value as 'top10' | 'top20' | 'all')}
|
||||
disabled={!loginEnabled}
|
||||
disabled={showDemoData}
|
||||
data={[
|
||||
{
|
||||
value: 'top10',
|
||||
@ -184,7 +216,7 @@ const AdminUsageSection: React.FC = () => {
|
||||
leftSection={<LocalIcon icon="refresh" width="1rem" height="1rem" />}
|
||||
onClick={handleRefresh}
|
||||
loading={loading}
|
||||
disabled={!loginEnabled}
|
||||
disabled={showDemoData}
|
||||
>
|
||||
{t('usage.controls.refresh', 'Refresh')}
|
||||
</Button>
|
||||
@ -198,7 +230,7 @@ const AdminUsageSection: React.FC = () => {
|
||||
<SegmentedControl
|
||||
value={dataType}
|
||||
onChange={(value) => setDataType(value as 'all' | 'api' | 'ui')}
|
||||
disabled={!loginEnabled}
|
||||
disabled={showDemoData}
|
||||
data={[
|
||||
{
|
||||
value: 'all',
|
||||
|
||||
@ -73,13 +73,19 @@ interface UsageAnalyticsChartProps {
|
||||
const UsageAnalyticsChart: React.FC<UsageAnalyticsChartProps> = ({ data }) => {
|
||||
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 (
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<Stack gap="md">
|
||||
<Text size="lg" fw={600}>
|
||||
{t('usage.chart.title', 'Endpoint Usage Chart')}
|
||||
</Text>
|
||||
<SimpleBarChart data={data} maxValue={Math.max(...data.map((d) => d.value), 1)} />
|
||||
<SimpleBarChart data={safeData} maxValue={safeMaxValue} />
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user