Compare commits
10 Commits
e7dd9a09ce
...
a0abfb3a62
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0abfb3a62 | ||
|
|
96950992df | ||
|
|
df2e176b35 | ||
|
|
08e9b8d962 | ||
|
|
990c989fa3 | ||
|
|
e302c425c7 | ||
|
|
58bf76fa82 | ||
|
|
22f219c82e | ||
|
|
6391823579 | ||
|
|
fae091801c |
1677
deploy/server.js
1677
deploy/server.js
File diff suppressed because it is too large
Load Diff
3332
public/index-editorial.html
Normal file
3332
public/index-editorial.html
Normal file
File diff suppressed because it is too large
Load Diff
@ -7,8 +7,8 @@
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css" />
|
||||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
<script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"></script>
|
||||||
<style>
|
<style>
|
||||||
*{margin:0;padding:0;box-sizing:border-box}
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
:root{
|
:root{
|
||||||
@ -263,6 +263,10 @@ a{color:var(--blue);text-decoration:none;transition:color .2s}a:hover{color:var(
|
|||||||
.prefix-badge{font-size:.7rem;padding:.2rem .5rem;border-radius:4px;background:var(--bg);border:1px solid var(--border);color:var(--text-dim);font-family:'Inter',monospace}
|
.prefix-badge{font-size:.7rem;padding:.2rem .5rem;border-radius:4px;background:var(--bg);border:1px solid var(--border);color:var(--text-dim);font-family:'Inter',monospace}
|
||||||
.routing-footer{display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:.75rem;margin-top:1rem;padding-top:.75rem;border-top:1px solid var(--border)}
|
.routing-footer{display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:.75rem;margin-top:1rem;padding-top:.75rem;border-top:1px solid var(--border)}
|
||||||
.routing-footer-left{display:flex;align-items:center;gap:.5rem;font-size:.8rem;color:var(--text-dim)}
|
.routing-footer-left{display:flex;align-items:center;gap:.5rem;font-size:.8rem;color:var(--text-dim)}
|
||||||
|
.pc-popup .maplibregl-popup-content{background:#1a1b26;border:1px solid #2a2b3d;border-radius:6px;padding:8px 10px;color:#e2e8f0;font-family:Inter,sans-serif;font-size:12px;box-shadow:0 4px 16px rgba(0,0,0,.5)}
|
||||||
|
.pc-popup .maplibregl-popup-tip{border-top-color:#1a1b26;border-bottom-color:#1a1b26}
|
||||||
|
.maplibregl-ctrl-attrib{background:rgba(26,27,38,.7)!important;color:#8892a4!important;font-size:10px!important}
|
||||||
|
.maplibregl-ctrl-attrib a{color:#3b82f6!important}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@ -434,10 +438,25 @@ a{color:var(--blue);text-decoration:none;transition:color .2s}a:hover{color:var(
|
|||||||
<div class="card full" id="mapCard" style="display:none">
|
<div class="card full" id="mapCard" style="display:none">
|
||||||
<div class="card-title" style="cursor:pointer" onclick="toggleExpand(this)">
|
<div class="card-title" style="cursor:pointer" onclick="toggleExpand(this)">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 10-16 0c0 3 2.7 7 8 11.7z"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 10-16 0c0 3 2.7 7 8 11.7z"/></svg>
|
||||||
Network Footprint Map
|
Global Infrastructure Map
|
||||||
</div>
|
</div>
|
||||||
<div class="expand-body">
|
<div class="expand-body">
|
||||||
<div id="networkMap" style="height:450px;border-radius:8px;background:#1a1b26"></div>
|
<!-- Layer toggle bar -->
|
||||||
|
<div id="mapLayerBar" style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px;align-items:center">
|
||||||
|
<span style="font-size:.7rem;color:var(--muted);margin-right:2px;text-transform:uppercase;letter-spacing:.05em">Layers:</span>
|
||||||
|
<button class="map-layer-btn active" id="layerBtnPops" onclick="toggleMapLayer('pops',this)" style="padding:3px 10px;font-size:.72rem;border-radius:4px;border:1px solid #7dcfff;color:#7dcfff;background:rgba(125,207,255,.12);cursor:pointer">● ASN PoPs</button>
|
||||||
|
<button class="map-layer-btn" id="layerBtnCables" onclick="toggleMapLayer('cables',this)" style="padding:3px 10px;font-size:.72rem;border-radius:4px;border:1px solid #4a5568;color:var(--muted);background:transparent;cursor:pointer">▪ Submarine Cables</button>
|
||||||
|
<button class="map-layer-btn" id="layerBtnGlobalFacs" onclick="toggleMapLayer('globalFacs',this)" style="padding:3px 10px;font-size:.72rem;border-radius:4px;border:1px solid #4a5568;color:var(--muted);background:transparent;cursor:pointer">○ Global Datacenters</button>
|
||||||
|
<span id="mapLoadingIndicator" style="font-size:.7rem;color:var(--muted);margin-left:4px;display:none">Loading...</span>
|
||||||
|
</div>
|
||||||
|
<div id="networkMap" style="height:500px;border-radius:8px;background:#1a1b26;position:relative"></div>
|
||||||
|
<!-- Legend -->
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:12px;margin-top:8px;font-size:.7rem;color:var(--muted)">
|
||||||
|
<span><span style="color:#ff9e64">●</span> IXP</span>
|
||||||
|
<span><span style="color:#7dcfff">●</span> Datacenter/Facility</span>
|
||||||
|
<span><span style="color:#9ece6a">▬</span> Submarine Cable</span>
|
||||||
|
<span><span style="color:#666">○</span> Global Datacenter (PeeringDB)</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -1240,18 +1259,127 @@ function renderDashboard(d) {
|
|||||||
// Note: innerHTML usage above is the existing pattern in this codebase; all user-facing
|
// Note: innerHTML usage above is the existing pattern in this codebase; all user-facing
|
||||||
// strings are escaped via escHtml/escAttr before insertion.
|
// strings are escaped via escHtml/escAttr before insertion.
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Global Infrastructure Map (MapLibre GL)
|
||||||
|
// Layers: ASN PoPs | Submarine Cables | Global Datacenters
|
||||||
|
// ============================================================
|
||||||
var _pcMap = null;
|
var _pcMap = null;
|
||||||
|
var _pcMapData = null; // current ASN data
|
||||||
|
var _mapLayers = { pops: true, cables: false, globalFacs: false };
|
||||||
|
var _cablesLoaded = false;
|
||||||
|
var _globalFacsLoaded = false;
|
||||||
|
|
||||||
|
function toggleMapLayer(layer, btn) {
|
||||||
|
_mapLayers[layer] = !_mapLayers[layer];
|
||||||
|
var active = _mapLayers[layer];
|
||||||
|
btn.style.border = active ? '1px solid ' + _layerColor(layer) : '1px solid #4a5568';
|
||||||
|
btn.style.color = active ? _layerColor(layer) : 'var(--muted)';
|
||||||
|
btn.style.background = active ? 'rgba(' + _layerRgb(layer) + ',.12)' : 'transparent';
|
||||||
|
|
||||||
|
if (!_pcMap) return;
|
||||||
|
if (layer === 'pops') {
|
||||||
|
['pops-fac', 'pops-ix'].forEach(function(id) {
|
||||||
|
if (_pcMap.getLayer(id)) _pcMap.setLayoutProperty(id, 'visibility', active ? 'visible' : 'none');
|
||||||
|
});
|
||||||
|
} else if (layer === 'cables') {
|
||||||
|
if (active && !_cablesLoaded) { _loadCables(); return; }
|
||||||
|
['cables-line', 'cables-glow'].forEach(function(id) {
|
||||||
|
if (_pcMap.getLayer(id)) _pcMap.setLayoutProperty(id, 'visibility', active ? 'visible' : 'none');
|
||||||
|
});
|
||||||
|
} else if (layer === 'globalFacs') {
|
||||||
|
if (active && !_globalFacsLoaded) { _loadGlobalFacs(); return; }
|
||||||
|
if (_pcMap.getLayer('global-facs')) _pcMap.setLayoutProperty('global-facs', 'visibility', active ? 'visible' : 'none');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _layerColor(l) {
|
||||||
|
return l === 'pops' ? '#7dcfff' : l === 'cables' ? '#9ece6a' : '#bb9af7';
|
||||||
|
}
|
||||||
|
function _layerRgb(l) {
|
||||||
|
return l === 'pops' ? '125,207,255' : l === 'cables' ? '158,206,106' : '187,154,247';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _showMapLoader(show) {
|
||||||
|
var el = document.getElementById('mapLoadingIndicator');
|
||||||
|
if (el) el.style.display = show ? 'inline' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _loadCables() {
|
||||||
|
_showMapLoader(true);
|
||||||
|
fetch('/api/submarine-cables').then(function(r) { return r.json(); }).then(function(geo) {
|
||||||
|
if (!_pcMap) return;
|
||||||
|
_cablesLoaded = true;
|
||||||
|
_showMapLoader(false);
|
||||||
|
if (_pcMap.getSource('cables')) return;
|
||||||
|
_pcMap.addSource('cables', { type: 'geojson', data: geo });
|
||||||
|
_pcMap.addLayer({
|
||||||
|
id: 'cables-glow', type: 'line', source: 'cables',
|
||||||
|
layout: { 'line-cap': 'round', visibility: 'visible' },
|
||||||
|
paint: { 'line-color': '#9ece6a', 'line-width': 4, 'line-opacity': 0.08 }
|
||||||
|
}, 'pops-fac');
|
||||||
|
_pcMap.addLayer({
|
||||||
|
id: 'cables-line', type: 'line', source: 'cables',
|
||||||
|
layout: { 'line-cap': 'round', visibility: 'visible' },
|
||||||
|
paint: { 'line-color': '#9ece6a', 'line-width': 1.2, 'line-opacity': 0.55 }
|
||||||
|
}, 'pops-fac');
|
||||||
|
_pcMap.on('click', 'cables-line', function(e) {
|
||||||
|
var props = e.features[0].properties;
|
||||||
|
new maplibregl.Popup({ closeButton: false, className: 'pc-popup' })
|
||||||
|
.setLngLat(e.lngLat)
|
||||||
|
.setHTML('<b style="color:#9ece6a">' + (props.name || 'Submarine Cable') + '</b>' +
|
||||||
|
(props.color ? '<br><span style="color:#8892a4;font-size:11px">Color: ' + props.color + '</span>' : ''))
|
||||||
|
.addTo(_pcMap);
|
||||||
|
});
|
||||||
|
_pcMap.on('mouseenter', 'cables-line', function() { _pcMap.getCanvas().style.cursor = 'pointer'; });
|
||||||
|
_pcMap.on('mouseleave', 'cables-line', function() { _pcMap.getCanvas().style.cursor = ''; });
|
||||||
|
}).catch(function() { _showMapLoader(false); _cablesLoaded = false; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function _loadGlobalFacs() {
|
||||||
|
_showMapLoader(true);
|
||||||
|
fetch('/api/global-infra').then(function(r) { return r.json(); }).then(function(data) {
|
||||||
|
if (!_pcMap) return;
|
||||||
|
_globalFacsLoaded = true;
|
||||||
|
_showMapLoader(false);
|
||||||
|
if (_pcMap.getSource('global-facs')) return;
|
||||||
|
var features = (data.facs || []).map(function(f) {
|
||||||
|
return { type: 'Feature', geometry: { type: 'Point', coordinates: [f.lng, f.lat] },
|
||||||
|
properties: { name: f.name, city: f.city, country: f.country } };
|
||||||
|
});
|
||||||
|
_pcMap.addSource('global-facs', { type: 'geojson', data: { type: 'FeatureCollection', features: features } });
|
||||||
|
_pcMap.addLayer({
|
||||||
|
id: 'global-facs', type: 'circle', source: 'global-facs',
|
||||||
|
layout: { visibility: 'visible' },
|
||||||
|
paint: { 'circle-radius': 3, 'circle-color': '#bb9af7', 'circle-opacity': 0.5, 'circle-stroke-width': 0.5, 'circle-stroke-color': '#bb9af7' }
|
||||||
|
}, 'pops-fac');
|
||||||
|
_pcMap.on('click', 'global-facs', function(e) {
|
||||||
|
var p = e.features[0].properties;
|
||||||
|
new maplibregl.Popup({ closeButton: false, className: 'pc-popup' })
|
||||||
|
.setLngLat(e.lngLat)
|
||||||
|
.setHTML('<b style="color:#bb9af7">' + p.name + '</b><br><span style="font-size:11px;color:#8892a4">' + (p.city || '') + (p.country ? ', ' + p.country : '') + '</span>')
|
||||||
|
.addTo(_pcMap);
|
||||||
|
});
|
||||||
|
_pcMap.on('mouseenter', 'global-facs', function() { _pcMap.getCanvas().style.cursor = 'pointer'; });
|
||||||
|
_pcMap.on('mouseleave', 'global-facs', function() { _pcMap.getCanvas().style.cursor = ''; });
|
||||||
|
}).catch(function() { _showMapLoader(false); _globalFacsLoaded = false; });
|
||||||
|
}
|
||||||
|
|
||||||
function renderNetworkMap(d) {
|
function renderNetworkMap(d) {
|
||||||
var mapCard = document.getElementById('mapCard');
|
var mapCard = document.getElementById('mapCard');
|
||||||
var mapDiv = document.getElementById('networkMap');
|
var mapDiv = document.getElementById('networkMap');
|
||||||
if (!mapCard || !mapDiv || typeof L === 'undefined') return;
|
if (!mapCard || !mapDiv || typeof maplibregl === 'undefined') return;
|
||||||
|
|
||||||
// Collect all markers
|
_pcMapData = d;
|
||||||
var markers = [];
|
|
||||||
|
// Build ASN PoP GeoJSON
|
||||||
|
var popFeatures = [];
|
||||||
var facs = (d.facilities && d.facilities.list) || [];
|
var facs = (d.facilities && d.facilities.list) || [];
|
||||||
facs.forEach(function(f) {
|
facs.forEach(function(f) {
|
||||||
if (f.latitude && f.longitude) {
|
if (f.latitude && f.longitude) {
|
||||||
markers.push({ lat: f.latitude, lng: f.longitude, type: 'fac', name: f.name, detail: f.city + (f.country ? ', ' + f.country : '') });
|
popFeatures.push({ type: 'Feature',
|
||||||
|
geometry: { type: 'Point', coordinates: [+f.longitude, +f.latitude] },
|
||||||
|
properties: { type: 'fac', name: f.name, detail: (f.city || '') + (f.country ? ', ' + f.country : '') }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
var ixLocs = d.ix_locations || [];
|
var ixLocs = d.ix_locations || [];
|
||||||
@ -1261,80 +1389,78 @@ function renderNetworkMap(d) {
|
|||||||
ixLocs.forEach(function(ix) {
|
ixLocs.forEach(function(ix) {
|
||||||
if (ix.latitude && ix.longitude) {
|
if (ix.latitude && ix.longitude) {
|
||||||
var spd = ixSpeedMap[ix.ix_id] || 0;
|
var spd = ixSpeedMap[ix.ix_id] || 0;
|
||||||
markers.push({ lat: ix.latitude, lng: ix.longitude, type: 'ix', name: ix.name, detail: (ix.city || '') + (spd ? ' | ' + fmtSpeed(spd) : '') });
|
popFeatures.push({ type: 'Feature',
|
||||||
|
geometry: { type: 'Point', coordinates: [+ix.longitude, +ix.latitude] },
|
||||||
|
properties: { type: 'ix', name: ix.name, detail: (ix.city || '') + (spd ? ' | ' + fmtSpeed(spd) : '') }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (markers.length === 0) { mapCard.style.display = 'none'; return; }
|
if (popFeatures.length === 0) { mapCard.style.display = 'none'; return; }
|
||||||
|
|
||||||
// KEY FIX: Set display + explicit pixel dimensions BEFORE creating Leaflet map
|
|
||||||
mapCard.style.display = 'block';
|
mapCard.style.display = 'block';
|
||||||
var cardWidth = mapCard.getBoundingClientRect().width;
|
|
||||||
mapDiv.style.width = (cardWidth - 40) + 'px';
|
|
||||||
mapDiv.style.height = '450px';
|
|
||||||
|
|
||||||
// Destroy previous map
|
// Destroy previous map
|
||||||
if (_pcMap) { _pcMap.remove(); _pcMap = null; }
|
if (_pcMap) { _pcMap.remove(); _pcMap = null; _cablesLoaded = false; _globalFacsLoaded = false; }
|
||||||
|
|
||||||
// Use setTimeout to let the browser fully lay out the container
|
|
||||||
setTimeout(function() {
|
setTimeout(function() {
|
||||||
// Re-measure after layout
|
_pcMap = new maplibregl.Map({
|
||||||
var w = mapDiv.getBoundingClientRect().width;
|
container: 'networkMap',
|
||||||
if (w < 100) { mapDiv.style.width = '100%'; }
|
style: 'https://tiles.openfreemap.org/styles/dark',
|
||||||
|
center: [10, 20],
|
||||||
_pcMap = L.map(mapDiv, { scrollWheelZoom: false, attributionControl: false });
|
zoom: 2,
|
||||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
attributionControl: false,
|
||||||
maxZoom: 18, subdomains: 'abcd'
|
|
||||||
}).addTo(_pcMap);
|
|
||||||
L.control.attribution({ prefix: false }).addAttribution('© <a href="https://carto.com">CARTO</a>').addTo(_pcMap);
|
|
||||||
|
|
||||||
var bounds = L.latLngBounds();
|
|
||||||
markers.forEach(function(m) {
|
|
||||||
var color = m.type === 'fac' ? '#7dcfff' : '#ff9e64';
|
|
||||||
var radius = m.type === 'fac' ? 7 : 6;
|
|
||||||
var circle = L.circleMarker([m.lat, m.lng], {
|
|
||||||
radius: radius, fillColor: color, fillOpacity: 0.85, color: color, weight: 1, opacity: 0.6
|
|
||||||
}).addTo(_pcMap);
|
|
||||||
var popupDiv = document.createElement('div');
|
|
||||||
popupDiv.style.cssText = 'font-family:Inter,sans-serif;font-size:12px';
|
|
||||||
var nameEl = document.createElement('b');
|
|
||||||
nameEl.textContent = m.name;
|
|
||||||
popupDiv.appendChild(nameEl);
|
|
||||||
if (m.detail) {
|
|
||||||
popupDiv.appendChild(document.createElement('br'));
|
|
||||||
var detailEl = document.createElement('span');
|
|
||||||
detailEl.textContent = m.detail;
|
|
||||||
popupDiv.appendChild(detailEl);
|
|
||||||
}
|
|
||||||
circle.bindPopup(popupDiv);
|
|
||||||
bounds.extend([m.lat, m.lng]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Faint lines between facilities in same country
|
_pcMap.addControl(new maplibregl.AttributionControl({ compact: true }), 'bottom-right');
|
||||||
var facByCountry = {};
|
|
||||||
facs.forEach(function(f) {
|
_pcMap.on('load', function() {
|
||||||
if (f.latitude && f.longitude && f.country) {
|
var popGeo = { type: 'FeatureCollection', features: popFeatures };
|
||||||
if (!facByCountry[f.country]) facByCountry[f.country] = [];
|
|
||||||
facByCountry[f.country].push([f.latitude, f.longitude]);
|
_pcMap.addSource('pops', { type: 'geojson', data: popGeo });
|
||||||
}
|
|
||||||
});
|
// Facility circles
|
||||||
Object.keys(facByCountry).forEach(function(cc) {
|
_pcMap.addLayer({
|
||||||
var pts = facByCountry[cc];
|
id: 'pops-fac', type: 'circle', source: 'pops',
|
||||||
if (pts.length >= 2 && pts.length <= 15) {
|
filter: ['==', ['get', 'type'], 'fac'],
|
||||||
for (var i = 0; i < pts.length - 1; i++) {
|
paint: { 'circle-radius': 7, 'circle-color': '#7dcfff', 'circle-opacity': 0.85, 'circle-stroke-width': 1, 'circle-stroke-color': '#7dcfff', 'circle-stroke-opacity': 0.5 }
|
||||||
L.polyline([pts[i], pts[i + 1]], { color: '#bb9af7', weight: 1, opacity: 0.3 }).addTo(_pcMap);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (bounds.isValid()) {
|
// IXP circles
|
||||||
_pcMap.fitBounds(bounds, { padding: [30, 30], maxZoom: 6 });
|
_pcMap.addLayer({
|
||||||
}
|
id: 'pops-ix', type: 'circle', source: 'pops',
|
||||||
|
filter: ['==', ['get', 'type'], 'ix'],
|
||||||
|
paint: { 'circle-radius': 6, 'circle-color': '#ff9e64', 'circle-opacity': 0.85, 'circle-stroke-width': 1, 'circle-stroke-color': '#ff9e64', 'circle-stroke-opacity': 0.5 }
|
||||||
|
});
|
||||||
|
|
||||||
// Final invalidateSize after everything is rendered
|
// Popups for PoPs
|
||||||
setTimeout(function() {
|
['pops-fac', 'pops-ix'].forEach(function(layerId) {
|
||||||
if (_pcMap) _pcMap.invalidateSize();
|
_pcMap.on('click', layerId, function(e) {
|
||||||
}, 300);
|
var p = e.features[0].properties;
|
||||||
|
var color = p.type === 'ix' ? '#ff9e64' : '#7dcfff';
|
||||||
|
var label = p.type === 'ix' ? 'IXP' : 'Datacenter';
|
||||||
|
new maplibregl.Popup({ closeButton: false, className: 'pc-popup' })
|
||||||
|
.setLngLat(e.lngLat)
|
||||||
|
.setHTML('<div style="font-family:Inter,sans-serif;font-size:12px;min-width:140px">' +
|
||||||
|
'<span style="font-size:10px;color:' + color + ';text-transform:uppercase;letter-spacing:.05em">' + label + '</span><br>' +
|
||||||
|
'<b style="color:#e2e8f0">' + p.name + '</b>' +
|
||||||
|
(p.detail ? '<br><span style="color:#8892a4;font-size:11px">' + p.detail + '</span>' : '') +
|
||||||
|
'</div>')
|
||||||
|
.addTo(_pcMap);
|
||||||
|
});
|
||||||
|
_pcMap.on('mouseenter', layerId, function() { _pcMap.getCanvas().style.cursor = 'pointer'; });
|
||||||
|
_pcMap.on('mouseleave', layerId, function() { _pcMap.getCanvas().style.cursor = ''; });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fit to PoP bounds
|
||||||
|
var lngs = popFeatures.map(function(f) { return f.geometry.coordinates[0]; });
|
||||||
|
var lats = popFeatures.map(function(f) { return f.geometry.coordinates[1]; });
|
||||||
|
var bounds = [[Math.min.apply(null,lngs)-2, Math.min.apply(null,lats)-2], [Math.max.apply(null,lngs)+2, Math.max.apply(null,lats)+2]];
|
||||||
|
_pcMap.fitBounds(bounds, { padding: 40, maxZoom: 6, duration: 800 });
|
||||||
|
|
||||||
|
// Restore active overlay layers
|
||||||
|
if (_mapLayers.cables && !_cablesLoaded) _loadCables();
|
||||||
|
if (_mapLayers.globalFacs && !_globalFacsLoaded) _loadGlobalFacs();
|
||||||
|
});
|
||||||
}, 50);
|
}, 50);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
420
public/shell.html
Normal file
420
public/shell.html
Normal file
@ -0,0 +1,420 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>PeerCortex Shell — Feedback Admin</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
html,body{height:100%;background:#0a0a0a;color:#00ff41;font-family:'IBM Plex Mono','Courier New',monospace;font-size:.82rem;line-height:1.7;overflow:hidden}
|
||||||
|
|
||||||
|
#shell{display:flex;flex-direction:column;height:100vh;max-width:900px;margin:0 auto;padding:0 1rem}
|
||||||
|
|
||||||
|
/* Title bar */
|
||||||
|
#titlebar{display:flex;align-items:center;gap:.5rem;padding:.5rem 0;border-bottom:1px solid rgba(0,255,65,.12);flex-shrink:0;margin-bottom:.25rem}
|
||||||
|
.tb-dot{width:11px;height:11px;border-radius:50%;display:inline-block;flex-shrink:0}
|
||||||
|
#tb-title{flex:1;text-align:center;color:rgba(0,255,65,.45);font-size:.68rem;letter-spacing:.1em}
|
||||||
|
|
||||||
|
/* Output */
|
||||||
|
#output{flex:1;overflow-y:auto;padding:.5rem 0;word-break:break-word}
|
||||||
|
#output div{padding:.05rem 0}
|
||||||
|
|
||||||
|
/* Input bar */
|
||||||
|
#inputbar{display:flex;align-items:center;gap:.5rem;padding:.5rem 0;border-top:1px solid rgba(0,255,65,.12);flex-shrink:0}
|
||||||
|
#prompt{color:rgba(0,255,65,.55);white-space:nowrap;flex-shrink:0}
|
||||||
|
#cmd{flex:1;background:transparent;border:none;outline:none;color:#00ff41;font-family:'IBM Plex Mono','Courier New',monospace;font-size:.82rem;caret-color:#00ff41}
|
||||||
|
|
||||||
|
/* Scrollbar */
|
||||||
|
#output::-webkit-scrollbar{width:4px}
|
||||||
|
#output::-webkit-scrollbar-track{background:transparent}
|
||||||
|
#output::-webkit-scrollbar-thumb{background:rgba(0,255,65,.2);border-radius:2px}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="shell">
|
||||||
|
<div id="titlebar">
|
||||||
|
<span class="tb-dot" style="background:#ff5f56"></span>
|
||||||
|
<span class="tb-dot" style="background:#ffbd2e"></span>
|
||||||
|
<span class="tb-dot" style="background:#27c93f"></span>
|
||||||
|
<span id="tb-title">shell.peercortex.org — admin terminal</span>
|
||||||
|
</div>
|
||||||
|
<div id="output"></div>
|
||||||
|
<div id="inputbar">
|
||||||
|
<span id="prompt">shell:~$</span>
|
||||||
|
<input id="cmd" type="text" autocomplete="off" spellcheck="false">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
var token = null;
|
||||||
|
var allEntries = [];
|
||||||
|
|
||||||
|
var G = 'color:#27c93f';
|
||||||
|
var DIM = 'color:rgba(0,255,65,.35)';
|
||||||
|
var MUT = 'color:rgba(0,255,65,.45)';
|
||||||
|
var Y = 'color:#ffbd2e';
|
||||||
|
var R = 'color:rgba(255,100,100,.8)';
|
||||||
|
var W = 'color:#fff';
|
||||||
|
|
||||||
|
// Safe DOM output — no innerHTML on user data
|
||||||
|
function print(template, fallbackColor) {
|
||||||
|
var out = document.getElementById('output');
|
||||||
|
var line = document.createElement('div');
|
||||||
|
if (fallbackColor) line.style.color = fallbackColor;
|
||||||
|
var parts = template.split(/(<span[^>]*>[^<]*<\/span>)/g);
|
||||||
|
parts.forEach(function(part) {
|
||||||
|
var m = part.match(/^<span([^>]*)>(.*?)<\/span>$/);
|
||||||
|
if (m) {
|
||||||
|
var sp = document.createElement('span');
|
||||||
|
var sm = m[1].match(/style="([^"]*)"/);
|
||||||
|
if (sm) sp.style.cssText = sm[1];
|
||||||
|
sp.textContent = m[2];
|
||||||
|
line.appendChild(sp);
|
||||||
|
} else if (part) {
|
||||||
|
line.appendChild(document.createTextNode(part));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
out.appendChild(line);
|
||||||
|
out.scrollTop = out.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function printText(text, color) {
|
||||||
|
var out = document.getElementById('output');
|
||||||
|
var line = document.createElement('div');
|
||||||
|
line.textContent = text;
|
||||||
|
if (color) line.style.color = color;
|
||||||
|
out.appendChild(line);
|
||||||
|
out.scrollTop = out.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function blank() { print(''); }
|
||||||
|
|
||||||
|
function clear() { document.getElementById('output').textContent = ''; }
|
||||||
|
|
||||||
|
function setPrompt(p) { document.getElementById('prompt').textContent = p; }
|
||||||
|
|
||||||
|
// Boot sequence
|
||||||
|
function boot() {
|
||||||
|
clear();
|
||||||
|
token = null;
|
||||||
|
allEntries = [];
|
||||||
|
setPrompt('shell:~$');
|
||||||
|
var lines = [
|
||||||
|
'<span style="' + DIM + '">══════════════════════════════════════════════════════════</span>',
|
||||||
|
'',
|
||||||
|
' <span style="' + G + ';font-weight:600">PeerCortex — Feedback Administration Shell</span>',
|
||||||
|
' <span style="' + MUT + '">Unauthorized access prohibited. All sessions logged.</span>',
|
||||||
|
'',
|
||||||
|
'<span style="' + DIM + '">══════════════════════════════════════════════════════════</span>',
|
||||||
|
'',
|
||||||
|
'Type <span style="' + G + '">login</span> to authenticate.',
|
||||||
|
''
|
||||||
|
];
|
||||||
|
var d = 0;
|
||||||
|
lines.forEach(function(l){ setTimeout(function(){ print(l); }, d); d += 40; });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command dispatch
|
||||||
|
function dispatch(raw) {
|
||||||
|
var val = raw.trim();
|
||||||
|
var low = val.toLowerCase();
|
||||||
|
var args = val.split(/\s+/);
|
||||||
|
var cmd = args[0].toLowerCase();
|
||||||
|
|
||||||
|
// Echo input safely
|
||||||
|
(function(){
|
||||||
|
var out = document.getElementById('output');
|
||||||
|
var d = document.createElement('div');
|
||||||
|
var sp = document.createElement('span');
|
||||||
|
sp.style.color = 'rgba(0,255,65,.35)';
|
||||||
|
sp.textContent = document.getElementById('prompt').textContent;
|
||||||
|
d.appendChild(sp);
|
||||||
|
d.appendChild(document.createTextNode(' ' + val));
|
||||||
|
out.appendChild(d);
|
||||||
|
out.scrollTop = out.scrollHeight;
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
// Unauthenticated mode
|
||||||
|
if (cmd === 'login') {
|
||||||
|
doLogin();
|
||||||
|
} else if (cmd === 'help') {
|
||||||
|
showHelp(false);
|
||||||
|
} else if (cmd === 'clear') {
|
||||||
|
clear();
|
||||||
|
} else if (val === '') {
|
||||||
|
// noop
|
||||||
|
} else {
|
||||||
|
printText('Permission denied. Type login first.', 'rgba(255,100,100,.75)');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticated mode
|
||||||
|
if (cmd === 'list') {
|
||||||
|
var cat = args[1] || null;
|
||||||
|
doList(cat);
|
||||||
|
} else if (cmd === 'show') {
|
||||||
|
var idx = parseInt(args[1], 10);
|
||||||
|
doShow(idx);
|
||||||
|
} else if (cmd === 'stats') {
|
||||||
|
doStats();
|
||||||
|
} else if (cmd === 'export') {
|
||||||
|
doExport();
|
||||||
|
} else if (cmd === 'refresh') {
|
||||||
|
doFetch(function(){ print('<span style="' + G + '">✓ Data refreshed. ' + allEntries.length + ' entries loaded.</span>'); });
|
||||||
|
} else if (cmd === 'clear') {
|
||||||
|
clear();
|
||||||
|
} else if (cmd === 'logout') {
|
||||||
|
token = null; allEntries = [];
|
||||||
|
setPrompt('shell:~$');
|
||||||
|
blank();
|
||||||
|
printText('Session terminated.', MUT.slice(6));
|
||||||
|
blank();
|
||||||
|
printText('Type login to re-authenticate.');
|
||||||
|
} else if (cmd === 'help') {
|
||||||
|
showHelp(true);
|
||||||
|
} else if (val === '') {
|
||||||
|
// noop
|
||||||
|
} else {
|
||||||
|
printText('shell: ' + val + ': command not found', 'rgba(255,100,100,.75)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login flow
|
||||||
|
var loginState = 0;
|
||||||
|
function doLogin() {
|
||||||
|
loginState = 1;
|
||||||
|
setPrompt('token:');
|
||||||
|
blank();
|
||||||
|
printText('Enter FEEDBACK_TOKEN:');
|
||||||
|
document.getElementById('cmd').type = 'password';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLogin(val) {
|
||||||
|
document.getElementById('cmd').type = 'text';
|
||||||
|
loginState = 0;
|
||||||
|
setPrompt('shell:~$');
|
||||||
|
blank();
|
||||||
|
// Verify token
|
||||||
|
fetch('/api/feedback?token=' + encodeURIComponent(val))
|
||||||
|
.then(function(r){ return r.json(); })
|
||||||
|
.then(function(d){
|
||||||
|
if (d.ok) {
|
||||||
|
token = val;
|
||||||
|
allEntries = d.entries || [];
|
||||||
|
setPrompt('root@peercortex:~#');
|
||||||
|
print('<span style="' + G + '">✓ Authenticated. ' + allEntries.length + ' feedback entries loaded.</span>');
|
||||||
|
blank();
|
||||||
|
showHelp(true);
|
||||||
|
} else {
|
||||||
|
printText('✗ Authentication failed. Wrong token.', 'rgba(255,100,100,.8)');
|
||||||
|
blank();
|
||||||
|
}
|
||||||
|
}).catch(function(){
|
||||||
|
printText('Network error during authentication.', 'rgba(255,100,100,.8)');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch fresh data from server
|
||||||
|
function doFetch(cb) {
|
||||||
|
fetch('/api/feedback?token=' + encodeURIComponent(token))
|
||||||
|
.then(function(r){ return r.json(); })
|
||||||
|
.then(function(d){
|
||||||
|
if (d.ok) { allEntries = d.entries || []; if (cb) cb(); }
|
||||||
|
else { printText('Fetch error: ' + (d.error || 'unknown'), 'rgba(255,100,100,.8)'); }
|
||||||
|
}).catch(function(){ printText('Network error.', 'rgba(255,100,100,.8)'); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// list [category]
|
||||||
|
function doList(filterCat) {
|
||||||
|
var data = allEntries;
|
||||||
|
if (filterCat) {
|
||||||
|
var lf = filterCat.toLowerCase();
|
||||||
|
data = data.filter(function(e){ return e.category && e.category.toLowerCase().indexOf(lf) >= 0; });
|
||||||
|
}
|
||||||
|
blank();
|
||||||
|
if (data.length === 0) {
|
||||||
|
printText('No entries' + (filterCat ? ' matching "' + filterCat + '"' : '') + '.', MUT.slice(6));
|
||||||
|
blank();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
print('<span style="' + DIM + '">── # DATE CATEGORY NAME ASN ──</span>');
|
||||||
|
data.forEach(function(e, i) {
|
||||||
|
var realIdx = allEntries.indexOf(e);
|
||||||
|
var date = e.timestamp ? e.timestamp.slice(0,10) : '?';
|
||||||
|
var cat = (e.category || 'General').slice(0,18).padEnd(18);
|
||||||
|
var name = (e.name || 'Anonymous').slice(0,16).padEnd(16);
|
||||||
|
var asn = e.asn ? ('AS' + e.asn) : '—';
|
||||||
|
var num = String(realIdx + 1).padStart(3);
|
||||||
|
(function(entry, n){
|
||||||
|
var out = document.getElementById('output');
|
||||||
|
var line = document.createElement('div');
|
||||||
|
var snr = document.createElement('span');
|
||||||
|
snr.style.color = 'rgba(0,255,65,.4)';
|
||||||
|
snr.textContent = num + ' ';
|
||||||
|
var sdat = document.createElement('span');
|
||||||
|
sdat.style.color = '#ffbd2e';
|
||||||
|
sdat.textContent = date + ' ';
|
||||||
|
var scat = document.createElement('span');
|
||||||
|
scat.style.color = '#00ff41';
|
||||||
|
scat.textContent = cat + ' ';
|
||||||
|
var snm = document.createElement('span');
|
||||||
|
snm.style.color = 'rgba(0,255,65,.7)';
|
||||||
|
snm.textContent = name + ' ';
|
||||||
|
var sasn = document.createElement('span');
|
||||||
|
sasn.style.color = 'rgba(0,255,65,.45)';
|
||||||
|
sasn.textContent = asn;
|
||||||
|
line.appendChild(snr); line.appendChild(sdat); line.appendChild(scat);
|
||||||
|
line.appendChild(snm); line.appendChild(sasn);
|
||||||
|
out.appendChild(line);
|
||||||
|
out.scrollTop = out.scrollHeight;
|
||||||
|
})(e, realIdx);
|
||||||
|
});
|
||||||
|
print('<span style="' + DIM + '">──────────────────────────────────────────────────────────────</span>');
|
||||||
|
var out2 = document.getElementById('output');
|
||||||
|
var tot = document.createElement('div');
|
||||||
|
tot.style.color = 'rgba(0,255,65,.45)';
|
||||||
|
tot.textContent = data.length + ' entr' + (data.length === 1 ? 'y' : 'ies') + (filterCat ? ' (filtered)' : '') + ' — type show <n> for full message';
|
||||||
|
out2.appendChild(tot);
|
||||||
|
out2.scrollTop = out2.scrollHeight;
|
||||||
|
blank();
|
||||||
|
}
|
||||||
|
|
||||||
|
// show <n>
|
||||||
|
function doShow(n) {
|
||||||
|
if (isNaN(n) || n < 1 || n > allEntries.length) {
|
||||||
|
printText('Usage: show <number> (1–' + allEntries.length + ')', 'rgba(255,189,46,.8)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var e = allEntries[n - 1];
|
||||||
|
blank();
|
||||||
|
print('<span style="' + DIM + '">────────────────────────────────────────────────────────────</span>');
|
||||||
|
(function(){
|
||||||
|
var fields = [
|
||||||
|
['ID', e.id || '?'],
|
||||||
|
['Timestamp', e.timestamp || '?'],
|
||||||
|
['Category', e.category || 'General'],
|
||||||
|
['Name', e.name || 'Anonymous'],
|
||||||
|
['ASN', e.asn ? 'AS' + e.asn : '—'],
|
||||||
|
['IP', e.ip || '—'],
|
||||||
|
];
|
||||||
|
fields.forEach(function(f){
|
||||||
|
var out = document.getElementById('output');
|
||||||
|
var d = document.createElement('div');
|
||||||
|
var lbl = document.createElement('span');
|
||||||
|
lbl.style.color = 'rgba(0,255,65,.45)';
|
||||||
|
lbl.textContent = f[0].padEnd(12) + ' ';
|
||||||
|
var val = document.createElement('span');
|
||||||
|
val.style.color = '#00ff41';
|
||||||
|
val.textContent = f[1];
|
||||||
|
d.appendChild(lbl); d.appendChild(val);
|
||||||
|
out.appendChild(d);
|
||||||
|
out.scrollTop = out.scrollHeight;
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
blank();
|
||||||
|
printText('Message:', 'rgba(0,255,65,.45)');
|
||||||
|
printText(e.message || '(empty)', '#fff');
|
||||||
|
blank();
|
||||||
|
print('<span style="' + DIM + '">────────────────────────────────────────────────────────────</span>');
|
||||||
|
blank();
|
||||||
|
}
|
||||||
|
|
||||||
|
// stats
|
||||||
|
function doStats() {
|
||||||
|
if (allEntries.length === 0) { printText('No feedback entries yet.', MUT.slice(6)); return; }
|
||||||
|
var cats = {};
|
||||||
|
var asns = {};
|
||||||
|
allEntries.forEach(function(e){
|
||||||
|
var c = e.category || 'General';
|
||||||
|
cats[c] = (cats[c] || 0) + 1;
|
||||||
|
if (e.asn) { asns[e.asn] = (asns[e.asn] || 0) + 1; }
|
||||||
|
});
|
||||||
|
blank();
|
||||||
|
print('<span style="' + G + ';font-weight:600">─── Feedback Statistics ───────────────────</span>');
|
||||||
|
var out = document.getElementById('output');
|
||||||
|
var tot = document.createElement('div');
|
||||||
|
tot.style.color = '#ffbd2e';
|
||||||
|
tot.textContent = 'Total entries: ' + allEntries.length;
|
||||||
|
out.appendChild(tot);
|
||||||
|
blank();
|
||||||
|
printText('By category:', 'rgba(0,255,65,.45)');
|
||||||
|
Object.keys(cats).sort(function(a,b){ return cats[b]-cats[a]; }).forEach(function(c){
|
||||||
|
var d2 = document.createElement('div');
|
||||||
|
var bar = '█'.repeat(Math.round(cats[c] / allEntries.length * 20));
|
||||||
|
var sp1 = document.createElement('span');
|
||||||
|
sp1.style.color = 'rgba(0,255,65,.4)';
|
||||||
|
sp1.textContent = ' ' + c.padEnd(20);
|
||||||
|
var sp2 = document.createElement('span');
|
||||||
|
sp2.style.color = '#27c93f';
|
||||||
|
sp2.textContent = bar + ' ' + cats[c];
|
||||||
|
d2.appendChild(sp1); d2.appendChild(sp2);
|
||||||
|
out.appendChild(d2);
|
||||||
|
});
|
||||||
|
var topAsn = Object.keys(asns);
|
||||||
|
if (topAsn.length > 0) {
|
||||||
|
blank();
|
||||||
|
printText('Top ASNs reported:', 'rgba(0,255,65,.45)');
|
||||||
|
topAsn.sort(function(a,b){ return asns[b]-asns[a]; }).slice(0,5).forEach(function(a){
|
||||||
|
var d3 = document.createElement('div');
|
||||||
|
d3.style.color = 'rgba(0,255,65,.7)';
|
||||||
|
d3.textContent = ' AS' + a + ' — ' + asns[a] + ' report' + (asns[a]===1?'':'s');
|
||||||
|
out.appendChild(d3);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
out.scrollTop = out.scrollHeight;
|
||||||
|
blank();
|
||||||
|
}
|
||||||
|
|
||||||
|
// export — trigger JSON file download
|
||||||
|
function doExport() {
|
||||||
|
var blob = new Blob([JSON.stringify(allEntries, null, 2)], {type:'application/json'});
|
||||||
|
var url = URL.createObjectURL(blob);
|
||||||
|
var a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'peercortex-feedback-' + new Date().toISOString().slice(0,10) + '.json';
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
print('<span style="' + G + '">✓ Export downloaded: ' + allEntries.length + ' entries.</span>');
|
||||||
|
blank();
|
||||||
|
}
|
||||||
|
|
||||||
|
// help
|
||||||
|
function showHelp(authed) {
|
||||||
|
blank();
|
||||||
|
print('<span style="' + G + ';font-weight:600">Available commands:</span>');
|
||||||
|
if (!authed) {
|
||||||
|
print(' <span style="' + G + '">login</span> — authenticate with FEEDBACK_TOKEN');
|
||||||
|
print(' <span style="' + G + '">clear</span> — clear screen');
|
||||||
|
} else {
|
||||||
|
print(' <span style="' + G + '">list</span> — show all feedback entries');
|
||||||
|
print(' <span style="' + G + '">list [category]</span> — filter by category (bug, feature, design, general)');
|
||||||
|
print(' <span style="' + G + '">show [n]</span> — show full message for entry #n');
|
||||||
|
print(' <span style="' + G + '">stats</span> — category + ASN statistics');
|
||||||
|
print(' <span style="' + G + '">export</span> — download all entries as JSON');
|
||||||
|
print(' <span style="' + G + '">refresh</span> — reload entries from server');
|
||||||
|
print(' <span style="' + G + '">clear</span> — clear screen');
|
||||||
|
print(' <span style="' + G + '">logout</span> — end session');
|
||||||
|
}
|
||||||
|
blank();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input handler
|
||||||
|
document.getElementById('cmd').addEventListener('keydown', function(e){
|
||||||
|
if (e.key !== 'Enter') return;
|
||||||
|
var val = this.value;
|
||||||
|
this.value = '';
|
||||||
|
if (loginState === 1) {
|
||||||
|
handleLogin(val.trim());
|
||||||
|
} else {
|
||||||
|
dispatch(val);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start
|
||||||
|
boot();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
x
Reference in New Issue
Block a user