Merge branch 'feat/dashboard-single-ip-page' into feat/add-search-bar
This commit is contained in:
@@ -477,6 +477,15 @@ tbody {
|
||||
color: #58a6ff;
|
||||
border-bottom-color: #58a6ff;
|
||||
}
|
||||
.tab-button.disabled {
|
||||
color: #484f58;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.tab-button.disabled:hover {
|
||||
color: #484f58;
|
||||
background: transparent;
|
||||
}
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -17,19 +17,26 @@ document.addEventListener('alpine:init', () => {
|
||||
// Chart state
|
||||
chartLoaded: false,
|
||||
|
||||
// IP Insight state
|
||||
insightIp: null,
|
||||
|
||||
init() {
|
||||
// Handle hash-based tab routing
|
||||
const hash = window.location.hash.slice(1);
|
||||
if (hash === 'ip-stats' || hash === 'attacks') {
|
||||
this.switchToAttacks();
|
||||
}
|
||||
// ip-insight tab is only accessible via lens buttons, not direct hash navigation
|
||||
|
||||
window.addEventListener('hashchange', () => {
|
||||
const h = window.location.hash.slice(1);
|
||||
if (h === 'ip-stats' || h === 'attacks') {
|
||||
this.switchToAttacks();
|
||||
} else {
|
||||
this.switchToOverview();
|
||||
} else if (h !== 'ip-insight') {
|
||||
// Don't switch away from ip-insight via hash if already there
|
||||
if (this.tab !== 'ip-insight') {
|
||||
this.switchToOverview();
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
@@ -38,15 +45,9 @@ document.addEventListener('alpine:init', () => {
|
||||
this.tab = 'attacks';
|
||||
window.location.hash = '#ip-stats';
|
||||
|
||||
// Delay initialization to ensure the container is visible and
|
||||
// the browser has reflowed after x-show removes display:none.
|
||||
// Leaflet and Chart.js need visible containers with real dimensions.
|
||||
// Delay chart initialization to ensure the container is visible
|
||||
this.$nextTick(() => {
|
||||
setTimeout(() => {
|
||||
if (!this.mapInitialized && typeof initializeAttackerMap === 'function') {
|
||||
initializeAttackerMap();
|
||||
this.mapInitialized = true;
|
||||
}
|
||||
if (!this.chartLoaded && typeof loadAttackTypesChart === 'function') {
|
||||
loadAttackTypesChart();
|
||||
this.chartLoaded = true;
|
||||
@@ -60,6 +61,31 @@ document.addEventListener('alpine:init', () => {
|
||||
window.location.hash = '#overview';
|
||||
},
|
||||
|
||||
switchToIpInsight() {
|
||||
// Only allow switching if an IP is selected
|
||||
if (!this.insightIp) return;
|
||||
this.tab = 'ip-insight';
|
||||
window.location.hash = '#ip-insight';
|
||||
},
|
||||
|
||||
openIpInsight(ip) {
|
||||
// Set the IP and load the insight content
|
||||
this.insightIp = ip;
|
||||
this.tab = 'ip-insight';
|
||||
window.location.hash = '#ip-insight';
|
||||
|
||||
// Load IP insight content via HTMX
|
||||
this.$nextTick(() => {
|
||||
const container = document.getElementById('ip-insight-htmx-container');
|
||||
if (container && typeof htmx !== 'undefined') {
|
||||
htmx.ajax('GET', `${this.dashboardPath}/htmx/ip-insight/${encodeURIComponent(ip)}`, {
|
||||
target: '#ip-insight-htmx-container',
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
async viewRawRequest(logId) {
|
||||
try {
|
||||
const resp = await fetch(
|
||||
@@ -110,6 +136,19 @@ document.addEventListener('alpine:init', () => {
|
||||
}));
|
||||
});
|
||||
|
||||
// Global function for opening IP Insight (used by map popups)
|
||||
window.openIpInsight = function(ip) {
|
||||
// Find the Alpine component and call openIpInsight
|
||||
const container = document.querySelector('[x-data="dashboardApp()"]');
|
||||
if (container) {
|
||||
// Try Alpine 3.x API first, then fall back to older API
|
||||
const data = Alpine.$data ? Alpine.$data(container) : (container._x_dataStack && container._x_dataStack[0]);
|
||||
if (data && typeof data.openIpInsight === 'function') {
|
||||
data.openIpInsight(ip);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Utility function for formatting timestamps (used by map popups)
|
||||
function formatTimestamp(isoTimestamp) {
|
||||
if (!isoTimestamp) return 'N/A';
|
||||
|
||||
@@ -36,14 +36,45 @@ function createClusterIcon(cluster) {
|
||||
gradientStops.push(`${color} ${start.toFixed(1)}deg ${end.toFixed(1)}deg`);
|
||||
});
|
||||
|
||||
const size = Math.max(32, Math.min(50, 32 + Math.log2(total) * 5));
|
||||
const inner = size - 10;
|
||||
const offset = 5; // (size - inner) / 2
|
||||
const size = Math.max(20, Math.min(44, 20 + Math.log2(total) * 4));
|
||||
const centerSize = size - 8;
|
||||
const centerOffset = 4;
|
||||
const ringWidth = 4;
|
||||
const radius = (size / 2) - (ringWidth / 2);
|
||||
const cx = size / 2;
|
||||
const cy = size / 2;
|
||||
const gapDeg = 8;
|
||||
|
||||
// Build SVG arc segments with gaps - glow layer first, then sharp layer
|
||||
let glowSegments = '';
|
||||
let segments = '';
|
||||
let currentAngle = -90;
|
||||
sorted.forEach(([cat, count], idx) => {
|
||||
const sliceDeg = (count / total) * 360;
|
||||
if (sliceDeg < gapDeg) return;
|
||||
const startAngle = currentAngle + (gapDeg / 2);
|
||||
const endAngle = currentAngle + sliceDeg - (gapDeg / 2);
|
||||
const startRad = (startAngle * Math.PI) / 180;
|
||||
const endRad = (endAngle * Math.PI) / 180;
|
||||
const x1 = cx + radius * Math.cos(startRad);
|
||||
const y1 = cy + radius * Math.sin(startRad);
|
||||
const x2 = cx + radius * Math.cos(endRad);
|
||||
const y2 = cy + radius * Math.sin(endRad);
|
||||
const largeArc = (endAngle - startAngle) > 180 ? 1 : 0;
|
||||
const color = categoryColors[cat] || '#8b949e';
|
||||
// Glow layer - subtle
|
||||
glowSegments += `<path d="M ${x1} ${y1} A ${radius} ${radius} 0 ${largeArc} 1 ${x2} ${y2}" fill="none" stroke="${color}" stroke-width="${ringWidth + 4}" stroke-linecap="round" opacity="0.35" filter="url(#glow)"/>`;
|
||||
// Sharp layer
|
||||
segments += `<path d="M ${x1} ${y1} A ${radius} ${radius} 0 ${largeArc} 1 ${x2} ${y2}" fill="none" stroke="${color}" stroke-width="${ringWidth}" stroke-linecap="round"/>`;
|
||||
currentAngle += sliceDeg;
|
||||
});
|
||||
|
||||
return L.divIcon({
|
||||
html: `<div style="position:relative;width:${size}px;height:${size}px;">` +
|
||||
`<div style="position:absolute;top:0;left:0;width:${size}px;height:${size}px;border-radius:50%;background:conic-gradient(${gradientStops.join(', ')});box-shadow:0 0 6px rgba(0,0,0,0.5);"></div>` +
|
||||
`<div style="position:absolute;top:${offset}px;left:${offset}px;width:${inner}px;height:${inner}px;border-radius:50%;background:rgba(13,17,23,0.85);color:#e6edf3;font-size:11px;font-weight:700;line-height:${inner}px;text-align:center;">${total}</div>` +
|
||||
`<svg width="${size}" height="${size}" style="position:absolute;top:0;left:0;overflow:visible;">` +
|
||||
`<defs><filter id="glow" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="2" result="blur"/></filter></defs>` +
|
||||
`${glowSegments}${segments}</svg>` +
|
||||
`<div style="position:absolute;top:${centerOffset}px;left:${centerOffset}px;width:${centerSize}px;height:${centerSize}px;border-radius:50%;background:#0d1117;display:flex;align-items:center;justify-content:center;color:#e6edf3;font-family:'SF Mono',Monaco,Consolas,monospace;font-size:${Math.max(9, centerSize * 0.38)}px;font-weight:600;">${total}</div>` +
|
||||
`</div>`,
|
||||
className: 'ip-cluster-icon',
|
||||
iconSize: L.point(size, size)
|
||||
@@ -180,11 +211,11 @@ function buildMapMarkers(ips) {
|
||||
|
||||
// Single cluster group with custom pie-chart icons
|
||||
clusterGroup = L.markerClusterGroup({
|
||||
maxClusterRadius: 20,
|
||||
maxClusterRadius: 35,
|
||||
spiderfyOnMaxZoom: true,
|
||||
showCoverageOnHover: false,
|
||||
zoomToBoundsOnClick: true,
|
||||
disableClusteringAtZoom: 10,
|
||||
disableClusteringAtZoom: 8,
|
||||
iconCreateFunction: createClusterIcon
|
||||
});
|
||||
|
||||
@@ -309,6 +340,15 @@ function buildMapMarkers(ips) {
|
||||
`;
|
||||
}
|
||||
|
||||
// Add inspect button
|
||||
popupContent += `
|
||||
<div style="margin-top: 12px; border-top: 1px solid #30363d; padding-top: 12px; text-align: center;">
|
||||
<button onclick="window.openIpInsight('${ip.ip}')" class="inspect-btn" style="display: inline-flex; align-items: center; gap: 4px; padding: 6px 12px; background: #21262d; color: #58a6ff; border: 1px solid #30363d; border-radius: 4px; font-size: 12px; font-weight: 500; cursor: pointer; transition: all 0.2s;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><path d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7Z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
popupContent += '</div>';
|
||||
marker.setPopupContent(popupContent);
|
||||
} catch (err) {
|
||||
@@ -332,6 +372,11 @@ function buildMapMarkers(ips) {
|
||||
<div style="margin-top: 12px; border-top: 1px solid #30363d; padding-top: 12px; text-align: center; color: #f85149; font-size: 11px;">
|
||||
Failed to load chart: ${err.message}
|
||||
</div>
|
||||
<div style="margin-top: 12px; text-align: center;">
|
||||
<button onclick="window.openIpInsight('${ip.ip}')" class="inspect-btn" style="display: inline-flex; align-items: center; gap: 4px; padding: 6px 12px; background: #21262d; color: #58a6ff; border: 1px solid #30363d; border-radius: 4px; font-size: 12px; font-weight: 500; cursor: pointer; transition: all 0.2s;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><path d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7Z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
marker.setPopupContent(errorPopup);
|
||||
|
||||
Reference in New Issue
Block a user