Files
awesome-copilot/plugins/fastah-ip-geo-tools/skills/geofeed-tuner/scripts/templates/index.html
2026-03-31 00:00:16 +00:00

2306 lines
98 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Geofeed Tuner Report</title>
<link href="https://fonts.googleapis.com/css2?family=Raleway:wght@300;400;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/h3-js@4.1.0/dist/h3-js.umd.js"></script>
<style>
body {
background-color: #adadad;
padding: 20px;
font-family: 'Raleway', sans-serif;
}
i[class*="bi"] {
font-weight: 700;
text-shadow: 0 0 0.3px currentColor;
}
.report-container {
background-color: white;
border-radius: 15px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
padding: 30px;
margin: 0 auto 20px auto;
max-width: 1400px;
}
.metadata {
background-color: #f0f0f0;
padding: 20px;
border-radius: 5px;
margin-bottom: 30px;
}
.metadata h3 {
margin-bottom: 15px;
color: #333333;
}
.metadata-item {
display: inline-block;
margin-right: 40px;
margin-bottom: 10px;
}
.metadata-label {
font-weight: bold;
color: #696969;
}
.metadata-value {
color: #333333;
}
.status-badge {
padding: 3px 10px;
border-radius: 4px;
font-weight: bold;
font-size: 0.75em;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
white-space: nowrap;
width: 150px;
}
.status-error {
background-color: #ff707f;
color: #ffffff;
border: 1px solid #ff707f;
}
.status-warning {
background-color: #ffdc73;
color: #000000;
border: 1px solid #ffdc73;
}
.status-suggestion {
background-color: #1877ec;
color: #ffffff;
border: 1px solid #1877ec;
}
.status-ok {
background-color: #28a745;
color: #ffffff;
border: 1px solid #28a745;
}
/* Checkbox styling */
input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
accent-color: #4a4a4a;
display: block;
margin: 0 auto;
}
input[type="checkbox"]:hover {
transform: scale(1.1);
}
/* Disabled checkbox with X effect */
input[type="checkbox"]:disabled {
cursor: not-allowed;
opacity: 0.6;
position: relative;
}
input[type="checkbox"]:disabled:hover {
transform: none;
}
/* Create X overlay on disabled checkbox */
.message-line input[type="checkbox"]:disabled {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
width: 16px;
height: 16px;
border: 2px solid #dc3545;
border-radius: 3px;
background-color: #dc3545;
position: relative;
cursor: not-allowed;
}
.message-line input[type="checkbox"]:disabled::before,
.message-line input[type="checkbox"]:disabled::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 10px;
height: 2px;
background-color: #ffffff;
}
.message-line input[type="checkbox"]:disabled::before {
transform: translate(-50%, -50%) rotate(45deg);
}
.message-line input[type="checkbox"]:disabled::after {
transform: translate(-50%, -50%) rotate(-45deg);
}
#selectAll {
width: 17px;
height: 17px;
}
.entries-table td:first-child,
.entries-table th:first-child {
text-align: center;
vertical-align: middle;
}
.entries-table {
width: 100%;
border-collapse: collapse;
border-radius: 8px;
overflow: hidden;
table-layout: fixed;
}
.entries-table thead {
background-color: #4a4a4a;
color: white;
}
.entries-table th {
padding: 12px;
text-align: left;
border: 1px solid #d3d3d3;
font-weight: 600;
background-color: #6b6b6b;
color: #ffffff;
}
.entries-table td {
padding: 12px;
border: 1px solid #d3d3d3;
vertical-align: top;
word-wrap: break-word;
overflow-wrap: break-word;
}
.entries-table tbody tr:not(.expand-details-row) td:nth-child(3) {
text-align: center;
vertical-align: top;
}
.entries-table tbody tr:nth-child(odd) {
background-color: #f9f9f9;
}
.entries-table tbody tr:hover {
background-color: #f0f0f0;
}
.messages-list {
list-style: none;
padding: 0;
margin: 0;
}
.messages-list li {
padding: 1px 0;
border-left: 3px solid #b0b0b0;
padding-left: 10px;
color: #696969;
font-size: 0.95em;
}
h1 {
color: #ffffff;
margin-bottom: 10px;
margin-top: -30px;
margin-left: -30px;
margin-right: -30px;
border-bottom: none;
padding-bottom: 10px;
font-size: 3em;
font-weight: 700;
font-family: 'Raleway', sans-serif;
background: linear-gradient(135deg, #2a2a2a 0%, #4a4a4a 100%);
padding: 30px;
border-radius: 15px 15px 0 0;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
}
.metrics-table {
width: 100%;
border-collapse: collapse;
margin: 0 auto 20px auto;
border-radius: 8px;
overflow: hidden;
}
.metrics-table td {
padding: 10px 15px;
border: 1px solid #d3d3d3;
}
.metrics-table tr:nth-child(even) {
background-color: #f9f9f9;
}
.metrics-label {
font-weight: bold;
width: 30%;
background-color: #f0f0f0;
}
.metrics-value {
width: 70%;
color: #333333;
}
.metrics-table.feed-metadata .metrics-label {
width: 20%;
}
.metrics-table.feed-metadata .metrics-value {
width: 80%;
}
.accuracy-summary-table td {
width: auto !important;
}
.accuracy-summary-table .metrics-label {
width: 15% !important;
}
.accuracy-summary-table .metrics-value {
width: 35% !important;
}
#filterCountry, #filterRegion, #filterCity, #filterStatus, #filterIpPrefix {
box-sizing: border-box;
font-family: 'Raleway', sans-serif;
}
#filterCountry::placeholder, #filterRegion::placeholder, #filterCity::placeholder, #filterIpPrefix::placeholder {
color: #aaa;
}
#resetFilters:hover, #toggleFilters:hover, #toggleExpandAll:hover, #downloadCSV:hover {
background-color: #6a6a6a;
border-color: #aaa;
}
.expand-details-row {
display: none;
}
.expand-details-row.show {
display: table-row;
}
.expand-details-row td {
border-left: none;
border-right: none;
}
.expand-details-row td:first-child {
border-left: 1px solid #d3d3d3;
padding: 0;
}
.expand-details-row td:nth-child(2) {
padding: 0;
}
.expand-details-row td:last-child {
border-right: 1px solid #d3d3d3;
}
.previous-values-row td {
background-color: #f0f0f0;
font-style: italic;
color: #555;
padding: 8px 12px;
border-top: 1px solid #d3d3d3;
border-bottom: 1px solid #d3d3d3;
}
.previous-values-row td:first-child {
border-left: 1px solid #d3d3d3;
}
.previous-values-row td:last-child {
border-right: 1px solid #d3d3d3;
}
.issues-row td {
border-top: 1px solid #d3d3d3;
border-bottom: 1px solid #d3d3d3;
}
.expandable-row {
cursor: pointer;
}
.expandable-row:focus {
outline: 2px solid #4a9eff;
outline-offset: -2px;
}
.tune-all-btn {
display: none;
padding: 3px 8px;
border: 1px solid #888;
border-radius: 3px;
background-color: #5a5a5a;
color: #ffffff;
cursor: pointer;
font-weight: 600;
font-size: 0.8em;
}
body.tuning-mode .tune-all-btn {
display: inline;
}
.issues-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.message-line {
display: flex;
justify-content: space-between;
align-items: center;
margin: 5px 0;
}
.message-line input[type="checkbox"] {
display: none;
}
body.tuning-mode .message-line input[type="checkbox"] {
display: inline-block;
}
.message-line span {
flex: 1;
font-size: 0.98em;
}
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.6);
}
.modal-content {
background-color: #ffffff;
margin: 50px auto;
padding: 0;
border: none;
width: 90%;
max-width: 1200px;
border-radius: 15px;
max-height: 90vh;
overflow: hidden;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
display: flex;
flex-direction: column;
}
.modal-content h2 {
background-color: #4a4a4a;
color: #ffffff;
padding: 20px 25px;
margin: 0;
font-size: 1.3em;
font-weight: bold;
font-family: 'Raleway', sans-serif;
border-radius: 15px 15px 0 0;
position: relative;
}
.close {
color: #ffffff;
position: absolute;
right: 20px;
top: 50%;
transform: translateY(-50%);
font-size: 32px;
font-weight: bold;
cursor: pointer;
line-height: 1;
transition: color 0.2s;
}
.close:hover,
.close:focus {
color: #ddd;
}
.matches-container {
margin-top: 0;
padding: 25px;
display: flex;
flex-wrap: nowrap;
gap: 15px;
justify-content: center;
overflow-x: auto;
background-color: #f5f5f5;
flex: 1;
overflow-y: auto;
}
.match-item {
flex: 1 1 0;
max-width: 350px;
min-width: 280px;
border: 2px solid #c0c0c0;
border-radius: 8px;
padding: 0;
background-color: white;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
cursor: pointer;
transition: all 0.2s;
overflow: hidden;
}
.match-item:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.25);
border-color: #4a9eff;
transform: translateY(-2px);
}
.match-item:active {
transform: translateY(0);
}
.match-item.selected {
border-color: #4a9eff;
border-width: 3px;
box-shadow: 0 0 15px rgba(74, 158, 255, 0.6);
}
.match-map {
height: 180px;
width: 100%;
margin: 0;
border: none;
border-radius: 0;
}
.match-info-container {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px;
background-color: #f0f0f0;
}
.match-header {
font-size: 22px;
font-weight: bold;
color: #333;
text-align: left;
font-family: 'Raleway', sans-serif;
line-height: 1.2;
}
.location-details {
font-size: 15px;
color: #666;
display: inline;
}
.location-details span {
margin-right: 15px;
}
.default-values {
display: none;
padding: 5px 0;
font-size: 13px;
margin-bottom: 10px;
}
.default-values.show {
display: block;
}
.mode-toggle-container {
display: flex;
align-items: center;
gap: 10px;
font-size: 0.72em;
}
.mode-toggle {
position: relative;
width: 240px;
height: 32px;
background-color: #888;
border-radius: 16px;
cursor: pointer;
transition: background-color 0.3s;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 8px;
}
.mode-toggle:focus {
outline: 2px solid #4a9eff;
outline-offset: 2px;
}
.mode-toggle-text {
font-size: 0.85em;
font-weight: 600;
color: #cccccc;
z-index: 1;
transition: color 0.3s;
flex: 1;
text-align: center;
}
.mode-toggle-text.active {
color: #333333;
}
.mode-toggle-slider {
position: absolute;
top: 3px;
left: 3px;
width: 116px;
height: 26px;
background-color: #ffffff;
border-radius: 13px;
transition: transform 0.3s;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
.mode-toggle.tuning .mode-toggle-slider {
transform: translateX(118px);
}
.mode-toggle.h3mode .mode-toggle-slider {
transform: translateX(96px);
}
.mode-toggle-label {
font-weight: 600;
color: #ffffff;
font-size: 0.9em;
}
/* Keyboard accessibility focus styles */
button:focus {
outline: 2px solid #4a9eff;
outline-offset: 2px;
}
input[type="text"]:focus,
select:focus {
outline: 2px solid #4a9eff;
outline-offset: 1px;
}
input[type="checkbox"]:focus {
outline: 2px solid #4a9eff;
outline-offset: 2px;
}
/* Pagination styles */
.pagination-container {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 20px;
padding: 15px 20px;
background-color: #f5f5f5;
border-radius: 8px;
flex-wrap: wrap;
gap: 15px;
}
.pagination-info {
color: #333333;
font-size: 0.95em;
}
.pagination-controls {
display: flex;
gap: 5px;
align-items: center;
}
.pagination-btn {
padding: 6px 12px;
border: 1px solid #888;
border-radius: 4px;
background-color: #5a5a5a;
color: #ffffff;
cursor: pointer;
font-weight: 600;
font-size: 0.9em;
transition: background-color 0.2s;
}
.pagination-btn:hover:not(:disabled) {
background-color: #4a4a4a;
}
.pagination-btn:disabled {
background-color: #888;
cursor: not-allowed;
opacity: 0.5;
}
.pagination-btn.active {
background-color: #1877ec;
border-color: #1877ec;
}
.page-size-selector {
display: flex;
align-items: center;
gap: 8px;
}
.page-size-selector select {
padding: 5px 10px;
border: 1px solid #888;
border-radius: 4px;
background-color: #5a5a5a;
color: #ffffff;
font-size: 0.9em;
cursor: pointer;
}
/* Helper classes for common inline styles */
.table-header {
background-color: #6b6b6b;
padding: 12px 15px;
font-weight: bold;
color: #ffffff;
font-size: 1.1em;
font-family: 'Raleway', sans-serif;
}
.icon-spacer {
margin-right: 8px;
}
.flex-container {
display: flex;
justify-content: space-between;
align-items: center;
}
.action-button {
padding: 5px 11px;
border: 1px solid #888;
border-radius: 3px;
background-color: #5a5a5a;
color: #ffffff;
cursor: pointer;
font-weight: 600;
font-size: 0.9em;
}
.action-button:hover {
background-color: #6a6a6a;
}
.subtitle-span {
font-size: 0.4em;
font-weight: 700;
display: block;
margin-top: 10px;
}
</style>
</head>
<body>
<div class="report-container">
<h1>Geofeed Tuner Report<br><span style="font-size: 0.4em; font-weight: 700; display: block; margin-top: 10px;">RFC 8805 Validation & Recommendations</span></h1>
<table class="metrics-table feed-metadata" style="margin-top: 30px; margin-bottom: 30px;">
<tr style="background-color: #6b6b6b;">
<td colspan="2" style="padding: 12px 15px; font-weight: bold; color: #ffffff; font-size: 1.1em; font-family: 'Raleway', sans-serif;">Feed Metadata</td>
</tr>
<tr>
<td class="metrics-label"><i class="bi bi-filetype-csv" style="margin-right: 8px;"></i>Input file</td>
<td class="metrics-value"><span id="inputFileMetrics">{{.Metadata.InputFile}}</span></td>
</tr>
<tr>
<td class="metrics-label"><i class="bi bi-calendar-event-fill" style="margin-right: 8px;"></i>Tuning timestamp</td>
<td class="metrics-value"><script>document.write(new Intl.DateTimeFormat("en", {dateStyle: "full", timeStyle: "long",}).format(new Date({{.Metadata.Timestamp}})))</script></td>
</tr>
</table>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 20px;">
<table class="metrics-table">
<tr style="background-color: #6b6b6b;">
<td colspan="2" style="padding: 12px 15px; font-weight: bold; color: #ffffff; font-size: 1.1em; font-family: 'Raleway', sans-serif;">Entries</td>
</tr>
<tr>
<td class="metrics-label"><i class="bi bi-list-ul" style="margin-right: 8px;"></i>Total entries</td>
<td class="metrics-value"><span id="totalEntriesMetrics">{{.Metadata.TotalEntries}}</span></td>
</tr>
<tr>
<td class="metrics-label"><i class="bi bi-diagram-3-fill" style="margin-right: 8px;"></i>IPv4 entries</td>
<td class="metrics-value"><span id="ipv4EntriesMetrics">{{.Metadata.IpV4Entries}}</span></td>
</tr>
<tr>
<td class="metrics-label"><i class="bi bi-diagram-3-fill" style="margin-right: 8px;"></i>IPv6 entries</td>
<td class="metrics-value"><span id="ipv6EntriesMetrics">{{.Metadata.IpV6Entries}}</span></td>
</tr>
<tr>
<td class="metrics-label"><i class="bi bi-exclamation-circle-fill" style="margin-right: 8px;"></i>Invalid entries</td>
<td class="metrics-value"><span id="invalidEntriesMetrics">{{.Metadata.InvalidEntries}}</span></td>
</tr>
</table>
<table class="metrics-table">
<tr style="background-color: #6b6b6b;">
<td colspan="2" style="padding: 12px 15px; font-weight: bold; color: #ffffff; font-size: 1.1em; font-family: 'Raleway', sans-serif;">Analysis Summary</td>
</tr>
<tr>
<td class="metrics-label"><i class="bi bi-x-circle-fill" style="margin-right: 8px;"></i>Errors</td>
<td class="metrics-value"><span id="errorCountMetrics">{{.Metadata.Errors}}</span></td>
</tr>
<tr>
<td class="metrics-label"><i class="bi bi-exclamation-triangle-fill" style="margin-right: 8px;"></i>Warnings</td>
<td class="metrics-value"><span id="warningCountMetrics">{{.Metadata.Warnings}}</span></td>
</tr>
<tr>
<td class="metrics-label"><i class="bi bi-lightbulb-fill" style="margin-right: 8px;"></i>Suggestions</td>
<td class="metrics-value"><span id="suggestionsMetrics">{{.Metadata.Suggestions}}</span></td>
</tr>
<tr>
<td class="metrics-label"><i class="bi bi-check-circle-fill" style="margin-right: 8px;"></i>OK </td>
<td class="metrics-value"><span id="okCountMetrics">{{.Metadata.OK}}</span></td>
</tr>
</table>
</div>
<div style="margin-bottom: 40px;">
<table class="metrics-table accuracy-summary-table" style="margin-top: 0; margin-bottom: 0; table-layout: fixed; width: 100%;">
<colgroup>
<col style="width: 20%;">
<col style="width: 30%;">
<col style="width: 20%;">
<col style="width: 30%;">
</colgroup>
<tr style="background-color: #6b6b6b;">
<td colspan="4" style="padding: 12px 15px; font-weight: bold; color: #ffffff; font-size: 1.1em; font-family: 'Raleway', sans-serif;">Accuracy Summary</td>
</tr>
<tr>
<td class="metrics-label"><i class="bi bi-building-fill" style="margin-right: 8px;"></i>City-level accuracy</td>
<td class="metrics-value"><span id="cityAccuracy">{{.Metadata.CityLevelAccuracy}}</span></td>
<td class="metrics-label"><i class="bi bi-map-fill" style="margin-right: 8px;"></i>Region-level accuracy</td>
<td class="metrics-value"><span id="regionAccuracy">{{.Metadata.RegionLevelAccuracy}}</span></td>
</tr>
<tr>
<td class="metrics-label"><i class="bi bi-globe-americas" style="margin-right: 8px;"></i>Country-level accuracy</td>
<td class="metrics-value"><span id="countryAccuracy">{{.Metadata.CountryLevelAccuracy}}</span></td>
<td class="metrics-label"><i class="bi bi-ban-fill" style="margin-right: 8px;"></i>Do-not-geolocate entries</td>
<td class="metrics-value"><span id="doNotGeolocate">{{.Metadata.DoNotGeolocate}}</span></td>
</tr>
</table>
</div>
<div style="overflow-x: auto;">
<table class="entries-table">
<colgroup>
<col style="width: 3%;">
<col style="width: 5%;">
<col style="width: 20%;">
<col style="width: 24%;">
<col style="width: 10%;">
<col style="width: 19%;">
<col style="width: 19%;">
</colgroup>
<thead>
<tr style="background-color: #4a4a4a;">
<th colspan="7" style="padding: 12px; font-weight: bold; color: #ffffff; font-size: 1.3em; font-family: 'Raleway', sans-serif;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<span><i class="bi bi-list-ul" style="margin-right: 10px;"></i>Entries</span>
<div class="mode-toggle-container">
<div class="mode-toggle" id="modeToggle" onclick="toggleMode()" tabindex="0" onkeydown="if(event.key === 'Enter' || event.key === ' ') { event.preventDefault(); toggleMode(); }">
<span class="mode-toggle-text active" id="viewingText">Viewing Mode</span>
<div class="mode-toggle-slider"></div>
<span class="mode-toggle-text" id="tuningText">Tuning Mode</span>
</div>
</div>
</div>
</th>
</tr>
<tr>
<th colspan="7" style="background-color: #6b6b6b; padding: 8px;">
<div style="display: flex; justify-content: space-between; gap: 5px;">
<div style="display: flex; gap: 5px;">
<button id="toggleFilters" style="padding: 5px 11px; border: 1px solid #888; border-radius: 3px; background-color: #5a5a5a; color: #ffffff; cursor: pointer; font-weight: 600; font-size: 0.9em; width: 130px;">
<i class="bi bi-funnel-fill" style="margin-right: 5px;"></i>Show Filters
</button>
<button id="toggleExpandAll" style="padding: 5px 11px; border: 1px solid #888; border-radius: 3px; background-color: #5a5a5a; color: #ffffff; cursor: pointer; font-weight: 600; font-size: 0.9em; width: 145px;">
<i class="bi bi-arrows-expand" style="margin-right: 5px;"></i>Expand Rows
</button>
<button id="tuneAll" class="tune-all-btn" style="padding: 5px 11px; border: 1px solid #888; border-radius: 3px; background-color: #5a5a5a; color: #ffffff; cursor: pointer; font-weight: 600; font-size: 0.9em;">
<i class="bi bi-wrench" style="margin-right: 5px;"></i>Tune All
</button>
</div>
<button id="downloadCSV" style="padding: 5px 11px; border: 1px solid #888; border-radius: 3px; background-color: #5a5a5a; color: #ffffff; cursor: pointer; font-weight: 600; font-size: 0.9em;">
<i class="bi bi-filetype-csv" style="margin-right: 5px;"></i>Download
</button>
</div>
</th>
</tr>
<tr>
<th><input type="checkbox" id="selectAll" checked></th>
<th>Line</th>
<th>Status</th>
<th>IP Prefix</th>
<th>Country</th>
<th>Region</th>
<th>City</th>
</tr>
<tr id="filterRow" style="background-color: #6b6b6b; display: none;">
<th colspan="2" style="padding: 8px;">
<button id="resetFilters" style="padding: 5px 11px; border: 1px solid #888; border-radius: 3px; background-color: #5a5a5a; color: #ffffff; cursor: pointer; font-weight: 600; font-size: 0.9em; width: 100%;">Reset</button>
</th>
<th style="padding: 8px;">
<select id="filterStatus" style="padding: 5px 9px; border: 1px solid #888; border-radius: 3px; background-color: #5a5a5a; color: #ffffff; font-size: 0.9em; width: 100%;">
<option value="">All</option>
<option value="ERROR">ERROR</option>
<option value="WARNING">WARNING</option>
<option value="SUGGESTION">SUGGESTION</option>
<option value="OK">OK</option>
</select>
</th>
<th style="padding: 8px;">
<input type="text" id="filterIpPrefix" placeholder="IP Prefix" style="padding: 5px 9px; border: 1px solid #888; border-radius: 3px; background-color: #5a5a5a; color: #ffffff; font-size: 0.9em; width: 100%; box-sizing: border-box;">
</th>
<th style="padding: 8px;">
<input type="text" id="filterCountry" placeholder="Country" style="padding: 5px 9px; border: 1px solid #888; border-radius: 3px; background-color: #5a5a5a; color: #ffffff; font-size: 0.9em; width: 100%; box-sizing: border-box;">
</th>
<th style="padding: 8px;">
<input type="text" id="filterRegion" placeholder="Region" style="padding: 5px 9px; border: 1px solid #888; border-radius: 3px; background-color: #5a5a5a; color: #ffffff; font-size: 0.9em; width: 100%; box-sizing: border-box;">
</th>
<th style="padding: 8px;">
<input type="text" id="filterCity" placeholder="City" style="padding: 5px 9px; border: 1px solid #888; border-radius: 3px; background-color: #5a5a5a; color: #ffffff; font-size: 0.9em; width: 100%; box-sizing: border-box;">
</th>
</tr>
</thead>
<tbody id="entriesTableBody">
{{range .Entries}}
<tr
id="csv-r-{{.Line}}"
class="expandable-row"
data-geocoding-hint="{{.GeocodingHint}}"
data-do-not-geolocate="{{.DoNotGeolocate}}"
data-has-warning="{{.HasWarning}}"
data-has-error="{{.HasError}}"
data-has-suggestion="{{.HasSuggestion}}"
data-tunable="{{.Tunable}}"
data-tuned-country="{{.TunedEntry.CountryCode}}"
data-tuned-region="{{.TunedEntry.RegionCode}}"
data-tuned-city="{{.TunedEntry.Name}}"
data-h3-cells="{{.TunedEntry.H3Cells}}"
data-bounding-box="{{.TunedEntry.BoundingBox}}">
<td><input type="checkbox" class="row-checkbox" checked></td>
<td>{{.Line}}</td>
<td><span class="status-badge status-{{if eq .Status "ERROR"}}error{{else if eq .Status "WARNING"}}warning{{else if eq .Status "SUGGESTION"}}suggestion{{else}}ok{{end}}"><i class="bi {{if eq .Status "ERROR"}}bi-x-circle-fill{{else if eq .Status "WARNING"}}bi-exclamation-triangle-fill{{else if eq .Status "SUGGESTION"}}bi-lightbulb-fill{{else}}bi-check-circle-fill{{end}}"></i>{{.Status}}</span></td>
<td><strong>{{.IPPrefix}}</strong></td>
<td>{{.CountryCode}}</td>
<td>{{.RegionCode}}</td>
<td>{{.City}}</td>
</tr>
<tr class="expand-details-row previous-values-row">
<td></td>
<td></td>
<td></td>
<td><strong>Previous values:</strong></td>
<td class="previous-value"><span class="default-country">{{.CountryCode}}</span></td>
<td class="previous-value"><span class="default-region">{{.RegionCode}}</span></td>
<td class="previous-value"><span class="default-city">{{.City}}</span></td>
</tr>
<tr class="expand-details-row issues-row">
<td></td>
<td></td>
<td colspan="5">
<div class="issues-header">
<strong>Issues:</strong>
{{if .Tunable}}
<button class="tune-all-btn" onclick="handleTuneButtonClick(this)">Tune</button>
{{end}}
</div>
{{range .Messages}}
<div class="message-line" data-id="{{.ID}}">
<span>{{.Text}}</span>
<input type="checkbox"{{if .Checked}} checked{{else}} disabled{{end}}>
</div>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
<!-- Pagination Controls -->
<div class="pagination-container">
<div class="pagination-info">
<span id="paginationInfo">Showing 0-0 of 0 rows</span>
</div>
<div class="pagination-controls">
<button class="pagination-btn" id="firstPage" title="First Page">
<i class="bi bi-chevron-bar-left"></i>
</button>
<button class="pagination-btn" id="prevPage" title="Previous Page">
<i class="bi bi-chevron-left"></i>
</button>
<div id="pageNumbers" style="display: flex; gap: 5px;"></div>
<button class="pagination-btn" id="nextPage" title="Next Page">
<i class="bi bi-chevron-right"></i>
</button>
<button class="pagination-btn" id="lastPage" title="Last Page">
<i class="bi bi-chevron-bar-right"></i>
</button>
</div>
<div class="page-size-selector">
<label for="pageSize" style="color: #333333; font-size: 0.9em;">Rows per page:</label>
<select id="pageSize">
<option value="100" selected>100</option>
<option value="all">All</option>
</select>
</div>
</div>
</div>
<div style="margin-top: 30px;">
<table class="metrics-table" style="margin-bottom: 0; width: 100%; border-bottom-left-radius: 0; border-bottom-right-radius: 0;">
<tr style="background-color: #6b6b6b;">
<td style="padding: 12px 15px; font-weight: bold; color: #ffffff; font-size: 1.1em; font-family: 'Raleway', sans-serif;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<span><i class="bi bi-map-fill" style="margin-right: 10px;"></i>Geographic Coverage Map</span>
<div class="mode-toggle-container" style="font-size: 1em;">
<div class="mode-toggle" id="mapModeToggle" onclick="switchMapMode()" tabindex="0" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();switchMapMode();}" style="width: 170px;">
<span class="mode-toggle-text active" id="mapModeBboxText">BBox</span>
<div class="mode-toggle-slider" style="width: 81px;"></div>
<span class="mode-toggle-text" id="mapModeH3Text">H3 Cells</span>
</div>
</div>
</div>
</td>
</tr>
</table>
<div id="summaryMap" style="height: 480px; border: 1px solid #bbb; border-top: none; border-radius: 0 0 4px 4px;"></div>
</div>
</div>
<div id="locationModal" class="modal" tabindex="-1">
<div class="modal-content" tabindex="0">
<h2>
<i class="bi bi-geo-alt" style="margin-right: 10px;"></i>Location Matches
<span class="close" tabindex="0">&times;</span>
</h2>
<div id="matchesList" class="matches-container"></div>
</div>
</div>
<script>
/**
* Table cell indices for accessing column data
* @type {Object<string, number>}
*/
// Constants
const CELL_INDEX = {
CHECKBOX: 0,
LINE: 1,
STATUS: 2,
IP_PREFIX: 3,
COUNTRY: 4,
REGION: 5,
CITY: 6,
MESSAGES: 7
};
/**
* Timing delays for UI interactions (in milliseconds)
* @type {Object<string, number>}
*/
const TIMING = {
MAP_INIT_DELAY: 100,
MODAL_FOCUS_DELAY: 150,
MAP_RESIZE_DELAY: 200
};
/**
* Global state variables for modal and map management
* @type {Object}
*/
// Global state
/** @type {Object<string, L.Map>} Maps indexed by mapId */
let maps = {};
/** @type {HTMLButtonElement|null} Currently active tune button reference */
let currentTuningButton = null;
/** @type {number} Currently selected match index in modal */
let selectedMatchIndex = 0;
/** @type {Array<HTMLElement>} Array of match item elements in modal */
let matchItems = [];
/** @type {boolean} Flag indicating if modal is currently open */
let isModalOpen = false;
let summaryMapInstance = null;
let summaryLayerGroup = null;
let currentMapMode = 'bbox';
let summaryRowData = [];
/**
* Stores CSV comment lines by line number for download functionality
* @type {Object<number, string>}
*/
// Comment map: stores CSV comments by line number
const commentMap = {{.Comments}};
// Modal elements
const modal = document.getElementById('locationModal');
const modalContent = document.querySelector('.modal-content');
const closeBtn = document.querySelector('.close');
// Modal functions
closeBtn.onclick = function() {
closeModal();
};
closeBtn.addEventListener('keydown', function(e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
closeModal();
}
});
window.onclick = function(event) {
if (event.target == modal) {
closeModal();
}
};
/**
* Closes the location matches modal and cleans up resources
* @returns {void}
*/
function closeModal() {
if (!isModalOpen) return;
modal.style.display = 'none';
isModalOpen = false;
currentTuningButton = null;
document.body.style.overflow = '';
Object.values(maps).forEach(map => {
if (map && map.remove) {
map.remove();
}
});
maps = {};
matchItems = [];
}
// Modal keyboard navigation
modal.addEventListener('keydown', function(event) {
if (!isModalOpen) return;
if (event.key === 'Escape' || event.key === 'Esc') {
event.preventDefault();
closeModal();
return;
}
if (event.key === 'Enter') {
event.preventDefault();
if (matchItems[selectedMatchIndex]) {
matchItems[selectedMatchIndex].click();
}
return;
}
if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
event.preventDefault();
navigateMatches(-1);
return;
}
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
event.preventDefault();
navigateMatches(1);
return;
}
if (event.key === '+' || event.key === '=') {
event.preventDefault();
zoomCurrentMap(1);
return;
}
if (event.key === '-' || event.key === '_') {
event.preventDefault();
zoomCurrentMap(-1);
return;
}
});
document.addEventListener('keydown', function(event) {
if ((event.key === 'Escape' || event.key === 'Esc') && isModalOpen) {
event.preventDefault();
closeModal();
}
});
/**
* Navigates between location match items with arrow keys
* @param {number} direction - Navigate direction: positive (right/down) or negative (left/up)
* @returns {void}
*/
function navigateMatches(direction) {
if (matchItems.length === 0) return;
if (matchItems[selectedMatchIndex]) {
matchItems[selectedMatchIndex].classList.remove('selected');
}
selectedMatchIndex = (selectedMatchIndex + direction + matchItems.length) % matchItems.length;
if (matchItems[selectedMatchIndex]) {
matchItems[selectedMatchIndex].classList.add('selected');
matchItems[selectedMatchIndex].scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
}
}
/**
* Zooms the currently selected map in or out
* @param {number} direction - Zoom direction: positive to zoom in, negative to zoom out
* @returns {void}
*/
function zoomCurrentMap(direction) {
const mapId = `map-${selectedMatchIndex}`;
const map = maps[mapId];
if (map) {
const currentZoom = map.getZoom();
map.setZoom(currentZoom + direction);
}
}
/**
* API endpoint for place search functionality
* @type {string}
*/
const API_ENDPOINT = 'https://mcp.fastah.ai/rest/geofeeds/place-search';
/**
* Fetches geolocation matches from the API for given location data
* @async
* @param {Array<Object>} rows - Array of row objects with location data (countryCode, regionCode, cityName)
* @returns {Promise<{results: Array<Object>}|null>} API response with matches or null on error
*/
async function fetchPlaceMatches(rows) {
const MAX_BATCH_SIZE = 1000;
let allResults = [];
let batchCount = Math.ceil(rows.length / MAX_BATCH_SIZE);
let lastError = null;
for (let i = 0; i < batchCount; i++) {
const batchRows = rows.slice(i * MAX_BATCH_SIZE, (i + 1) * MAX_BATCH_SIZE);
try {
const response = await fetch(API_ENDPOINT, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({ rows: batchRows })
});
if (!response.ok) {
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
}
const data = await response.json();
if (data && data.results) {
allResults = allResults.concat(data.results);
} else {
lastError = 'No results in API response';
}
} catch (error) {
console.error('Error calling place search API:', error);
lastError = error.message;
}
}
if (allResults.length === 0 && lastError) {
alert(`Failed to fetch location data: ${lastError}`);
return null;
}
return { results: allResults };
}
/**
* Prepares geofeed row data for API request
* @param {HTMLTableRowElement} dataRow - The table row element to extract data from
* @returns {Object|null} Object with countryCode, regionCode, cityName, maxResults and searchMode, or null if required data missing
*/
function prepareRowForApi(dataRow) {
const cells = dataRow.querySelectorAll('td');
if (cells.length <= CELL_INDEX.STATUS) return null;
const countryCode = cells[CELL_INDEX.COUNTRY].textContent.trim();
const regionCode = cells[CELL_INDEX.REGION].textContent.trim();
const cityName = cells[CELL_INDEX.CITY].textContent.trim();
if (!countryCode) {
return null;
}
return {
rowKey: crypto.randomUUID(),
countryCode: countryCode,
regionCode: regionCode || '',
cityName: cityName || '',
searchMode: 'auto'
};
}
/**
* Handles tune button click - fetches location matches for selected issues
* @async
* @param {HTMLButtonElement} button - The tune button that was clicked
* @returns {Promise<void>}
*/
async function handleTuneButtonClick(button) {
currentTuningButton = button;
const issuesContainer = button.closest('td');
const checkedBoxes = issuesContainer.querySelectorAll('.message-line input[type="checkbox"]:checked');
const selectedIssues = Array.from(checkedBoxes).map(cb => {
const messageLine = cb.closest('.message-line');
return {
id: messageLine.getAttribute('data-id'),
message: messageLine.querySelector('span').textContent
};
});
if (selectedIssues.length === 0) {
alert('Please select at least one issue to tune.');
return;
}
const detailsRow = button.closest('tr');
// Find the data row - it's before the expand-details-rows
let dataRow = detailsRow.previousElementSibling;
while (dataRow && dataRow.classList.contains('expand-details-row')) {
dataRow = dataRow.previousElementSibling;
}
if (!dataRow || !dataRow.classList.contains('expandable-row')) {
console.error('Could not find data row');
return;
}
const rowData = prepareRowForApi(dataRow);
if (!rowData) {
alert('Missing required location data for API call');
return;
}
const apiResponse = await fetchPlaceMatches([rowData]);
if (!apiResponse || !apiResponse.results || apiResponse.results.length === 0) {
alert('No results returned from API');
return;
}
const result = apiResponse.results[0];
if (result.matches && result.matches.length > 0) {
showLocationPopup(result.matches, selectedIssues);
} else {
alert('No location matches found for this entry');
}
}
/**
* Displays the location matches popup modal with maps and selectable options
* @param {Array<Object>} matches - Array of location match objects from API
* @param {Array<Object>} [selectedIssues=[]] - Array of selected issues for tracking
* @returns {void}
*/
function showLocationPopup(matches, selectedIssues = []) {
if (!matches || matches.length === 0) {
return;
}
const matchesList = document.getElementById('matchesList');
matchesList.innerHTML = '';
maps = {};
matchItems = [];
selectedMatchIndex = 0;
matches.forEach((match, index) => {
const matchItem = document.createElement('div');
matchItem.className = 'match-item';
if (index === 0) {
matchItem.classList.add('selected');
}
const mapId = `map-${index}`;
// Determine primary location text: city > region > country
let primaryLocation = match.placeName;
let locationType = 'city';
if (!primaryLocation || primaryLocation.trim() === '') {
primaryLocation = match.stateCode;
locationType = 'region';
}
if (!primaryLocation || primaryLocation.trim() === '') {
primaryLocation = match.countryCode;
locationType = 'country';
}
// Build secondary info (show what's available)
let secondaryInfo = [];
if (match.placeName && locationType !== 'city') {
secondaryInfo.push(`City: ${escapeHtml(match.placeName)}`);
}
if (match.stateCode && locationType !== 'region') {
secondaryInfo.push(`Region: ${escapeHtml(match.stateCode)}`);
}
if (match.countryCode && locationType !== 'country') {
secondaryInfo.push(`Country: ${escapeHtml(match.countryCode)}`);
}
matchItem.innerHTML = `
<div id="${mapId}" class="match-map"></div>
<div class="match-info-container">
<div class="match-header">${escapeHtml(primaryLocation)}</div>
${secondaryInfo.length > 0 ? `<div class="location-details">${secondaryInfo.join(' \u2022 ')}</div>` : ''}
</div>
`;
matchItem.addEventListener('click', function() {
selectLocationMatch(match);
});
matchesList.appendChild(matchItem);
matchItems.push(matchItem);
setTimeout(() => {
initializeMapForMatch(mapId, match);
}, TIMING.MAP_INIT_DELAY);
});
modal.style.display = 'block';
isModalOpen = true;
document.body.style.overflow = 'hidden';
setTimeout(() => {
if (modalContent) {
modalContent.focus();
}
}, TIMING.MODAL_FOCUS_DELAY);
}
/**
* Selects a location match and updates the corresponding table row
* @param {Object} match - Match object with countryCode, regionCode, name, and boundingBox
* @returns {void}
*/
function selectLocationMatch(match) {
if (!currentTuningButton) {
console.error('No tuning button reference found');
return;
}
const detailsRow = currentTuningButton.closest('tr');
// Find the data row - it's before the expand-details-rows
let dataRow = detailsRow.previousElementSibling;
while (dataRow && dataRow.classList.contains('expand-details-row')) {
dataRow = dataRow.previousElementSibling;
}
if (!dataRow || !dataRow.classList.contains('expandable-row')) {
console.error('Could not find data row');
return;
}
updateRowWithMatchData(dataRow, match);
closeModal();
}
/**
* Updates a table row with selected match data and stores original values
* @param {HTMLTableRowElement} dataRow - The table row to update
* @param {Object} match - Match object with countryCode, regionCode, and name
* @returns {void}
*/
function updateRowWithMatchData(dataRow, match) {
const cells = dataRow.querySelectorAll('td');
const currentCountry = cells[CELL_INDEX.COUNTRY].textContent;
const currentRegion = cells[CELL_INDEX.REGION].textContent;
const currentCity = cells[CELL_INDEX.CITY].textContent;
if (!dataRow.hasAttribute('data-original-country')) {
dataRow.setAttribute('data-original-country', currentCountry);
dataRow.setAttribute('data-original-region', currentRegion);
dataRow.setAttribute('data-original-city', currentCity);
}
cells[CELL_INDEX.COUNTRY].textContent = match.countryCode;
cells[CELL_INDEX.REGION].textContent = match.stateCode;
cells[CELL_INDEX.CITY].textContent = match.placeName;
// Find the previous values row (it's the first expand-details-row after the data row)
const previousValuesRow = dataRow.nextElementSibling;
if (previousValuesRow && previousValuesRow.classList.contains('previous-values-row')) {
previousValuesRow.classList.add('show');
const defaultCountry = previousValuesRow.querySelector('.default-country');
const defaultRegion = previousValuesRow.querySelector('.default-region');
const defaultCity = previousValuesRow.querySelector('.default-city');
if (defaultCountry) defaultCountry.textContent = dataRow.getAttribute('data-original-country');
if (defaultRegion) defaultRegion.textContent = dataRow.getAttribute('data-original-region');
if (defaultCity) defaultCity.textContent = dataRow.getAttribute('data-original-city');
}
}
/**
* Initializes a Leaflet map with location boundary and marker for a match
* @param {string} mapId - The DOM element ID where map will render
* @param {Object} match - Match object with name, boundingBox, regionCode, countryCode
* @returns {void}
*/
function initializeMapForMatch(mapId, match) {
try {
const mapInstance = L.map(mapId, {
scrollWheelZoom: false,
dragging: true,
zoomControl: true
});
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap',
maxZoom: 19
}).addTo(mapInstance);
const bbox = match.boundingBox;
if (bbox && bbox.length === 4) {
const [west, north, east, south] = bbox;
const centerLat = (south + north) / 2;
const centerLng = (west + east) / 2;
const isPoint = (west === east && north === south);
if (isPoint) {
const offset = 0.05;
const bounds = [
[south - offset, west - offset],
[north + offset, east + offset]
];
L.rectangle(bounds, {
color: '#dc3545',
weight: 3,
opacity: 1,
fillColor: '#dc3545',
fillOpacity: 0.2
}).addTo(mapInstance);
mapInstance.fitBounds(bounds, { padding: [30, 30] });
} else {
const bounds = [
[south, west],
[north, east]
];
L.rectangle(bounds, {
color: '#dc3545',
weight: 3,
opacity: 1,
fillColor: '#dc3545',
fillOpacity: 0.2
}).addTo(mapInstance);
mapInstance.fitBounds(bounds, { padding: [20, 20] });
}
L.marker([centerLat, centerLng]).addTo(mapInstance)
.bindPopup(`<b>${escapeHtml(match.placeName)}</b><br>${escapeHtml(match.stateCode)}`);
maps[mapId] = mapInstance;
setTimeout(() => {
mapInstance.invalidateSize();
}, TIMING.MAP_RESIZE_DELAY);
}
} catch (error) {
console.error('Error initializing map:', error);
}
}
/**
* Sets the report mode between 'viewing' and 'tuning'
* Tuning mode shows checkboxes and expands rows with issues
* @param {string} mode - 'viewing' or 'tuning'
* @returns {void}
*/
function setMode(mode) {
const toggle = document.getElementById('modeToggle');
const viewingText = document.getElementById('viewingText');
const tuningText = document.getElementById('tuningText');
if (mode === 'tuning') {
document.body.classList.add('tuning-mode');
toggle.classList.add('tuning');
viewingText.classList.remove('active');
tuningText.classList.add('active');
// Only expand rows with issues
const expandableRows = document.querySelectorAll('.expandable-row');
expandableRows.forEach(row => {
const hasError = row.getAttribute('data-has-error') === 'true';
const hasWarning = row.getAttribute('data-has-warning') === 'true';
const hasSuggestion = row.getAttribute('data-has-suggestion') === 'true';
if (hasError || hasWarning || hasSuggestion) {
let detailRow = row.nextElementSibling;
while (detailRow && detailRow.classList.contains('expand-details-row')) {
// Only show issues-row, not previous-values-row
if (detailRow.classList.contains('issues-row')) {
detailRow.classList.add('show');
}
detailRow = detailRow.nextElementSibling;
}
}
});
} else {
document.body.classList.remove('tuning-mode');
toggle.classList.remove('tuning');
viewingText.classList.add('active');
tuningText.classList.remove('active');
document.querySelectorAll('.expand-details-row').forEach(row => {
row.classList.remove('show');
});
}
// Refresh pagination after mode change
updatePagination();
}
/**
* Toggles between viewing and tuning modes
* @returns {void}
*/
function toggleMode() {
const isTuning = document.body.classList.contains('tuning-mode');
setMode(isTuning ? 'viewing' : 'tuning');
}
/**
* Escapes HTML special characters to prevent XSS attacks
* @param {string} text - Text to escape
* @returns {string} Escaped HTML text
*/
function escapeHtml(text) {
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return text.replace(/[&<>"']/g, m => map[m]);
}
/**
* Filters the entries table based on current filter input values
* Resets to page 1 and updates pagination
* @returns {void}
*/
function filterTable() {
const countryFilter = document.getElementById('filterCountry').value.toLowerCase();
const regionFilter = document.getElementById('filterRegion').value.toLowerCase();
const cityFilter = document.getElementById('filterCity').value.toLowerCase();
const statusFilter = document.getElementById('filterStatus').value;
const ipPrefixFilter = document.getElementById('filterIpPrefix').value.toLowerCase();
const allExpandableRows = document.querySelectorAll('.expandable-row');
// Filter rows and update allRows array
allRows = Array.from(allExpandableRows).filter(row => {
const cells = row.querySelectorAll('td');
if (cells.length <= CELL_INDEX.STATUS) return false;
const rowData = {
country: cells[CELL_INDEX.COUNTRY].textContent.toLowerCase(),
region: cells[CELL_INDEX.REGION].textContent.toLowerCase(),
city: cells[CELL_INDEX.CITY].textContent.toLowerCase(),
status: cells[CELL_INDEX.STATUS].textContent,
ipPrefix: cells[CELL_INDEX.IP_PREFIX].textContent.toLowerCase()
};
return (
(!countryFilter || rowData.country.includes(countryFilter)) &&
(!regionFilter || rowData.region.includes(regionFilter)) &&
(!cityFilter || rowData.city.includes(cityFilter)) &&
(!statusFilter || rowData.status === statusFilter) &&
(!ipPrefixFilter || rowData.ipPrefix.includes(ipPrefixFilter))
);
});
// Reset to page 1 and update pagination
currentPage = 1;
updatePagination();
}
/**
* Pagination state and configuration
* @type {Object}
*/
// Pagination state
/** @type {number} Current page number (1-indexed) */
let currentPage = 1;
/** @type {number} Number of rows to display per page */
let rowsPerPage = 100;
/** @type {Array<HTMLTableRowElement>} Array of expandable rows after filtering */
let allRows = [];
// Initialize on page load
window.addEventListener('DOMContentLoaded', function() {
// Initialize pagination
allRows = Array.from(document.querySelectorAll('.expandable-row'));
initializePagination();
// Setup select all checkbox
const selectAllCheckbox = document.getElementById('selectAll');
const rowCheckboxes = document.querySelectorAll('.row-checkbox');
selectAllCheckbox.addEventListener('change', function() {
rowCheckboxes.forEach(checkbox => {
checkbox.checked = selectAllCheckbox.checked;
});
});
// Update select all checkbox when individual checkboxes change
rowCheckboxes.forEach(checkbox => {
checkbox.addEventListener('click', function(e) {
e.stopPropagation(); // Prevent row expansion when clicking checkbox
});
checkbox.addEventListener('keydown', function(e) {
if (e.key === ' ' || e.key === 'Enter') {
e.stopPropagation(); // Prevent row expansion when using keyboard on checkbox
}
});
checkbox.addEventListener('change', function(e) {
e.stopPropagation(); // Prevent row expansion when clicking checkbox
const allChecked = Array.from(rowCheckboxes).every(cb => cb.checked);
const noneChecked = Array.from(rowCheckboxes).every(cb => !cb.checked);
selectAllCheckbox.checked = allChecked;
selectAllCheckbox.indeterminate = !allChecked && !noneChecked;
});
});
// Setup expandable rows
const expandableRows = document.querySelectorAll('.expandable-row');
expandableRows.forEach(row => {
const hasError = row.getAttribute('data-has-error') === 'true';
const hasWarning = row.getAttribute('data-has-warning') === 'true';
const hasSuggestion = row.getAttribute('data-has-suggestion') === 'true';
// Only make rows with issues focusable
if (hasError || hasWarning || hasSuggestion) {
row.setAttribute('tabindex', '0');
// Click handler
row.addEventListener('click', function(e) {
// Don't expand if clicking on checkbox
if (e.target.classList.contains('row-checkbox')) {
return;
}
let detailRow = this.nextElementSibling;
let expanding = false;
// First pass: check if we are expanding (issues-row will be shown)
while (detailRow && detailRow.classList.contains('expand-details-row')) {
if (detailRow.classList.contains('issues-row')) {
expanding = !detailRow.classList.contains('show');
break;
}
detailRow = detailRow.nextElementSibling;
}
detailRow = this.nextElementSibling;
while (detailRow && detailRow.classList.contains('expand-details-row')) {
if (detailRow.classList.contains('issues-row')) {
// Always toggle issues-row
detailRow.classList.toggle('show');
} else if (detailRow.classList.contains('previous-values-row')) {
if (expanding) {
// Show previous-values-row if this row has original data (was tuned)
if (row.hasAttribute('data-original-country') ||
row.hasAttribute('data-original-region') ||
row.hasAttribute('data-original-city')) {
detailRow.classList.add('show');
}
} else {
// If collapsing, hide previous-values-row
detailRow.classList.remove('show');
}
}
detailRow = detailRow.nextElementSibling;
}
// Refresh pagination to show/hide the toggled detail rows
updatePagination();
});
// Keyboard handler
row.addEventListener('keydown', function(e) {
// Don't handle if focus is on checkbox
if (document.activeElement.classList.contains('row-checkbox')) {
return;
}
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
let detailRow = this.nextElementSibling;
let expanding = false;
// First pass: check if we are expanding (issues-row will be shown)
while (detailRow && detailRow.classList.contains('expand-details-row')) {
if (detailRow.classList.contains('issues-row')) {
expanding = !detailRow.classList.contains('show');
break;
}
detailRow = detailRow.nextElementSibling;
}
detailRow = this.nextElementSibling;
while (detailRow && detailRow.classList.contains('expand-details-row')) {
if (detailRow.classList.contains('issues-row')) {
// Always toggle issues-row
detailRow.classList.toggle('show');
} else if (detailRow.classList.contains('previous-values-row')) {
if (expanding) {
// Show previous-values-row if this row has original data (was tuned)
if (row.hasAttribute('data-original-country') ||
row.hasAttribute('data-original-region') ||
row.hasAttribute('data-original-city')) {
detailRow.classList.add('show');
}
} else {
// If collapsing, hide previous-values-row
detailRow.classList.remove('show');
}
}
detailRow = detailRow.nextElementSibling;
}
// Refresh pagination to show/hide the toggled detail rows
updatePagination();
}
});
} else {
row.style.cursor = 'default';
}
});
// Toggle Expand All button
let isExpanded = false;
document.getElementById('toggleExpandAll').addEventListener('click', function() {
const expandableRows = document.querySelectorAll('.expandable-row');
const button = document.getElementById('toggleExpandAll');
if (!isExpanded) {
// Show all
expandableRows.forEach(row => {
const checkbox = row.querySelector('.row-checkbox');
if (!checkbox || !checkbox.checked) return; // Only work on checked rows
const hasError = row.getAttribute('data-has-error') === 'true';
const hasWarning = row.getAttribute('data-has-warning') === 'true';
const hasSuggestion = row.getAttribute('data-has-suggestion') === 'true';
// Only expand rows with issues
if (hasError || hasWarning || hasSuggestion) {
let detailRow = row.nextElementSibling;
while (detailRow && detailRow.classList.contains('expand-details-row')) {
// Show issues-row always
if (detailRow.classList.contains('issues-row')) {
detailRow.classList.add('show');
} else if (detailRow.classList.contains('previous-values-row')) {
// Show previous-values-row if this row has original data (was tuned)
if (row.hasAttribute('data-original-country') ||
row.hasAttribute('data-original-region') ||
row.hasAttribute('data-original-city')) {
detailRow.classList.add('show');
}
}
detailRow = detailRow.nextElementSibling;
}
}
});
button.innerHTML = '<i class="bi bi-arrows-collapse" style="margin-right: 5px;"></i>Collapse Rows';
isExpanded = true;
} else {
// Collapse all
expandableRows.forEach(row => {
const checkbox = row.querySelector('.row-checkbox');
if (!checkbox || !checkbox.checked) return; // Only work on checked rows
let detailRow = row.nextElementSibling;
while (detailRow && detailRow.classList.contains('expand-details-row')) {
detailRow.classList.remove('show');
detailRow = detailRow.nextElementSibling;
}
});
button.innerHTML = '<i class="bi bi-arrows-expand" style="margin-right: 5px;"></i>Expand Rows';
isExpanded = false;
}
// Refresh pagination to show expanded rows correctly
updatePagination();
});
// Toggle Filters button
document.getElementById('toggleFilters').addEventListener('click', function() {
const filterRow = document.getElementById('filterRow');
const button = document.getElementById('toggleFilters');
if (filterRow.style.display === 'none') {
filterRow.style.display = '';
button.innerHTML = '<i class="bi bi-funnel-fill" style="margin-right: 5px;"></i>Hide Filters';
} else {
filterRow.style.display = 'none';
button.innerHTML = '<i class="bi bi-funnel-fill" style="margin-right: 5px;"></i>Show Filters';
}
});
// Tune All button
document.getElementById('tuneAll').addEventListener('click', async function() {
const expandableRows = document.querySelectorAll('.expandable-row');
let updatedCount = 0;
let skippedCount = 0;
expandableRows.forEach(dataRow => {
const checkbox = dataRow.querySelector('.row-checkbox');
if (!checkbox || !checkbox.checked) {
skippedCount++;
return; // Only work on checked rows
}
const isTunable = dataRow.getAttribute('data-tunable') === 'true';
if (!isTunable) {
skippedCount++;
return;
}
const tunedCountry = dataRow.getAttribute('data-tuned-country');
const tunedRegion = dataRow.getAttribute('data-tuned-region');
const tunedCity = dataRow.getAttribute('data-tuned-city');
if (!tunedCountry && !tunedRegion && !tunedCity) {
skippedCount++;
return;
}
const cells = dataRow.querySelectorAll('td');
if (cells.length <= CELL_INDEX.STATUS) {
skippedCount++;
return;
}
if (!dataRow.hasAttribute('data-original-country')) {
dataRow.setAttribute('data-original-country', cells[CELL_INDEX.COUNTRY].textContent);
dataRow.setAttribute('data-original-region', cells[CELL_INDEX.REGION].textContent);
dataRow.setAttribute('data-original-city', cells[CELL_INDEX.CITY].textContent);
}
if (tunedCountry) cells[CELL_INDEX.COUNTRY].textContent = tunedCountry;
if (tunedRegion) cells[CELL_INDEX.REGION].textContent = tunedRegion;
if (tunedCity) cells[CELL_INDEX.CITY].textContent = tunedCity;
// Find the previous values row
const previousValuesRow = dataRow.nextElementSibling;
if (previousValuesRow && previousValuesRow.classList.contains('previous-values-row')) {
previousValuesRow.classList.add('show');
const defaultCountry = previousValuesRow.querySelector('.default-country');
const defaultRegion = previousValuesRow.querySelector('.default-region');
const defaultCity = previousValuesRow.querySelector('.default-city');
if (defaultCountry) defaultCountry.textContent = dataRow.getAttribute('data-original-country');
if (defaultRegion) defaultRegion.textContent = dataRow.getAttribute('data-original-region');
if (defaultCity) defaultCity.textContent = dataRow.getAttribute('data-original-city');
}
updatedCount++;
});
// Refresh pagination to update displayed values
updatePagination();
alert(`Successfully updated ${updatedCount} rows (${skippedCount} rows skipped - not tunable or no tuned values available)`);
});
// Add filter event listeners
document.getElementById('filterCountry').addEventListener('input', filterTable);
document.getElementById('filterRegion').addEventListener('input', filterTable);
document.getElementById('filterCity').addEventListener('input', filterTable);
document.getElementById('filterStatus').addEventListener('change', filterTable);
document.getElementById('filterIpPrefix').addEventListener('input', filterTable);
// Reset filters button
document.getElementById('resetFilters').addEventListener('click', function() {
document.getElementById('filterCountry').value = '';
document.getElementById('filterRegion').value = '';
document.getElementById('filterCity').value = '';
document.getElementById('filterStatus').value = '';
document.getElementById('filterIpPrefix').value = '';
// Reset to all rows and update pagination
allRows = Array.from(document.querySelectorAll('.expandable-row'));
currentPage = 1;
updatePagination();
});
// Download CSV button
document.getElementById('downloadCSV').addEventListener('click', function() {
const rows = document.querySelectorAll('.expandable-row');
const rowDataMap = {};
// Collect all data rows with their line numbers
rows.forEach(row => {
const cells = row.querySelectorAll('td');
if (cells.length > CELL_INDEX.CITY) {
const lineNum = parseInt(cells[CELL_INDEX.LINE].textContent.trim());
const ipPrefix = cells[CELL_INDEX.IP_PREFIX].textContent.trim();
const country = cells[CELL_INDEX.COUNTRY].textContent.trim();
const region = cells[CELL_INDEX.REGION].textContent.trim();
const city = cells[CELL_INDEX.CITY].textContent.trim();
rowDataMap[lineNum] = [ipPrefix, country, region, city];
}
});
// Build CSV content with comments in the correct positions
const csvLines = [];
// Find max line number
const allLineNumbers = Object.keys(rowDataMap).map(n => parseInt(n));
const maxLine = Math.max(...allLineNumbers);
// Iterate through all line numbers from 1 to maxLine
for (let lineNum = 1; lineNum <= maxLine; lineNum++) {
if (lineNum in commentMap) {
// This line is a comment
csvLines.push(commentMap[lineNum]);
} else if (rowDataMap[lineNum]) {
// This line is a data row
const row = rowDataMap[lineNum];
const csvRow = row.map(cell => {
// Escape quotes and wrap in quotes if contains comma, quote, or newline
const cellStr = String(cell);
if (cellStr.includes(',') || cellStr.includes('"') || cellStr.includes('\n')) {
return '"' + cellStr.replace(/"/g, '""') + '"';
}
return cellStr;
}).join(',');
csvLines.push(csvRow);
}
// If neither comment nor data, skip the line (maintains original CSV structure)
}
const csvContent = csvLines.join('\n');
// Create download link
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
// Get filename from inputFileMetrics, ensure it has .csv extension
let filename =
document.getElementById('inputFileMetrics')
.textContent
.trim()
.split(/[/\\]/)
.pop() || 'geofeed_report.csv';
if (!filename.toLowerCase().endsWith('.csv')) {
filename += '.csv';
}
link.setAttribute('href', url);
link.setAttribute('download', filename);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
});
// Pagination event listeners
document.getElementById('pageSize').addEventListener('change', function() {
const value = this.value;
if (value === 'all') {
rowsPerPage = allRows.length;
} else {
rowsPerPage = parseInt(value);
}
currentPage = 1;
updatePagination();
});
document.getElementById('firstPage').addEventListener('click', function() {
currentPage = 1;
updatePagination();
});
document.getElementById('prevPage').addEventListener('click', function() {
if (currentPage > 1) {
currentPage--;
updatePagination();
}
});
document.getElementById('nextPage').addEventListener('click', function() {
const totalPages = Math.ceil(allRows.length / rowsPerPage);
if (currentPage < totalPages) {
currentPage++;
updatePagination();
}
});
document.getElementById('lastPage').addEventListener('click', function() {
currentPage = Math.ceil(allRows.length / rowsPerPage);
updatePagination();
});
initSummaryMap();
});
/**
* Initializes pagination on page load
* @returns {void}
*/
function initializePagination() {
updatePagination();
}
function initSummaryMap() {
try {
const summaryMapEl = document.getElementById('summaryMap');
if (!summaryMapEl) return;
summaryMapInstance = L.map('summaryMap', {
scrollWheelZoom: true,
dragging: true,
zoomControl: true,
center: [20, 0],
zoom: 2
});
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '\u00a9 <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 19
}).addTo(summaryMapInstance);
summaryLayerGroup = L.layerGroup().addTo(summaryMapInstance);
// Parse and cache all row data once
summaryRowData = [];
const expandableRows = document.querySelectorAll('.expandable-row');
expandableRows.forEach(row => {
const cells = row.querySelectorAll('td');
const hasError = row.getAttribute('data-has-error') === 'true';
const hasWarning = row.getAttribute('data-has-warning') === 'true';
const hasSugg = row.getAttribute('data-has-suggestion') === 'true';
let color = '#28a745';
if (hasError) color = '#dc3545';
else if (hasWarning) color = '#fd7e14';
else if (hasSugg) color = '#17a2b8';
const country = cells[CELL_INDEX.COUNTRY] ? cells[CELL_INDEX.COUNTRY].textContent.trim() : '';
const region = cells[CELL_INDEX.REGION] ? cells[CELL_INDEX.REGION].textContent.trim() : '';
const city = cells[CELL_INDEX.CITY] ? cells[CELL_INDEX.CITY].textContent.trim() : '';
const parts = [];
if (city && region && country) {
parts.push('<b>' + escapeHtml(city) + '</b>' + ' &bull; ' + escapeHtml(region) + ' &bull; ' + escapeHtml(country));
} else if (city && country) {
parts.push('<b>' + escapeHtml(city) + '</b>' + ' &bull; ' + escapeHtml(country));
} else if (region && country) {
parts.push('<b>' + escapeHtml(region) + '</b>' + ' &bull; ' + escapeHtml(country));
} else if (country) {
parts.push('<b>' + escapeHtml(country) + '</b>');
}
summaryRowData.push({
rowId: row.id,
bbox: row.getAttribute('data-bounding-box') || '',
h3cells: row.getAttribute('data-h3-cells') || '',
color: color,
popup: parts.join('<br>')
});
});
setTimeout(() => {
summaryMapInstance.invalidateSize();
switchMapMode('bbox');
}, 350);
} catch (err) {
console.error('initSummaryMap error:', err);
}
}
function switchMapMode(mode) {
const toggle = document.getElementById('mapModeToggle');
const bboxText = document.getElementById('mapModeBboxText');
const h3Text = document.getElementById('mapModeH3Text');
// If called with no argument, act as a toggle
if (mode === undefined) {
mode = currentMapMode === 'bbox' ? 'h3' : 'bbox';
}
if (mode === 'h3') {
toggle.classList.add('h3mode');
bboxText.classList.remove('active');
h3Text.classList.add('active');
} else {
toggle.classList.remove('h3mode');
bboxText.classList.add('active');
h3Text.classList.remove('active');
}
renderSummaryMapLayers(mode);
}
function renderSummaryMapLayers(mode) {
if (!summaryMapInstance || !summaryLayerGroup) return;
currentMapMode = mode;
summaryLayerGroup.clearLayers();
// Determine which row IDs are visible on the current page
const startIndex = (currentPage - 1) * rowsPerPage;
const endIndex = Math.min(startIndex + rowsPerPage, allRows.length);
const visibleIds = new Set();
for (let i = startIndex; i < endIndex; i++) {
visibleIds.add(allRows[i].id);
}
const tempBoundsLayers = [];
const seenBboxKeys = new Set(); // deduplicate identical bounding boxes / h3 cell sets
summaryRowData.forEach(data => {
// Only render entries whose row is on the current page
if (!visibleIds.has(data.rowId)) return;
if (mode === 'bbox') {
const raw = data.bbox.replace(/[\[\]]/g, '').trim();
const pts = raw.split(/[\s,]+/).map(Number);
if (pts.length !== 4 || pts.some(v => isNaN(v))) return;
// Skip duplicate bounding boxes
const bboxKey = pts.join(',');
if (seenBboxKeys.has(bboxKey)) return;
seenBboxKeys.add(bboxKey);
const [west, north, east, south] = pts;
const lb = [[south, west], [north, east]];
const rect = L.rectangle(lb, {
color: data.color, weight: 3, opacity: 1,
fillColor: data.color, fillOpacity: 0.35
});
rect.bindPopup(data.popup);
summaryLayerGroup.addLayer(rect);
const cm = L.circleMarker([(south + north) / 2, (west + east) / 2], {
radius: 5, color: data.color, fillColor: data.color, fillOpacity: 0.9, weight: 1
});
cm.bindPopup(data.popup);
summaryLayerGroup.addLayer(cm);
tempBoundsLayers.push(L.rectangle(lb));
} else {
// H3 cells mode
const raw = data.h3cells.replace(/[\[\]]/g, '').trim();
if (!raw) return;
// Skip duplicate h3 cell sets
if (seenBboxKeys.has(raw)) return;
seenBboxKeys.add(raw);
const cellIds = raw.split(/[\s,]+/).filter(Boolean);
cellIds.forEach(cellId => {
try {
const boundary = h3.cellToBoundary(cellId); // returns [[lat,lng],...]
const poly = L.polygon(boundary, {
color: data.color, weight: 2, opacity: 0.9,
fillColor: data.color, fillOpacity: 0.35
});
poly.bindPopup(data.popup);
summaryLayerGroup.addLayer(poly);
const b = poly.getBounds();
tempBoundsLayers.push(L.rectangle([[b.getSouth(), b.getWest()], [b.getNorth(), b.getEast()]]));
} catch (e) {
console.warn('H3 render error for cell', cellId, e);
}
});
}
});
if (tempBoundsLayers.length > 0) {
const group = L.featureGroup(tempBoundsLayers);
summaryMapInstance.fitBounds(group.getBounds(), { padding: [40, 40], maxZoom: 14 });
}
}
/**
* Updates pagination display for current page
* Shows/hides rows based on current page and rowsPerPage
* @returns {void}
*/
function updatePagination() {
const totalPages = Math.max(1, Math.ceil(allRows.length / rowsPerPage));
// Ensure currentPage is within valid range
if (currentPage > totalPages) {
currentPage = totalPages;
}
if (currentPage < 1) {
currentPage = 1;
}
const startIndex = (currentPage - 1) * rowsPerPage;
const endIndex = Math.min(startIndex + rowsPerPage, allRows.length);
// Hide ALL expandable rows first (not just filtered ones)
const allExpandableRows = document.querySelectorAll('.expandable-row');
allExpandableRows.forEach(row => {
row.style.display = 'none';
// Hide associated detail rows
let nextRow = row.nextElementSibling;
while (nextRow && nextRow.classList.contains('expand-details-row')) {
nextRow.style.display = 'none';
nextRow = nextRow.nextElementSibling;
}
});
// Show rows for current page
for (let i = startIndex; i < endIndex; i++) {
const row = allRows[i];
row.style.display = '';
// Show associated detail rows if they have 'show' class
let nextRow = row.nextElementSibling;
while (nextRow && nextRow.classList.contains('expand-details-row')) {
if (nextRow.classList.contains('show')) {
nextRow.style.display = '';
}
nextRow = nextRow.nextElementSibling;
}
}
// Update pagination info
if (allRows.length === 0) {
document.getElementById('paginationInfo').textContent = 'Showing 0 of 0 rows';
} else {
document.getElementById('paginationInfo').textContent =
`Showing ${startIndex + 1}-${endIndex} of ${allRows.length} rows`;
}
// Update button states
document.getElementById('firstPage').disabled = currentPage === 1 || allRows.length === 0;
document.getElementById('prevPage').disabled = currentPage === 1 || allRows.length === 0;
document.getElementById('nextPage').disabled = currentPage === totalPages || allRows.length === 0;
document.getElementById('lastPage').disabled = currentPage === totalPages || allRows.length === 0;
// Update page numbers
updatePageNumbers(totalPages);
// Refresh the Geographic Coverage Map to show only current-page entries
renderSummaryMapLayers(currentMapMode);
}
/**
* Updates page number buttons display with ellipsis for large page counts
* @param {number} totalPages - Total number of pages available
* @returns {void}
*/
function updatePageNumbers(totalPages) {
const pageNumbersContainer = document.getElementById('pageNumbers');
pageNumbersContainer.innerHTML = '';
// Show max 5 page numbers with ellipsis
const maxVisible = 5;
let startPage = Math.max(1, currentPage - Math.floor(maxVisible / 2));
let endPage = Math.min(totalPages, startPage + maxVisible - 1);
// Adjust if we're near the end
if (endPage - startPage < maxVisible - 1) {
startPage = Math.max(1, endPage - maxVisible + 1);
}
// Add first page and ellipsis if needed
if (startPage > 1) {
addPageButton(1);
if (startPage > 2) {
const ellipsis = document.createElement('span');
ellipsis.textContent = '...';
ellipsis.style.padding = '6px 8px';
ellipsis.style.color = '#333';
pageNumbersContainer.appendChild(ellipsis);
}
}
// Add page numbers
for (let i = startPage; i <= endPage; i++) {
addPageButton(i);
}
// Add ellipsis and last page if needed
if (endPage < totalPages) {
if (endPage < totalPages - 1) {
const ellipsis = document.createElement('span');
ellipsis.textContent = '...';
ellipsis.style.padding = '6px 8px';
ellipsis.style.color = '#333';
pageNumbersContainer.appendChild(ellipsis);
}
addPageButton(totalPages);
}
}
/**
* Creates and adds a page number button to pagination controls
* @param {number} pageNum - Page number for the button
* @returns {void}
*/
function addPageButton(pageNum) {
const pageNumbersContainer = document.getElementById('pageNumbers');
const btn = document.createElement('button');
btn.className = 'pagination-btn' + (pageNum === currentPage ? ' active' : '');
btn.textContent = pageNum;
btn.addEventListener('click', function() {
currentPage = pageNum;
updatePagination();
});
pageNumbersContainer.appendChild(btn);
}
</script>
</body>
</html>