1274 lines
35 KiB
HTML
1274 lines
35 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>Checkpoint Stats</title>
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
<link
|
|
rel="stylesheet"
|
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap"
|
|
/>
|
|
<style>
|
|
:root {
|
|
--primary-color: #4361ee;
|
|
--primary-light: rgba(67, 97, 238, 0.1);
|
|
--success-color: #2ecc71;
|
|
--success-light: rgba(46, 204, 113, 0.1);
|
|
--danger-color: #e74c3c;
|
|
--danger-light: rgba(231, 76, 60, 0.1);
|
|
--border-color: #e9ecef;
|
|
--card-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
|
--graph-bg: #fff;
|
|
--divider-color: #e9ecef;
|
|
--text-color: #495057;
|
|
--header-color: #212529;
|
|
--muted-color: #6c757d;
|
|
--hover-bg: #f8f9fa;
|
|
}
|
|
|
|
body {
|
|
background: #f8f9fa;
|
|
color: var(--text-color);
|
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,
|
|
Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
|
margin: 0;
|
|
padding: 0;
|
|
line-height: 1.5;
|
|
font-size: 15px;
|
|
}
|
|
|
|
.container {
|
|
max-width: 1200px;
|
|
margin: 0 auto;
|
|
padding: 20px;
|
|
}
|
|
|
|
h1,
|
|
h2,
|
|
h3 {
|
|
text-align: center;
|
|
color: var(--header-color);
|
|
font-weight: 600;
|
|
margin-top: 0;
|
|
}
|
|
|
|
h1 {
|
|
font-size: 1.8rem;
|
|
margin-bottom: 20px;
|
|
padding-bottom: 15px;
|
|
position: relative;
|
|
}
|
|
|
|
h1::after {
|
|
content: '';
|
|
display: block;
|
|
width: 80px;
|
|
height: 3px;
|
|
background: var(--primary-color);
|
|
margin: 15px auto 0;
|
|
border-radius: 2px;
|
|
}
|
|
|
|
.controls-wrapper {
|
|
background: white;
|
|
border-radius: 8px;
|
|
box-shadow: var(--card-shadow);
|
|
padding: 15px 20px;
|
|
margin: 0 0 25px;
|
|
border: 1px solid var(--border-color);
|
|
}
|
|
|
|
.controls {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
gap: 15px;
|
|
}
|
|
|
|
.controls-left {
|
|
display: flex;
|
|
align-items: center;
|
|
flex: 1;
|
|
}
|
|
|
|
.stats-summary {
|
|
display: flex;
|
|
gap: 15px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.stat-item {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
background: white;
|
|
padding: 8px 15px;
|
|
border-radius: 6px;
|
|
min-width: 90px;
|
|
flex: 1;
|
|
text-align: center;
|
|
}
|
|
|
|
.stat-label {
|
|
font-size: 0.8rem;
|
|
color: var(--muted-color);
|
|
margin-bottom: 3px;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.stat-value {
|
|
font-size: 1.2rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.stat-hits .stat-value {
|
|
color: var(--primary-color);
|
|
}
|
|
|
|
.stat-passes .stat-value {
|
|
color: var(--success-color);
|
|
}
|
|
|
|
.stat-failures .stat-value {
|
|
color: var(--danger-color);
|
|
}
|
|
|
|
.controls-right {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 15px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.controls label {
|
|
font-weight: 500;
|
|
color: var(--header-color);
|
|
font-size: 0.9rem;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.controls select {
|
|
padding: 8px 12px;
|
|
font-size: 0.9rem;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 6px;
|
|
background-color: white;
|
|
cursor: pointer;
|
|
font-family: inherit;
|
|
color: var(--text-color);
|
|
min-width: 120px;
|
|
appearance: none;
|
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236c757d' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
|
|
background-repeat: no-repeat;
|
|
background-position: right 10px center;
|
|
padding-right: 30px;
|
|
}
|
|
|
|
.controls select:hover {
|
|
border-color: var(--primary-color);
|
|
}
|
|
|
|
.controls select:focus {
|
|
outline: none;
|
|
border-color: var(--primary-color);
|
|
box-shadow: 0 0 0 3px var(--primary-light);
|
|
}
|
|
|
|
.refresh-button {
|
|
background: var(--primary-color);
|
|
color: white;
|
|
border: none;
|
|
border-radius: 6px;
|
|
padding: 8px 15px;
|
|
font-size: 0.9rem;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 5px;
|
|
transition: background 0.2s;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.refresh-button:hover {
|
|
background: #3a56de;
|
|
}
|
|
|
|
.refresh-button svg {
|
|
width: 16px;
|
|
height: 16px;
|
|
}
|
|
|
|
.section {
|
|
margin-bottom: 40px;
|
|
}
|
|
|
|
.section-header {
|
|
display: flex;
|
|
align-items: center;
|
|
margin-bottom: 15px;
|
|
}
|
|
|
|
.section-title {
|
|
font-size: 1.2rem;
|
|
font-weight: 600;
|
|
color: var(--header-color);
|
|
margin: 0;
|
|
flex: 1;
|
|
}
|
|
|
|
.divider {
|
|
text-align: center;
|
|
position: relative;
|
|
margin: 40px 0;
|
|
font-size: 1rem;
|
|
color: var(--muted-color);
|
|
}
|
|
|
|
.divider::before,
|
|
.divider::after {
|
|
content: '';
|
|
display: inline-block;
|
|
width: 30%;
|
|
height: 1px;
|
|
background: var(--divider-color);
|
|
vertical-align: middle;
|
|
margin: 0 15px;
|
|
}
|
|
|
|
.grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
|
gap: 20px;
|
|
margin-bottom: 30px;
|
|
}
|
|
|
|
.card {
|
|
background: white;
|
|
border-radius: 8px;
|
|
box-shadow: var(--card-shadow);
|
|
overflow: hidden;
|
|
position: relative;
|
|
border: 1px solid var(--border-color);
|
|
transition: transform 0.2s, box-shadow 0.2s;
|
|
}
|
|
|
|
.card:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
|
}
|
|
|
|
.card-header {
|
|
padding: 15px;
|
|
font-weight: 600;
|
|
font-size: 1rem;
|
|
text-align: center;
|
|
color: var(--header-color);
|
|
background: #fff;
|
|
border-bottom: 1px solid var(--border-color);
|
|
}
|
|
|
|
.hits-card .card-header {
|
|
border-top: 3px solid var(--primary-color);
|
|
}
|
|
|
|
.success-card .card-header {
|
|
border-top: 3px solid var(--success-color);
|
|
}
|
|
|
|
.failure-card .card-header {
|
|
border-top: 3px solid var(--danger-color);
|
|
}
|
|
|
|
.card-body {
|
|
padding: 15px;
|
|
position: relative;
|
|
}
|
|
|
|
.graph-container {
|
|
background: var(--graph-bg);
|
|
padding: 10px;
|
|
height: 250px;
|
|
position: relative;
|
|
}
|
|
|
|
.list-container {
|
|
height: 220px;
|
|
overflow-y: auto;
|
|
scrollbar-width: thin;
|
|
scrollbar-color: var(--border-color) transparent;
|
|
padding: 5px 0;
|
|
}
|
|
|
|
.list-container::-webkit-scrollbar {
|
|
width: 6px;
|
|
}
|
|
|
|
.list-container::-webkit-scrollbar-track {
|
|
background: transparent;
|
|
}
|
|
|
|
.list-container::-webkit-scrollbar-thumb {
|
|
background-color: var(--border-color);
|
|
border-radius: 10px;
|
|
}
|
|
|
|
.list-item {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
padding: 10px;
|
|
border-radius: 4px;
|
|
transition: background 0.2s;
|
|
}
|
|
|
|
.list-item:hover {
|
|
background: var(--hover-bg);
|
|
}
|
|
|
|
.list-item-count {
|
|
font-weight: 500;
|
|
background: var(--primary-light);
|
|
color: var(--primary-color);
|
|
padding: 2px 8px;
|
|
border-radius: 12px;
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.no-data {
|
|
display: flex;
|
|
height: 100%;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: var(--muted-color);
|
|
font-style: italic;
|
|
}
|
|
|
|
.note {
|
|
font-size: 0.85rem;
|
|
color: var(--muted-color);
|
|
text-align: center;
|
|
margin-top: 15px;
|
|
}
|
|
|
|
/* Loading indicator */
|
|
.loading {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: rgba(255, 255, 255, 0.85);
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 100;
|
|
gap: 10px;
|
|
}
|
|
|
|
.loading-text {
|
|
font-size: 0.85rem;
|
|
color: var(--muted-color);
|
|
}
|
|
|
|
.spinner {
|
|
width: 30px;
|
|
height: 30px;
|
|
border: 3px solid rgba(67, 97, 238, 0.2);
|
|
border-radius: 50%;
|
|
border-top-color: var(--primary-color);
|
|
animation: spin 1s ease-in-out infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to {
|
|
transform: rotate(360deg);
|
|
}
|
|
}
|
|
|
|
/* Fade-in-up animation for cards and stats */
|
|
@keyframes fadeInUp {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(20px);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: translateY(0);
|
|
}
|
|
}
|
|
|
|
.animate-fade-in {
|
|
opacity: 0;
|
|
animation: fadeInUp 0.6s ease-out forwards;
|
|
}
|
|
|
|
.delay-1 {
|
|
animation-delay: 0.2s;
|
|
}
|
|
.delay-2 {
|
|
animation-delay: 0.3s;
|
|
}
|
|
.delay-3 {
|
|
animation-delay: 0.4s;
|
|
}
|
|
.delay-4 {
|
|
animation-delay: 0.5s;
|
|
}
|
|
.delay-5 {
|
|
animation-delay: 0.6s;
|
|
}
|
|
|
|
/* Skeleton shimmer for loading placeholders */
|
|
.skeleton::before {
|
|
content: '';
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: linear-gradient(90deg, #eee 25%, #ddd 50%, #eee 75%);
|
|
background-size: 200% 100%;
|
|
animation: shimmer 1.5s infinite;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
@keyframes shimmer {
|
|
0% {
|
|
background-position: -200% 0;
|
|
}
|
|
100% {
|
|
background-position: 200% 0;
|
|
}
|
|
}
|
|
|
|
/* Responsive adjustments */
|
|
@media (max-width: 768px) {
|
|
.container {
|
|
padding: 15px;
|
|
}
|
|
|
|
h1 {
|
|
font-size: 1.5rem;
|
|
margin-bottom: 15px;
|
|
}
|
|
|
|
.controls-wrapper {
|
|
padding: 12px 15px;
|
|
}
|
|
|
|
.controls {
|
|
flex-direction: column;
|
|
align-items: stretch;
|
|
gap: 15px;
|
|
}
|
|
|
|
.controls-left {
|
|
justify-content: center;
|
|
width: 100%;
|
|
}
|
|
|
|
.stats-summary {
|
|
justify-content: center;
|
|
width: 100%;
|
|
gap: 10px;
|
|
}
|
|
|
|
.stat-item {
|
|
min-width: 80px;
|
|
padding: 6px 12px;
|
|
}
|
|
|
|
.stat-label {
|
|
font-size: 0.75rem;
|
|
}
|
|
|
|
.stat-value {
|
|
font-size: 1.1rem;
|
|
}
|
|
|
|
.controls-right {
|
|
flex-direction: column;
|
|
align-items: stretch;
|
|
gap: 10px;
|
|
}
|
|
|
|
.controls-right > div {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 10px;
|
|
}
|
|
|
|
.controls select {
|
|
min-width: auto;
|
|
flex: 1;
|
|
}
|
|
|
|
.refresh-button {
|
|
width: 100%;
|
|
justify-content: center;
|
|
}
|
|
|
|
.grid {
|
|
grid-template-columns: 1fr;
|
|
gap: 15px;
|
|
}
|
|
|
|
.graph-container {
|
|
height: 200px;
|
|
}
|
|
|
|
.list-container {
|
|
height: 180px;
|
|
}
|
|
|
|
.divider::before,
|
|
.divider::after {
|
|
width: 25%;
|
|
margin: 0 10px;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 480px) {
|
|
.container {
|
|
padding: 10px;
|
|
}
|
|
|
|
h1 {
|
|
font-size: 1.3rem;
|
|
}
|
|
|
|
.controls-wrapper {
|
|
padding: 10px 12px;
|
|
}
|
|
|
|
.stats-summary {
|
|
gap: 8px;
|
|
}
|
|
|
|
.stat-item {
|
|
min-width: 70px;
|
|
padding: 5px 8px;
|
|
}
|
|
|
|
.stat-label {
|
|
font-size: 0.7rem;
|
|
}
|
|
|
|
.stat-value {
|
|
font-size: 1rem;
|
|
}
|
|
|
|
.controls select {
|
|
padding: 6px 8px;
|
|
font-size: 0.85rem;
|
|
padding-right: 25px;
|
|
}
|
|
|
|
.refresh-button {
|
|
padding: 6px 12px;
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.card-header {
|
|
padding: 12px;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.card-body {
|
|
padding: 12px;
|
|
}
|
|
|
|
.graph-container {
|
|
height: 180px;
|
|
padding: 8px;
|
|
}
|
|
|
|
.list-container {
|
|
height: 160px;
|
|
}
|
|
|
|
.grid {
|
|
grid-template-columns: 1fr;
|
|
gap: 12px;
|
|
}
|
|
}
|
|
|
|
/* Very small screens */
|
|
@media (max-width: 360px) {
|
|
.stats-summary {
|
|
gap: 6px;
|
|
}
|
|
|
|
.stat-item {
|
|
min-width: 60px;
|
|
padding: 4px 6px;
|
|
}
|
|
|
|
.stat-label {
|
|
font-size: 0.65rem;
|
|
}
|
|
|
|
.stat-value {
|
|
font-size: 0.9rem;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<h1>Checkpoint Service Statistics</h1>
|
|
|
|
<div class="controls-wrapper">
|
|
<div class="controls">
|
|
<div class="controls-left">
|
|
<div class="stats-summary">
|
|
<div class="stat-item stat-hits">
|
|
<div class="stat-label">Total Hits</div>
|
|
<div class="stat-value" id="totalHits">--</div>
|
|
</div>
|
|
<div class="stat-item stat-passes">
|
|
<div class="stat-label">Total Passes</div>
|
|
<div class="stat-value" id="totalPasses">--</div>
|
|
</div>
|
|
<div class="stat-item stat-failures">
|
|
<div class="stat-label">Total Failures</div>
|
|
<div class="stat-value" id="totalFailures">--</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="controls-right">
|
|
<div>
|
|
<label for="rangeSelect">Time Range:</label>
|
|
<select id="rangeSelect">
|
|
<option value="3600000">1 Hour</option>
|
|
<option value="21600000">6 Hours</option>
|
|
<option value="43200000">12 Hours</option>
|
|
<option value="86400000" selected>1 Day</option>
|
|
<option value="604800000">1 Week</option>
|
|
<option value="1209600000">2 Weeks</option>
|
|
<option value="2592000000">1 Month</option>
|
|
</select>
|
|
</div>
|
|
<button id="refreshBtn" class="refresh-button">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="16"
|
|
height="16"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
>
|
|
<path d="M23 4v6h-6" />
|
|
<path d="M1 20v-6h6" />
|
|
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
|
|
</svg>
|
|
Refresh
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<section class="section checkpoint-section">
|
|
<div class="grid">
|
|
<div class="card hits-card">
|
|
<div class="card-header">Checkpoint Hits</div>
|
|
<div class="card-body">
|
|
<div class="graph-container">
|
|
<canvas id="hitsChart"></canvas>
|
|
<div id="hitsLoading" class="loading" style="display: none">
|
|
<div class="spinner"></div>
|
|
<div class="loading-text">Loading data...</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card success-card">
|
|
<div class="card-header">Checkpoint Passes</div>
|
|
<div class="card-body">
|
|
<div class="graph-container">
|
|
<canvas id="successChart"></canvas>
|
|
<div id="successLoading" class="loading" style="display: none">
|
|
<div class="spinner"></div>
|
|
<div class="loading-text">Loading data...</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card failure-card">
|
|
<div class="card-header">Checkpoint Failures</div>
|
|
<div class="card-body">
|
|
<div class="graph-container">
|
|
<canvas id="failureChart"></canvas>
|
|
<div id="failureLoading" class="loading" style="display: none">
|
|
<div class="spinner"></div>
|
|
<div class="loading-text">Loading data...</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<div class="divider">Plugin stats below this line</div>
|
|
|
|
<section class="section ipfilter-section">
|
|
<div class="section-header">
|
|
<h2 class="section-title">IPfilter Statistics</h2>
|
|
</div>
|
|
<div class="grid">
|
|
<div class="card">
|
|
<div class="card-header">Top Blocked ASNs</div>
|
|
<div class="card-body">
|
|
<div class="list-container" id="blockedAsns">
|
|
<div class="no-data">No data available yet</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div class="card-header">Top Blocked IPs</div>
|
|
<div class="card-body">
|
|
<div class="list-container" id="blockedIps">
|
|
<div class="no-data">No data available yet</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div class="card-header">Top Triggered Rules</div>
|
|
<div class="card-body">
|
|
<div class="list-container" id="triggeredRules">
|
|
<div class="no-data">No data available yet</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<p class="note">Data retention period: 30 days</p>
|
|
</section>
|
|
</div>
|
|
|
|
<script>
|
|
// First-visit detection
|
|
const isFirstVisit = sessionStorage.getItem('stats_visited') === null;
|
|
sessionStorage.setItem('stats_visited', 'true');
|
|
|
|
// Chart configuration
|
|
Chart.defaults.font.family =
|
|
"'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif";
|
|
Chart.defaults.font.size = 12;
|
|
Chart.defaults.plugins.legend.display = false;
|
|
Chart.defaults.elements.line.tension = 0.4;
|
|
Chart.defaults.elements.point.radius = 3;
|
|
Chart.defaults.elements.point.hoverRadius = 5;
|
|
|
|
// Adjust global animation duration and preserve easing
|
|
Chart.defaults.animation.duration = isFirstVisit ? 600 : 200;
|
|
Chart.defaults.animation.easing = 'easeOutQuart';
|
|
|
|
let currentRangeMs = 86400000; // 1 Day (default)
|
|
let chartInstances = {};
|
|
let isRefreshing = false;
|
|
|
|
const metrics = [
|
|
{
|
|
id: 'hitsChart',
|
|
metric: 'checkpoint.sent',
|
|
label: 'Checkpoint Hits',
|
|
color: 'rgba(67, 97, 238, 0.8)',
|
|
bgColor: 'rgba(67, 97, 238, 0.1)',
|
|
},
|
|
{
|
|
id: 'successChart',
|
|
metric: 'checkpoint.success',
|
|
label: 'Checkpoint Passes',
|
|
color: 'rgba(46, 204, 113, 0.8)',
|
|
bgColor: 'rgba(46, 204, 113, 0.1)',
|
|
},
|
|
{
|
|
id: 'failureChart',
|
|
metric: 'checkpoint.failure',
|
|
label: 'Checkpoint Failures',
|
|
color: 'rgba(231, 76, 60, 0.8)',
|
|
bgColor: 'rgba(231, 76, 60, 0.1)',
|
|
},
|
|
];
|
|
|
|
// Count-up animation for summary stats
|
|
function countUp(el, start, end, duration = 1000) {
|
|
// Skip animation if not first visit
|
|
if (!isFirstVisit) {
|
|
el.textContent = end;
|
|
return;
|
|
}
|
|
|
|
let startTime = null;
|
|
function step(time) {
|
|
if (!startTime) startTime = time;
|
|
const progress = Math.min((time - startTime) / duration, 1);
|
|
el.textContent = Math.floor(progress * (end - start) + start);
|
|
if (progress < 1) requestAnimationFrame(step);
|
|
}
|
|
requestAnimationFrame(step);
|
|
}
|
|
|
|
// Handle time range changes
|
|
document.getElementById('rangeSelect').addEventListener('change', (e) => {
|
|
currentRangeMs = Number(e.target.value);
|
|
updateCharts();
|
|
updateIPFilterStats();
|
|
});
|
|
|
|
// Handle refresh button click
|
|
document.getElementById('refreshBtn').addEventListener('click', () => {
|
|
if (isRefreshing) return;
|
|
isRefreshing = true;
|
|
|
|
const btn = document.getElementById('refreshBtn');
|
|
const originalText = btn.innerHTML;
|
|
btn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="spinner"><path d="M23 4v6h-6"/><path d="M1 20v-6h6"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg> Refreshing...`;
|
|
btn.disabled = true;
|
|
|
|
Promise.all([updateCharts(), updateIPFilterStats()]).finally(() => {
|
|
btn.innerHTML = originalText;
|
|
btn.disabled = false;
|
|
isRefreshing = false;
|
|
});
|
|
});
|
|
|
|
// Fetch data for charts
|
|
async function fetchData(metric) {
|
|
const id = metrics.find((m) => m.metric === metric)?.id;
|
|
if (id) showLoading(id, true);
|
|
|
|
try {
|
|
// Use a cache-busting technique to prevent stale data
|
|
const now = Date.now();
|
|
const start = now - currentRangeMs;
|
|
const cacheBuster = `&_=${now}`;
|
|
// Properly encode parameters to prevent injection
|
|
const url = `/stats/api?metric=${encodeURIComponent(
|
|
metric,
|
|
)}&start=${start}&end=${now}${cacheBuster}`;
|
|
const res = await fetch(url);
|
|
const data = res.ok ? await res.json() : [];
|
|
return data;
|
|
} catch (err) {
|
|
console.error(`Failed to fetch ${metric} data:`, err);
|
|
return [];
|
|
} finally {
|
|
if (id) showLoading(id, false);
|
|
}
|
|
}
|
|
|
|
// Show/hide loading indicators for charts
|
|
function showLoading(id, isLoading) {
|
|
const loadingEl = document.getElementById(`${id}Loading`);
|
|
if (loadingEl) {
|
|
loadingEl.style.display = isLoading ? 'flex' : 'none';
|
|
}
|
|
}
|
|
|
|
// Group data into time buckets
|
|
function bucketData(data, bucketSizeMs) {
|
|
// Memory optimization: ensure we process data efficiently for large datasets
|
|
const isLargeDataset = data.length > 5000;
|
|
|
|
const buckets = {};
|
|
|
|
// Faster processing for large datasets
|
|
if (isLargeDataset) {
|
|
// Use for loop for better performance with large arrays
|
|
for (let i = 0; i < data.length; i++) {
|
|
const bucket = Math.floor(data[i].timestamp / bucketSizeMs) * bucketSizeMs;
|
|
buckets[bucket] = (buckets[bucket] || 0) + 1;
|
|
}
|
|
} else {
|
|
// Use forEach for cleaner code with smaller datasets
|
|
data.forEach((item) => {
|
|
const bucket = Math.floor(item.timestamp / bucketSizeMs) * bucketSizeMs;
|
|
buckets[bucket] = (buckets[bucket] || 0) + 1;
|
|
});
|
|
}
|
|
|
|
return Object.entries(buckets)
|
|
.map(([time, count]) => ({ time: parseInt(time, 10), count }))
|
|
.sort((a, b) => a.time - b.time);
|
|
}
|
|
|
|
// Calculate and update summary metrics
|
|
function updateSummaryStats(metrics) {
|
|
if (!metrics || metrics.length === 0) return;
|
|
|
|
let hitPromise = fetchData('checkpoint.sent');
|
|
let passPromise = fetchData('checkpoint.success');
|
|
let failPromise = fetchData('checkpoint.failure');
|
|
|
|
Promise.all([hitPromise, passPromise, failPromise])
|
|
.then(([hits, passes, failures]) => {
|
|
countUp(document.getElementById('totalHits'), 0, hits.length);
|
|
countUp(document.getElementById('totalPasses'), 0, passes.length);
|
|
countUp(document.getElementById('totalFailures'), 0, failures.length);
|
|
})
|
|
.catch((err) => {
|
|
console.error('Failed to update summary stats:', err);
|
|
});
|
|
}
|
|
|
|
// Create or update charts
|
|
async function drawChart({ id, metric, label, color, bgColor }) {
|
|
// Set loading state
|
|
showLoading(id, true);
|
|
|
|
try {
|
|
const raw = await fetchData(metric);
|
|
|
|
// Dynamically adjust bucket count: 48 for <=1 day, 24 for <=1 week, 30 otherwise
|
|
let bucketCount;
|
|
if (currentRangeMs <= 86400000) {
|
|
bucketCount = 48;
|
|
} else if (currentRangeMs <= 604800000) {
|
|
bucketCount = 24;
|
|
} else {
|
|
bucketCount = 30;
|
|
}
|
|
|
|
// Performance: Use a smaller bucket size for large datasets
|
|
const dataSize = raw.length;
|
|
if (dataSize > 10000) {
|
|
bucketCount = Math.min(bucketCount, 12); // Reduce resolution for large datasets
|
|
}
|
|
|
|
const bucketSizeMs = Math.max(Math.floor(currentRangeMs / bucketCount), 60000); // min 1 minute
|
|
|
|
const series = bucketData(raw, bucketSizeMs);
|
|
const timestamps = series.map((pt) => pt.time);
|
|
const counts = series.map((pt) => pt.count);
|
|
|
|
// Create empty points for continuous line
|
|
if (timestamps.length > 1) {
|
|
const fullTimestamps = [];
|
|
const fullCounts = [];
|
|
|
|
const start = timestamps[0];
|
|
const end = timestamps[timestamps.length - 1];
|
|
|
|
for (let t = start; t <= end; t += bucketSizeMs) {
|
|
fullTimestamps.push(t);
|
|
const match = series.find((s) => s.time === t);
|
|
fullCounts.push(match ? match.count : 0);
|
|
}
|
|
|
|
// Use the full dataset with zero-filled gaps
|
|
if (fullTimestamps.length > timestamps.length) {
|
|
timestamps.length = 0;
|
|
counts.length = 0;
|
|
timestamps.push(...fullTimestamps);
|
|
counts.push(...fullCounts);
|
|
}
|
|
}
|
|
|
|
const labels = timestamps.map((t) => {
|
|
const date = new Date(t);
|
|
// Format based on range
|
|
if (currentRangeMs <= 86400000) {
|
|
// <= 1 day, show HH:MM
|
|
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
} else if (currentRangeMs <= 604800000) {
|
|
// <= 1 week, show Day HH:MM
|
|
return (
|
|
date.toLocaleDateString([], { weekday: 'short' }) +
|
|
' ' +
|
|
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
);
|
|
} else {
|
|
// > 1 week, show MM/DD
|
|
return date.toLocaleDateString([], { month: 'numeric', day: 'numeric' });
|
|
}
|
|
});
|
|
|
|
const canvas = document.getElementById(id);
|
|
if (!canvas) return;
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
// Destroy existing chart instance if any
|
|
if (chartInstances[id]) {
|
|
chartInstances[id].destroy();
|
|
}
|
|
|
|
// Calculate max Y value for better scaling
|
|
const maxCount = Math.max(...counts, 1);
|
|
const yMax = maxCount <= 5 ? maxCount + 1 : undefined;
|
|
|
|
// Optimize point display based on data density
|
|
const pointRadius = counts.length > 30 ? (counts.length > 100 ? 0 : 1) : 3;
|
|
const pointHoverRadius = counts.length > 100 ? 3 : 5;
|
|
|
|
// Create new chart
|
|
chartInstances[id] = new Chart(ctx, {
|
|
type: 'line',
|
|
data: {
|
|
labels,
|
|
datasets: [
|
|
{
|
|
label,
|
|
data: counts,
|
|
borderColor: color,
|
|
backgroundColor: bgColor || color.replace('0.8)', '0.1)'),
|
|
borderWidth: 2,
|
|
fill: true,
|
|
tension: 0.3,
|
|
pointRadius: pointRadius,
|
|
pointHoverRadius: pointHoverRadius,
|
|
pointBackgroundColor: 'white',
|
|
pointBorderWidth: 2,
|
|
},
|
|
],
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
// Single animation configuration to avoid conflicts
|
|
animation: {
|
|
duration: counts.length > 500 ? 0 : isFirstVisit ? 800 : 200,
|
|
easing: 'easeOutQuart',
|
|
animateScale: true,
|
|
},
|
|
plugins: {
|
|
title: {
|
|
display: false,
|
|
},
|
|
tooltip: {
|
|
mode: 'index',
|
|
intersect: false,
|
|
displayColors: false,
|
|
backgroundColor: 'rgba(0,0,0,0.8)',
|
|
bodyFont: {
|
|
size: 13,
|
|
},
|
|
padding: 10,
|
|
callbacks: {
|
|
title: (items) => {
|
|
return items[0].label;
|
|
},
|
|
label: (item) => {
|
|
return `${item.parsed.y} ${item.parsed.y === 1 ? 'event' : 'events'}`;
|
|
},
|
|
},
|
|
},
|
|
},
|
|
scales: {
|
|
x: {
|
|
display: true,
|
|
grid: {
|
|
display: false,
|
|
},
|
|
ticks: {
|
|
maxRotation: 45,
|
|
minRotation: 45,
|
|
// Limit x-axis ticks for performance with large datasets
|
|
maxTicksLimit: counts.length > 100 ? 6 : 12,
|
|
font: {
|
|
size: 10,
|
|
},
|
|
},
|
|
},
|
|
y: {
|
|
display: true,
|
|
beginAtZero: true,
|
|
suggestedMax: yMax,
|
|
title: {
|
|
display: false,
|
|
},
|
|
ticks: {
|
|
precision: 0,
|
|
font: {
|
|
size: 10,
|
|
},
|
|
},
|
|
grid: {
|
|
color: 'rgba(0,0,0,0.05)',
|
|
},
|
|
},
|
|
},
|
|
interaction: {
|
|
mode: 'nearest',
|
|
axis: 'x',
|
|
intersect: false,
|
|
},
|
|
hover: {
|
|
mode: 'nearest',
|
|
intersect: false,
|
|
},
|
|
},
|
|
});
|
|
} catch (err) {
|
|
console.error(`Error drawing chart ${id}:`, err);
|
|
} finally {
|
|
// Always hide loading indicator when done
|
|
showLoading(id, false);
|
|
}
|
|
}
|
|
|
|
// Aggregate IPFilter data
|
|
async function updateIPFilterStats() {
|
|
const now = Date.now();
|
|
const start = now - currentRangeMs;
|
|
|
|
try {
|
|
const res = await fetch(`/stats/api?metric=ipfilter.block&start=${start}&end=${now}`);
|
|
if (!res.ok) return;
|
|
|
|
const data = await res.json();
|
|
if (!data || !data.length) return;
|
|
|
|
// Process ASNs
|
|
const asnCounts = {};
|
|
const ipCounts = {};
|
|
const ruleCounts = {};
|
|
|
|
data.forEach((item) => {
|
|
// Count ASNs
|
|
if (item.asn_org) {
|
|
asnCounts[item.asn_org] = (asnCounts[item.asn_org] || 0) + 1;
|
|
}
|
|
|
|
// Count IPs (using ip from data if available)
|
|
if (item.ip) {
|
|
ipCounts[item.ip] = (ipCounts[item.ip] || 0) + 1;
|
|
}
|
|
|
|
// Count rules (using type+value as rule identifier)
|
|
if (item.type && item.value) {
|
|
const rule = `${item.type}:${item.value}`;
|
|
ruleCounts[rule] = (ruleCounts[rule] || 0) + 1;
|
|
}
|
|
});
|
|
|
|
// Sort and display top ASNs
|
|
updateBlockList('blockedAsns', asnCounts);
|
|
|
|
// Sort and display top IPs
|
|
updateBlockList('blockedIps', ipCounts);
|
|
|
|
// Sort and display top rules
|
|
updateBlockList('triggeredRules', ruleCounts, (rule) => {
|
|
const [type, value] = rule.split(':');
|
|
return `${type.toUpperCase()}: ${value}`;
|
|
});
|
|
} catch (err) {
|
|
console.error('Failed to fetch IPfilter stats:', err);
|
|
}
|
|
}
|
|
|
|
function updateBlockList(elementId, countMap, labelFormatter = null) {
|
|
const container = document.getElementById(elementId);
|
|
if (!container) return;
|
|
|
|
const sortedItems = Object.entries(countMap)
|
|
.sort((a, b) => b[1] - a[1])
|
|
.slice(0, 10); // Top 10
|
|
|
|
if (sortedItems.length === 0) {
|
|
container.innerHTML = '<div class="no-data">No data available yet</div>';
|
|
return;
|
|
}
|
|
|
|
const html = sortedItems
|
|
.map(([key, count]) => {
|
|
const label = labelFormatter ? labelFormatter(key) : key;
|
|
return `
|
|
<div class="list-item">
|
|
<span>${label}</span>
|
|
<span class="list-item-count">${count}</span>
|
|
</div>
|
|
`;
|
|
})
|
|
.join('');
|
|
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
async function updateCharts() {
|
|
// Update summary metrics
|
|
updateSummaryStats(metrics);
|
|
|
|
// Update all charts
|
|
const promises = [];
|
|
for (const cfg of metrics) {
|
|
promises.push(drawChart(cfg));
|
|
}
|
|
|
|
// Wait for all charts to finish rendering
|
|
await Promise.all(promises);
|
|
}
|
|
|
|
// Initialize on page load
|
|
window.addEventListener('DOMContentLoaded', () => {
|
|
// Apply animations on first visit only
|
|
if (isFirstVisit) {
|
|
// Animate summary stats
|
|
document.querySelectorAll('.stat-item').forEach((el, i) => {
|
|
el.classList.add('animate-fade-in', `delay-${i + 1}`);
|
|
});
|
|
|
|
// Animate charts
|
|
document.querySelectorAll('.card').forEach((el, i) => {
|
|
el.classList.add('animate-fade-in', `delay-${i + 2}`);
|
|
});
|
|
}
|
|
|
|
// Initially hide loading spinners
|
|
metrics.forEach((m) => {
|
|
showLoading(m.id, false);
|
|
});
|
|
|
|
// Then start loading data
|
|
updateCharts();
|
|
updateIPFilterStats();
|
|
|
|
// Refresh data periodically (every 5 minutes)
|
|
setInterval(() => {
|
|
updateCharts();
|
|
updateIPFilterStats();
|
|
}, 5 * 60 * 1000);
|
|
|
|
// Adjust for mobile screens
|
|
function handleResize() {
|
|
if (window.innerWidth < 600) {
|
|
Chart.defaults.font.size = 10;
|
|
Chart.defaults.elements.point.radius = 2;
|
|
metrics.forEach((m) => {
|
|
if (chartInstances[m.id]) {
|
|
chartInstances[m.id].options.scales.x.ticks.maxRotation = 90;
|
|
chartInstances[m.id].update();
|
|
}
|
|
});
|
|
} else {
|
|
Chart.defaults.font.size = 12;
|
|
Chart.defaults.elements.point.radius = 3;
|
|
metrics.forEach((m) => {
|
|
if (chartInstances[m.id]) {
|
|
chartInstances[m.id].options.scales.x.ticks.maxRotation = 45;
|
|
chartInstances[m.id].update();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// Initial resize check
|
|
handleResize();
|
|
|
|
// Listen for window resize
|
|
window.addEventListener('resize', handleResize);
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|