Security fixes, enterprise stuff and more (#3241)

# Description of Changes

Please provide a summary of the changes, including:

- Enable user to add custom JAVA ops with env JAVA_CUSTOM_OPTS
- Added support for prometheus (enabled via JAVA_CUSTOM_OPTS +
enterprise license)
- Changed settings from enterprise naming to 'Premium'
- KeygenLicense Check to support offline licenses
- Disable URL-to-PDF due to huge security bug
- Remove loud Split PDF logs
- addUsers renamed to adminSettings
- Added Usage analytics page
- Add user button to only be enabled based on total users free
- Improve Merge memory usage


Closes #(issue_number)

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: a <a>
Co-authored-by: pixeebot[bot] <104101892+pixeebot[bot]@users.noreply.github.com>
Co-authored-by: Connor Yoh <[email protected]>
This commit is contained in:
Anthony Stirling
2025-03-25 17:57:17 +00:00
committed by GitHub
co-authored by a pixeebot[bot] Connor Yoh
parent 86becc61de
commit e151286337
35 changed files with 1603 additions and 267 deletions
+1 -1
View File
@@ -40,4 +40,4 @@ springdoc.api-docs.path=/v1/api-docs
# Set the URL of the OpenAPI JSON for the Swagger UI
springdoc.swagger-ui.url=/v1/api-docs
posthog.api.key=phc_fiR65u5j6qmXTYL56MNrLZSWqLaDW74OrZH0Insd2xq
posthog.host=https://eu.i.posthog.com
posthog.host=https://eu.i.posthog.com
@@ -231,7 +231,31 @@ adminUserSettings.activeUsers=Active Users:
adminUserSettings.disabledUsers=Disabled Users:
adminUserSettings.totalUsers=Total Users:
adminUserSettings.lastRequest=Last Request
adminUserSettings.usage=View Usage
endpointStatistics.title=Endpoint Statistics
endpointStatistics.header=Endpoint Statistics
endpointStatistics.top10=Top 10
endpointStatistics.top20=Top 20
endpointStatistics.all=All
endpointStatistics.refresh=Refresh
endpointStatistics.includeHomepage=Include Homepage ('/')
endpointStatistics.includeLoginPage=Include Login Page ('/login')
endpointStatistics.totalEndpoints=Total Endpoints
endpointStatistics.totalVisits=Total Visits
endpointStatistics.showing=Showing
endpointStatistics.selectedVisits=Selected Visits
endpointStatistics.endpoint=Endpoint
endpointStatistics.visits=Visits
endpointStatistics.percentage=Percentage
endpointStatistics.loading=Loading...
endpointStatistics.failedToLoad=Failed to load endpoint data. Please try refreshing.
endpointStatistics.home=Home
endpointStatistics.login=Login
endpointStatistics.top=Top
endpointStatistics.numberOfVisits=Number of Visits
endpointStatistics.visitsTooltip=Visits: {0} ({1}% of total)
endpointStatistics.retry=Retry
database.title=Database Import/Export
database.header=Database Import/Export
+11 -8
View File
@@ -61,15 +61,17 @@ security:
privateKey: classpath:saml-private-key.key # Your private key. Generated from your keypair
spCert: classpath:saml-public-cert.crt # Your signing certificate. Generated from your keypair
enterpriseEdition:
enabled: false # set to 'true' to enable enterprise edition
premium:
key: 00000000-0000-0000-0000-000000000000
SSOAutoLogin: false # Enable to auto login to first provided SSO
CustomMetadata:
autoUpdateMetadata: false # set to 'true' to automatically update metadata with below values
author: username # supports text such as 'John Doe' or types such as username to autopopulate with user's username
creator: Stirling-PDF # supports text such as 'Company-PDF'
producer: Stirling-PDF # supports text such as 'Company-PDF'
enabled: false # Enable license key checks for pro/enterprise features
proFeatures:
SSOAutoLogin: false
CustomMetadata:
autoUpdateMetadata: false
author: username
creator: Stirling-PDF
producer: Stirling-PDF
legal:
termsAndConditions: https://www.stirlingpdf.com/terms-and-conditions # URL to the terms and conditions of your application (e.g. https://example.com/terms). Empty string to disable or filename to load from local file in static folder
@@ -87,6 +89,7 @@ system:
customHTMLFiles: false # enable to have files placed in /customFiles/templates override the existing template HTML files
tessdataDir: /usr/share/tessdata # path to the directory containing the Tessdata files. This setting is relevant for Windows systems. For Windows users, this path should be adjusted to point to the appropriate directory where the Tessdata files are stored.
enableAnalytics: null # set to 'true' to enable analytics, set to 'false' to disable analytics; for enterprise users, this is set to true
enableUrlToPDF: false # Set to 'true' to enable URL to PDF, INTERNAL ONLY, known security issues, should not be used externally
disableSanitize: false # set to true to disable Sanitize HTML; (can lead to injections in HTML)
datasource:
enableCustomDatabase: false # Enterprise users ONLY, set this property to 'true' if you would like to use your own custom database configuration
+83
View File
@@ -0,0 +1,83 @@
.active-user {
color: green;
text-shadow: 0 0 5px green;
}
.text-overflow {
max-width: 100px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chart-container {
position: relative;
height: 400px;
width: 100%;
margin-bottom: 20px;
}
.stats-box {
background: var(--md-sys-color-outline-variant);
padding: .8rem;
margin: 10px 0;
border-radius: 2rem;
text-align: center;
}
.chart-controls {
display: flex;
justify-content: center;
gap: 10px;
margin-bottom: 15px;
flex-wrap: wrap;
}
.filter-controls {
display: flex;
justify-content: center;
gap: 15px;
margin-top: 15px;
flex-wrap: wrap;
}
.filter-checkbox {
display: flex;
align-items: center;
cursor: pointer;
user-select: none;
}
.filter-checkbox input {
margin-right: 5px;
}
.loading {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
html[data-bs-theme="light"] .loading {
background-color: rgba(255, 255, 255, 0.7);
}
html[data-bs-theme="dark"] .loading {
background-color: rgba(15, 20, 26, 0.7);
}
/* Add some animation to the refresh button */
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.refreshing .material-symbols-rounded {
animation: spin 1s linear infinite;
}
+363
View File
@@ -0,0 +1,363 @@
// We'll fetch data from the API instead of hardcoding it
let allEndpointData = [];
let filteredData = [];
// We'll store these as global variables that get updated when we fetch data
let sortedData = [];
let totalEndpoints = 0;
let totalVisits = 0;
// Chart instance
let myChart;
// Function to get chart colors based on current theme
function getChartColors() {
var style = window.getComputedStyle(document.body)
const colours = {
textColor: style.getPropertyValue('--md-sys-color-on-surface') ,
primaryColor: style.getPropertyValue('--md-sys-color-primary'),
backgroundColor: style.getPropertyValue('--md-sys-color-background'),
gridColor: style.getPropertyValue('--md-sys-color-on-surface'),
tooltipBgColor: style.getPropertyValue('--md-sys-color-inverse-on-surface'),
tooltipTextColor: style.getPropertyValue('--md-sys-color-inverse-surface')
}
return colours;
}
// Watch for theme changes and update chart if needed
function setupThemeChangeListener() {
// Start observing theme changes
document.addEventListener("modeChanged", (event) => {
setTimeout(function() {
if (myChart) {
const currentLimit = document.getElementById('currentlyShowing').textContent;
const limit = (currentLimit === endpointStatsTranslations.all)
? filteredData.length
: (currentLimit === endpointStatsTranslations.top20 ? 20 : 10);
updateChart(limit);
}
}, 100);
});
// Also watch for system preference changes
window
.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', () => {
if (myChart) {
const currentLimit = document.getElementById('currentlyShowing').textContent;
const limit = (currentLimit === endpointStatsTranslations.all)
? filteredData.length
: (currentLimit === endpointStatsTranslations.top20 ? 20 : 10);
updateChart(limit);
}
});
}
// Function to filter data based on checkbox settings
function filterData() {
const includeHome = document.getElementById('hideHomeCheckbox').checked;
const includeLogin = document.getElementById('hideLoginCheckbox').checked;
filteredData = allEndpointData.filter(item => {
if (!includeHome && item.endpoint === '/') return false;
if (!includeLogin && item.endpoint === '/login') return false;
return true;
});
// Sort and calculate
sortedData = [...filteredData].sort((a, b) => b.count - a.count);
totalEndpoints = filteredData.length;
totalVisits = filteredData.reduce((sum, item) => sum + item.count, 0);
// Update stats
document.getElementById('totalEndpoints').textContent = totalEndpoints.toLocaleString();
document.getElementById('totalVisits').textContent = totalVisits.toLocaleString();
// Update the chart with current limit
const currentLimit = document.getElementById('currentlyShowing').textContent;
const limit = (currentLimit === endpointStatsTranslations.all)
? filteredData.length
: (currentLimit === endpointStatsTranslations.top20 ? 20 : 10);
updateChart(limit);
}
// Function to fetch data from the API
async function fetchEndpointData() {
try {
// Show loading state
const chartContainer = document.querySelector('.chart-container');
const loadingDiv = document.createElement('div');
loadingDiv.className = 'loading';
loadingDiv.innerHTML = `
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">${endpointStatsTranslations.loading}</span>
</div>`;
chartContainer.appendChild(loadingDiv);
// Also add animation to refresh button
const refreshBtn = document.getElementById('refreshBtn');
refreshBtn.classList.add('refreshing');
refreshBtn.disabled = true;
const response = await fetch('/api/v1/info/load/all');
if (!response.ok) {
throw new Error('Network response was not ok');
}
const data = await response.json();
allEndpointData = data;
// Apply filters
filterData();
// Remove loading state
chartContainer.removeChild(loadingDiv);
refreshBtn.classList.remove('refreshing');
refreshBtn.disabled = false;
} catch (error) {
console.error('Error fetching endpoint data:', error);
// Show error message to user
showError(endpointStatsTranslations.failedToLoad);
// Reset refresh button
const refreshBtn = document.getElementById('refreshBtn');
refreshBtn.classList.remove('refreshing');
refreshBtn.disabled = false;
}
}
// Function to format endpoint names
function formatEndpointName(endpoint) {
if (endpoint === '/') return endpointStatsTranslations.home;
if (endpoint === '/login') return endpointStatsTranslations.login;
return endpoint.replace('/', '').replace(/-/g, ' ');
}
// Function to update the table
function updateTable(data) {
const tableBody = document.getElementById('endpointTableBody');
tableBody.innerHTML = '';
data.forEach((item, index) => {
const percentage = ((item.count / totalVisits) * 100).toFixed(2);
const row = document.createElement('tr');
// Format endpoint for better readability
let displayEndpoint = item.endpoint;
if (displayEndpoint.length > 40) {
displayEndpoint = displayEndpoint.substring(0, 37) + '...';
}
row.innerHTML = `
<td>${index + 1}</td>
<td title="${item.endpoint}">${displayEndpoint}</td>
<td>${item.count.toLocaleString()}</td>
<td>${percentage}%</td>
`;
tableBody.appendChild(row);
});
}
// Function to update the chart
function updateChart(dataLimit) {
const chartData = sortedData.slice(0, dataLimit);
// Calculate displayed statistics
const displayedVisits = chartData.reduce((sum, item) => sum + item.count, 0);
const displayedPercentage = totalVisits > 0
? ((displayedVisits / totalVisits) * 100).toFixed(2)
: '0';
document.getElementById('displayedVisits').textContent = displayedVisits.toLocaleString();
document.getElementById('displayedPercentage').textContent = displayedPercentage;
// If the limit equals the total filtered items, show "All"; otherwise "Top X"
document.getElementById('currentlyShowing').textContent =
(dataLimit === filteredData.length)
? endpointStatsTranslations.all
: endpointStatsTranslations.top + dataLimit;
// Update the table with new data
updateTable(chartData);
// Prepare labels and datasets
const labels = chartData.map(item => formatEndpointName(item.endpoint));
const data = chartData.map(item => item.count);
// Get theme-specific colors
const colors = getChartColors();
// Destroy previous chart if it exists
if (myChart) {
myChart.destroy();
}
// Create chart context
const ctx = document.getElementById('endpointChart').getContext('2d');
// Create new chart with theme-appropriate colors
myChart = new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: endpointStatsTranslations.numberOfVisits,
data: data,
backgroundColor: colors.primaryColor.replace('rgb', 'rgba').replace(')', ', 0.6)'),
borderColor: colors.primaryColor,
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
indexAxis: dataLimit > 20 ? 'x' : 'y',
plugins: {
legend: {
display: true,
position: 'top',
labels: {
color: colors.primaryColor,
font: {
weight: 'bold'
}
}
},
tooltip: {
backgroundColor: colors.tooltipBgColor,
titleColor: colors.tooltipTextColor,
bodyColor: colors.tooltipTextColor,
borderColor: colors.tooltipBgColor,
borderWidth: 1,
padding: 12,
cornerRadius: 8,
titleFont: {
size: 14,
weight: 'bold'
},
bodyFont: {
size: 13
},
callbacks: {
label: (context) => {
const value = context.raw;
const percentage = totalVisits > 0
? ((value / totalVisits) * 100).toFixed(2)
: '0';
// Insert your i18n text in the final string:
// e.g. "Visits: 12 (34% of total)"
// If your translation includes placeholders, you'd parse them here:
return endpointStatsTranslations.visitsTooltip
.replace('{0}', value.toLocaleString())
.replace('{1}', percentage);
}
}
}
},
scales: {
x: {
border: {
color: colors.gridColor
},
ticks: {
color: colors.gridColor,
font: {
size: 12
},
callback: function(value, index, values) {
let label = this.getLabelForValue(value);
return label.length > 15 ? label.substr(0, 15) + '...' : label;
}
},
grid: {
color: `${colors.gridColor}`
}
},
y: {
border: {
color: colors.gridColor
},
min: 0,
ticks: {
color: colors.gridColor,
font: {
size: 12
},
precision: 0
},
grid: {
color: `${colors.gridColor}`
}
}
}
}
});
}
// Initialize with fetch and top 10
document.addEventListener('DOMContentLoaded', function() {
// Set up theme change listener
setupThemeChangeListener();
// Initial data fetch
fetchEndpointData();
// Set up button event listeners
document.getElementById('top10Btn').addEventListener('click', function() {
updateChart(10);
setActiveButton(this);
});
document.getElementById('top20Btn').addEventListener('click', function() {
updateChart(20);
setActiveButton(this);
});
document.getElementById('allBtn').addEventListener('click', function() {
updateChart(filteredData.length);
setActiveButton(this);
});
document.getElementById('refreshBtn').addEventListener('click', function() {
fetchEndpointData();
});
// Set up filter checkbox listeners
document.getElementById('hideHomeCheckbox').addEventListener('change', filterData);
document.getElementById('hideLoginCheckbox').addEventListener('change', filterData);
});
function setActiveButton(activeButton) {
// Remove active class from all buttons
document.querySelectorAll('.chart-controls button').forEach(button => {
button.classList.remove('active');
});
// Add active class to clicked button
activeButton.classList.add('active');
}
// Function to handle errors in a user-friendly way
function showError(message) {
const chartContainer = document.querySelector('.chart-container');
const errorDiv = document.createElement('div');
errorDiv.className = 'alert alert-danger';
errorDiv.innerHTML = `
<span class="material-symbols-rounded" style="vertical-align: bottom; margin-right: 5px;">error</span>
${message}
<button id="errorRetryBtn" class="btn btn-outline-danger btn-sm" style="margin-left: 10px;">
<span class="material-symbols-rounded" style="font-size: 1rem; vertical-align: bottom;">refresh</span>
${endpointStatsTranslations.retry}
</button>
`;
chartContainer.innerHTML = '';
chartContainer.appendChild(errorDiv);
// Add retry button functionality
document.getElementById('errorRetryBtn').addEventListener('click', fetchEndpointData);
}
+1 -1
View File
@@ -349,7 +349,7 @@
</script>
<div class="mb-3 mt-4 text-center">
<a th:href="@{'/logout'}" role="button" class="btn btn-danger" th:text="#{account.signOut}">Sign Out</a>
<a th:if="${role == 'ROLE_ADMIN'}" class="btn btn-info" th:href="@{'/addUsers'}" role="button" th:text="#{account.adminSettings}" target="_blank">Admin Settings</a>
<a th:if="${role == 'ROLE_ADMIN'}" class="btn btn-info" th:href="@{'/adminSettings'}" role="button" th:text="#{account.adminSettings}" target="_blank">Admin Settings</a>
</div>
</div>
</div>
@@ -32,21 +32,46 @@
</div>
<!-- User Settings Title -->
<div style="background: var(--md-sys-color-outline-variant);padding: .8rem; margin: 10px 0; border-radius: 2rem; text-align: center;">
<a href="#" data-bs-toggle="modal" data-bs-target="#addUserModal" class="btn btn-outline-success" th:title="#{adminUserSettings.addUser}">
<span class="material-symbols-rounded">person_add</span>
<span th:text="#{adminUserSettings.addUser}">Add New User</span>
</a>
<a href="#" data-bs-toggle="modal" data-bs-target="#changeUserRoleModal" class="btn btn-outline-success" th:title="#{adminUserSettings.changeUserRole}">
<span class="material-symbols-rounded">edit</span>
<span th:text="#{adminUserSettings.changeUserRole}">Change User's Role</span>
</a>
<div class="my-4">
<strong th:text="#{adminUserSettings.totalUsers}">Total Users:</strong> <span th:text="${totalUsers}"></span>
<strong style="margin-left: 20px;" th:text="#{adminUserSettings.activeUsers}">Active Users:</strong> <span th:text="${activeUsers}"></span>
<strong style="margin-left: 20px;" th:text="#{adminUserSettings.disabledUsers}">Disabled Users:</strong> <span th:text="${disabledUsers}"></span>
</div>
</div>
<div style="background: var(--md-sys-color-outline-variant);padding: .8rem; margin: 10px 0; border-radius: 2rem; text-align: center;">
<a href="#"
th:data-bs-toggle="${@runningEE && totalUsers >= maxEnterpriseUsers} ? null : 'modal'"
th:data-bs-target="${@runningEE && totalUsers >= maxEnterpriseUsers} ? null : '#addUserModal'"
th:class="${@runningEE && totalUsers >= maxEnterpriseUsers} ? 'btn btn-danger' : 'btn btn-outline-success'"
th:title="${@runningEE && totalUsers >= maxEnterpriseUsers} ? #{adminUserSettings.maxUsersReached} : #{adminUserSettings.addUser}">
<span class="material-symbols-rounded">person_add</span>
<span th:text="#{adminUserSettings.addUser}">Add New User</span>
</a>
<a href="#"
data-bs-toggle="modal"
data-bs-target="#changeUserRoleModal"
class="btn btn-outline-success"
th:title="#{adminUserSettings.changeUserRole}">
<span class="material-symbols-rounded">edit</span>
<span th:text="#{adminUserSettings.changeUserRole}">Change User's Role</span>
</a>
<a href="/usage"
class="btn btn-outline-success"
th:title="#{adminUserSettings.usage}">
<span class="material-symbols-rounded">analytics</span>
<span th:text="#{adminUserSettings.usage}">Usage Statistics</span>
</a>
<div class="my-4">
<strong style="margin-left: 20px;" th:text="#{adminUserSettings.totalUsers}">Total Users:</strong>
<span th:text="${totalUsers}"></span>
<span th:if="${@runningEE}" th:text="'/'+${maxEnterpriseUsers}"></span>
<strong style="margin-left: 20px;" th:text="#{adminUserSettings.activeUsers}">Active Users:</strong>
<span th:text="${activeUsers}"></span>
<strong style="margin-left: 20px;" th:text="#{adminUserSettings.disabledUsers}">Disabled Users:</strong>
<span th:text="${disabledUsers}"></span>
</div>
</div>
<div th:if="${addMessage}" class="p-3" style="background: var(--md-sys-color-outline-variant);border-radius: 2rem; text-align: center;">
<div class="alert alert-danger mb-auto">
<span th:text="#{${addMessage}}">Default message if not found</span>
@@ -101,7 +126,7 @@
</tbody>
</table>
</div>
<p th:text="#{enterpriseEdition.ssoAdvert}"></p>
<p th:if="${!@runningEE}" th:text="#{enterpriseEdition.ssoAdvert}"></p>
<script th:inline="javascript">
const delete_confirm_text = /*[[#{adminUserSettings.confirmDeleteUser}]]*/ 'Should the user be deleted?';
+110
View File
@@ -0,0 +1,110 @@
<!DOCTYPE html>
<html th:lang="${#locale.language}" th:dir="#{language.direction}" th:data-language="${#locale.toString()}" xmlns:th="https://www.thymeleaf.org">
<head>
<th:block th:insert="~{fragments/common :: head(title=#{endpointStatistics.title}, header=#{endpointStatistics.header})}"></th:block>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
<link rel="stylesheet" th:href="@{'/css/usage.css'}">
</head>
<body>
<th:block th:insert="~{fragments/common :: game}"></th:block>
<div id="page-container">
<div id="content-wrap">
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
<br><br>
<div class="container">
<div class="row justify-content-center">
<div class="col-md-9 bg-card">
<div class="tool-header">
<span class="material-symbols-rounded tool-header-icon">analytics</span>
<span class="tool-header-text" th:text="#{endpointStatistics.header}">Endpoint Statistics</span>
</div>
<!-- Statistics Summary Box -->
<div class="stats-box">
<div class="chart-controls">
<button id="top10Btn" class="btn btn-outline-primary active">
<span class="material-symbols-rounded">bar_chart</span>
<span th:text="#{endpointStatistics.top10}">Top 10</span>
</button>
<button id="top20Btn" class="btn btn-outline-primary">
<span class="material-symbols-rounded">data_usage</span>
<span th:text="#{endpointStatistics.top20}">Top 20</span>
</button>
<button id="allBtn" class="btn btn-outline-primary">
<span class="material-symbols-rounded">insights</span>
<span th:text="#{endpointStatistics.all}">All</span>
</button>
<button id="refreshBtn" class="btn btn-outline-secondary">
<span class="material-symbols-rounded">refresh</span>
<span th:text="#{endpointStatistics.refresh}">Refresh</span>
</button>
</div>
<div class="filter-controls">
<label class="filter-checkbox">
<input type="checkbox" id="hideHomeCheckbox" checked>
<span th:text="#{endpointStatistics.includeHomepage}">Include Homepage ('/')</span>
</label>
<label class="filter-checkbox">
<input type="checkbox" id="hideLoginCheckbox" checked>
<span th:text="#{endpointStatistics.includeLoginPage}">Include Login Page ('/login')</span>
</label>
</div>
<div class="my-4" style="color: var(--md-sys-color-on-surface); font-weight: 500;">
<span style="margin: 0 10px;"><strong th:text="#{endpointStatistics.totalEndpoints} + ':'">Total Endpoints:</strong> <span id="totalEndpoints">0</span></span>
<span style="margin: 0 10px;"><strong th:text="#{endpointStatistics.totalVisits} + ':'">Total Visits:</strong> <span id="totalVisits">0</span></span>
<span style="margin: 0 10px;"><strong th:text="#{endpointStatistics.showing} + ':'">Showing:</strong> <span id="currentlyShowing">Top 10</span></span>
<span style="margin: 0 10px;"><strong th:text="#{endpointStatistics.selectedVisits} + ':'">Selected Visits:</strong> <span id="displayedVisits">0</span> (<span id="displayedPercentage">0</span>%)</span>
</div>
</div>
<!-- Chart Container -->
<div class="bg-card mt-3 mb-3">
<div class="chart-container">
<canvas id="endpointChart"></canvas>
</div>
</div>
<!-- Table for detailed data -->
<div class="bg-card mt-3 mb-3">
<table class="endpoint-table">
<thead>
<tr>
<th scope="col" style="width: 5%;">#</th>
<th scope="col" style="width: 55%;" th:text="#{endpointStatistics.endpoint}">Endpoint</th>
<th scope="col" style="width: 20%;" th:text="#{endpointStatistics.visits}">Visits</th>
<th scope="col" style="width: 20%;" th:text="#{endpointStatistics.percentage}">Percentage</th>
</tr>
</thead>
<tbody id="endpointTableBody">
<!-- Table rows will be dynamically generated -->
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<script type="module" th:src="@{'/js/usage.js'}"></script>
<script th:inline="javascript">
const endpointStatsTranslations = {
all: /*[[#{endpointStatistics.all}]]*/ 'All',
top20: /*[[#{endpointStatistics.top20}]]*/ 'Top 20',
loading: /*[[#{endpointStatistics.loading}]]*/ 'Loading...',
failedToLoad: /*[[#{endpointStatistics.failedToLoad}]]*/ 'Failed to load endpoint data. Please try refreshing.',
home: /*[[#{endpointStatistics.home}]]*/ 'Home',
login: /*[[#{endpointStatistics.login}]]*/ 'Login',
top: /*[[#{endpointStatistics.top}]]*/ 'Top ',
numberOfVisits: /*[[#{endpointStatistics.numberOfVisits}]]*/ 'Number of Visits',
visitsTooltip: /*[[#{endpointStatistics.visitsTooltip}]]*/ 'Visits: {0} ({1}% of total)',
retry: /*[[#{endpointStatistics.retry}]]*/ 'Retry'
};
</script>
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
</div>
</body>
</html>