mirror of
https://github.com/github/awesome-copilot.git
synced 2026-04-11 02:35:55 +00:00
2306 lines
98 KiB
HTML
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">×</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 = {
|
|
'&': '&',
|
|
'<': '<',
|
|
'>': '>',
|
|
'"': '"',
|
|
"'": '''
|
|
};
|
|
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>' + ' • ' + escapeHtml(region) + ' • ' + escapeHtml(country));
|
|
} else if (city && country) {
|
|
parts.push('<b>' + escapeHtml(city) + '</b>' + ' • ' + escapeHtml(country));
|
|
} else if (region && country) {
|
|
parts.push('<b>' + escapeHtml(region) + '</b>' + ' • ' + 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>
|