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:
Anthony Stirling
2026-01-23 16:26:46 +00:00
committed by GitHub
parent 4d84dcdd42
commit 0436460c03
7 changed files with 209 additions and 112 deletions
@@ -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..."
+11 -10
View File
@@ -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>
);
}
@@ -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>
) : ( ) : (
@@ -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',
@@ -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>
); );
@@ -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>