mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
feat(wifi): channel heatmap and security ring chart
Replace static channel bar chart and security dots with a scrolling 2.4 GHz channel heatmap (up to 10 scan snapshots) and an SVG donut security ring showing WPA2/WPA3/WEP/Open network distribution.
This commit is contained in:
@@ -3866,100 +3866,158 @@ header h1 .tagline {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* WiFi Analysis Panel (RIGHT) */
|
||||
/* WiFi Analysis Panel */
|
||||
.wifi-analysis-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wifi-channel-section,
|
||||
.wifi-security-section {
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wifi-channel-section {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.wifi-channel-section h5,
|
||||
.wifi-security-section h5 {
|
||||
margin: 0 0 10px 0;
|
||||
color: var(--accent-cyan);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.wifi-channel-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.channel-band-tab {
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
font-size: 10px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.channel-band-tab:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.channel-band-tab.active {
|
||||
background: var(--accent-cyan);
|
||||
color: var(--text-inverse);
|
||||
border-color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.wifi-channel-chart {
|
||||
min-height: 120px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.wifi-security-stats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.wifi-security-item {
|
||||
.wifi-analysis-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-tertiary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wifi-security-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
.wifi-analysis-panel-header .panel-title {
|
||||
color: var(--accent-cyan);
|
||||
font-size: 10px;
|
||||
letter-spacing: 1.5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.wifi-detail-back-btn {
|
||||
font-family: inherit;
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
background: none;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 3px;
|
||||
padding: 2px 8px;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.wifi-detail-back-btn:hover { color: var(--text-primary); }
|
||||
|
||||
/* Heatmap */
|
||||
.wifi-heatmap-wrap {
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wifi-heatmap-label {
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.wifi-heatmap-ch-labels {
|
||||
display: grid;
|
||||
grid-template-columns: 26px repeat(11, 1fr);
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.wifi-heatmap-ch-label {
|
||||
text-align: center;
|
||||
font-size: 8px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.wifi-heatmap-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 26px repeat(11, 1fr);
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.wifi-heatmap-time-label {
|
||||
font-size: 8px;
|
||||
color: var(--text-dim);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.wifi-heatmap-cell {
|
||||
border-radius: 2px;
|
||||
min-height: 10px;
|
||||
}
|
||||
|
||||
.wifi-security-item.wpa3 .wifi-security-dot { background: var(--accent-green); }
|
||||
.wifi-security-item.wpa2 .wifi-security-dot { background: var(--accent-cyan); }
|
||||
.wifi-security-item.wep .wifi-security-dot { background: var(--accent-orange); }
|
||||
.wifi-security-item.open .wifi-security-dot { background: var(--accent-red); }
|
||||
|
||||
.wifi-security-count {
|
||||
margin-left: auto;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
.wifi-heatmap-empty {
|
||||
grid-column: 1 / -1;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: var(--text-dim);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.wifi-heatmap-legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.wifi-heatmap-legend-grad {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: linear-gradient(90deg, #0d1117 0%, #0d4a6e 30%, #0ea5e9 60%, #f97316 80%, #ef4444 100%);
|
||||
}
|
||||
|
||||
/* Security ring */
|
||||
.wifi-security-ring-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-secondary);
|
||||
border-top: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wifi-security-ring-legend {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.wifi-security-ring-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.wifi-security-ring-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 1px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wifi-security-ring-name { color: var(--text-dim); flex: 1; }
|
||||
.wifi-security-ring-count { color: var(--text-primary); font-weight: 600; }
|
||||
|
||||
/* WiFi Detail Drawer */
|
||||
.wifi-detail-drawer {
|
||||
display: none;
|
||||
|
||||
@@ -119,6 +119,7 @@ const WiFiMode = (function() {
|
||||
let probeRequests = [];
|
||||
let channelStats = [];
|
||||
let recommendations = [];
|
||||
let channelHistory = []; // max 10 entries, each { timestamp, channels: {1:N,...,11:N} }
|
||||
|
||||
// UI state
|
||||
let selectedBssid = null;
|
||||
@@ -167,7 +168,7 @@ const WiFiMode = (function() {
|
||||
initScanModeTabs();
|
||||
initNetworkFilters();
|
||||
initSortControls();
|
||||
initChannelChart();
|
||||
initHeatmap();
|
||||
scheduleRender({ table: true, stats: true, radar: true, chart: true });
|
||||
|
||||
// Check if already scanning
|
||||
@@ -199,9 +200,16 @@ const WiFiMode = (function() {
|
||||
networkList: document.getElementById('wifiNetworkList'),
|
||||
networkFilters: document.getElementById('wifiNetworkFilters'),
|
||||
|
||||
// Visualizations
|
||||
channelChart: document.getElementById('wifiChannelChart'),
|
||||
channelBandTabs: document.getElementById('wifiChannelBandTabs'),
|
||||
// Visualizations — heatmap & security ring
|
||||
heatmapGrid: document.getElementById('wifiHeatmapGrid'),
|
||||
heatmapChLabels: document.getElementById('wifiHeatmapChLabels'),
|
||||
heatmapCount: document.getElementById('wifiHeatmapCount'),
|
||||
securityRingSvg: document.getElementById('wifiSecurityRingSvg'),
|
||||
securityRingLegend: document.getElementById('wifiSecurityRingLegend'),
|
||||
heatmapView: document.getElementById('wifiHeatmapView'),
|
||||
detailView: document.getElementById('wifiDetailView'),
|
||||
rightPanelTitle: document.getElementById('wifiRightPanelTitle'),
|
||||
detailBackBtn: document.getElementById('wifiDetailBackBtn'),
|
||||
|
||||
// Zone summary
|
||||
zoneImmediate: document.getElementById('wifiZoneImmediate'),
|
||||
@@ -1076,7 +1084,6 @@ const WiFiMode = (function() {
|
||||
if (pendingRender.table) renderNetworks();
|
||||
if (pendingRender.stats) updateStats();
|
||||
if (pendingRender.radar) renderRadar(Array.from(networks.values()));
|
||||
if (pendingRender.chart) updateChannelChart();
|
||||
if (pendingRender.detail && selectedBssid) {
|
||||
updateDetailPanel(selectedBssid, { refreshClients: false });
|
||||
}
|
||||
@@ -1092,6 +1099,18 @@ const WiFiMode = (function() {
|
||||
function renderNetworks() {
|
||||
if (!elements.networkList) return;
|
||||
|
||||
// Snapshot 2.4 GHz channel utilisation (use all networks, not filtered)
|
||||
const snapshot = { timestamp: Date.now(), channels: {} };
|
||||
for (let ch = 1; ch <= 11; ch++) snapshot.channels[ch] = 0;
|
||||
Array.from(networks.values())
|
||||
.filter(n => n.band && n.band.startsWith('2.4'))
|
||||
.forEach(n => {
|
||||
const ch = parseInt(n.channel);
|
||||
if (ch >= 1 && ch <= 11) snapshot.channels[ch]++;
|
||||
});
|
||||
channelHistory.unshift(snapshot);
|
||||
if (channelHistory.length > 10) channelHistory.pop();
|
||||
|
||||
// Filter networks
|
||||
let filtered = Array.from(networks.values());
|
||||
|
||||
@@ -1162,6 +1181,9 @@ const WiFiMode = (function() {
|
||||
const sel = elements.networkList.querySelector(`[data-bssid="${CSS.escape(selectedBssid)}"]`);
|
||||
if (sel) sel.classList.add('selected');
|
||||
}
|
||||
|
||||
renderHeatmap();
|
||||
renderSecurityRing(Array.from(networks.values()));
|
||||
}
|
||||
|
||||
function createNetworkRow(network) {
|
||||
@@ -1549,81 +1571,101 @@ const WiFiMode = (function() {
|
||||
// Channel Chart
|
||||
// ==========================================================================
|
||||
|
||||
function initChannelChart() {
|
||||
if (!elements.channelChart) return;
|
||||
function initHeatmap() {
|
||||
if (!elements.heatmapChLabels) return;
|
||||
// Time-label placeholder + 11 channel labels
|
||||
elements.heatmapChLabels.innerHTML =
|
||||
'<div class="wifi-heatmap-ch-label"></div>' +
|
||||
[1,2,3,4,5,6,7,8,9,10,11].map(ch =>
|
||||
`<div class="wifi-heatmap-ch-label">${ch}</div>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
// Initialize channel chart component
|
||||
if (typeof ChannelChart !== 'undefined') {
|
||||
ChannelChart.init('wifiChannelChart');
|
||||
function renderHeatmap() {
|
||||
if (!elements.heatmapGrid) return;
|
||||
|
||||
if (channelHistory.length === 0) {
|
||||
elements.heatmapGrid.innerHTML =
|
||||
'<div class="wifi-heatmap-empty">Scan to populate channel history</div>';
|
||||
if (elements.heatmapCount) elements.heatmapCount.textContent = '0';
|
||||
return;
|
||||
}
|
||||
|
||||
// Band tabs
|
||||
if (elements.channelBandTabs) {
|
||||
elements.channelBandTabs.addEventListener('click', (e) => {
|
||||
if (e.target.matches('.channel-band-tab')) {
|
||||
const band = e.target.dataset.band;
|
||||
elements.channelBandTabs.querySelectorAll('.channel-band-tab').forEach(t => {
|
||||
t.classList.toggle('active', t.dataset.band === band);
|
||||
});
|
||||
updateChannelChart(band);
|
||||
}
|
||||
if (elements.heatmapCount) elements.heatmapCount.textContent = channelHistory.length;
|
||||
|
||||
// Find max value for colour scale
|
||||
let maxVal = 1;
|
||||
channelHistory.forEach(snap => {
|
||||
Object.values(snap.channels).forEach(v => { if (v > maxVal) maxVal = v; });
|
||||
});
|
||||
|
||||
const rows = channelHistory.map((snap, i) => {
|
||||
const timeLabel = i === 0 ? 'now' : '';
|
||||
const cells = [1,2,3,4,5,6,7,8,9,10,11].map(ch => {
|
||||
const v = snap.channels[ch] || 0;
|
||||
return `<div class="wifi-heatmap-cell" style="background:${congestionColor(v, maxVal)}"></div>`;
|
||||
});
|
||||
}
|
||||
return `<div class="wifi-heatmap-time-label">${timeLabel}</div>${cells.join('')}`;
|
||||
});
|
||||
|
||||
elements.heatmapGrid.innerHTML = rows.join('');
|
||||
}
|
||||
|
||||
function calculateChannelStats() {
|
||||
// Calculate channel stats from current networks
|
||||
const stats = {};
|
||||
const networksList = Array.from(networks.values());
|
||||
|
||||
// Initialize all channels
|
||||
// 2.4 GHz: channels 1-13
|
||||
for (let ch = 1; ch <= 13; ch++) {
|
||||
stats[ch] = { channel: ch, band: '2.4GHz', ap_count: 0, client_count: 0, utilization_score: 0 };
|
||||
}
|
||||
// 5 GHz: common channels
|
||||
[36, 40, 44, 48, 52, 56, 60, 64, 100, 104, 108, 112, 116, 120, 124, 128, 132, 136, 140, 144, 149, 153, 157, 161, 165].forEach(ch => {
|
||||
stats[ch] = { channel: ch, band: '5GHz', ap_count: 0, client_count: 0, utilization_score: 0 };
|
||||
});
|
||||
|
||||
// Count APs per channel
|
||||
networksList.forEach(net => {
|
||||
const ch = parseInt(net.channel);
|
||||
if (stats[ch]) {
|
||||
stats[ch].ap_count++;
|
||||
stats[ch].client_count += (net.client_count || 0);
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate utilization score (0-1)
|
||||
const maxAPs = Math.max(1, ...Object.values(stats).map(s => s.ap_count));
|
||||
Object.values(stats).forEach(s => {
|
||||
s.utilization_score = s.ap_count / maxAPs;
|
||||
});
|
||||
|
||||
return Object.values(stats).filter(s => s.ap_count > 0 || [1, 6, 11, 36, 40, 44, 48, 149, 153, 157, 161, 165].includes(s.channel));
|
||||
function congestionColor(value, maxValue) {
|
||||
if (value === 0 || maxValue === 0) return '#0d1117';
|
||||
const ratio = value / maxValue;
|
||||
if (ratio < 0.05) return '#0d1117';
|
||||
if (ratio < 0.25) return `rgba(13,74,110,${(ratio * 4).toFixed(2)})`;
|
||||
if (ratio < 0.5) return `rgba(14,165,233,${ratio.toFixed(2)})`;
|
||||
if (ratio < 0.75) return `rgba(249,115,22,${ratio.toFixed(2)})`;
|
||||
return `rgba(239,68,68,${ratio.toFixed(2)})`;
|
||||
}
|
||||
|
||||
function updateChannelChart(band) {
|
||||
if (typeof ChannelChart === 'undefined') return;
|
||||
function renderSecurityRing(networksList) {
|
||||
const svg = elements.securityRingSvg;
|
||||
const legend = elements.securityRingLegend;
|
||||
if (!svg || !legend) return;
|
||||
|
||||
// Use the currently active band tab if no band specified
|
||||
if (!band) {
|
||||
const activeTab = elements.channelBandTabs && elements.channelBandTabs.querySelector('.channel-band-tab.active');
|
||||
band = activeTab ? activeTab.dataset.band : '2.4';
|
||||
}
|
||||
const C = 2 * Math.PI * 15; // circumference ≈ 94.25
|
||||
const sec = networksList.reduce((acc, n) => {
|
||||
const s = (n.security || '').toLowerCase();
|
||||
if (s.includes('wpa3')) acc.wpa3++;
|
||||
else if (s.includes('wpa')) acc.wpa2++;
|
||||
else if (s.includes('wep')) acc.wep++;
|
||||
else acc.open++;
|
||||
return acc;
|
||||
}, { wpa2: 0, open: 0, wpa3: 0, wep: 0 });
|
||||
|
||||
// Recalculate channel stats from networks if needed
|
||||
if (channelStats.length === 0 && networks.size > 0) {
|
||||
channelStats = calculateChannelStats();
|
||||
}
|
||||
const total = networksList.length || 1;
|
||||
const segments = [
|
||||
{ label: 'WPA2', color: '#38c180', count: sec.wpa2 },
|
||||
{ label: 'Open', color: '#e25d5d', count: sec.open },
|
||||
{ label: 'WPA3', color: '#4aa3ff', count: sec.wpa3 },
|
||||
{ label: 'WEP', color: '#d6a85e', count: sec.wep },
|
||||
];
|
||||
|
||||
// Filter stats by band
|
||||
const bandFilter = band === '2.4' ? '2.4GHz' : band === '5' ? '5GHz' : '6GHz';
|
||||
const filteredStats = channelStats.filter(s => s.band === bandFilter);
|
||||
const filteredRecs = recommendations.filter(r => r.band === bandFilter);
|
||||
let offset = 0;
|
||||
const arcs = segments.map(seg => {
|
||||
const arcLen = (seg.count / total) * C;
|
||||
const arc = `<circle cx="24" cy="24" r="15" fill="none"
|
||||
stroke="${seg.color}" stroke-width="7"
|
||||
stroke-dasharray="${arcLen.toFixed(2)} ${(C - arcLen).toFixed(2)}"
|
||||
stroke-dashoffset="${(-offset).toFixed(2)}"
|
||||
transform="rotate(-90 24 24)"/>`;
|
||||
offset += arcLen;
|
||||
return arc;
|
||||
});
|
||||
|
||||
ChannelChart.update(filteredStats, filteredRecs);
|
||||
svg.innerHTML = arcs.join('') +
|
||||
'<circle cx="24" cy="24" r="9" fill="var(--bg-primary)"/>';
|
||||
|
||||
legend.innerHTML = segments.map(seg => `
|
||||
<div class="wifi-security-ring-item">
|
||||
<div class="wifi-security-ring-dot" style="background:${seg.color}"></div>
|
||||
<span class="wifi-security-ring-name">${seg.label}</span>
|
||||
<span class="wifi-security-ring-count">${seg.count}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
|
||||
@@ -936,40 +936,41 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT: Channel Analysis + Security -->
|
||||
<!-- RIGHT: Channel Heatmap + Security Ring -->
|
||||
<div class="wifi-analysis-panel">
|
||||
<div class="wifi-channel-section">
|
||||
<h5>Channel Analysis</h5>
|
||||
<div class="wifi-channel-tabs" id="wifiChannelBandTabs">
|
||||
<button class="channel-band-tab active" data-band="2.4">2.4 GHz</button>
|
||||
<button class="channel-band-tab" data-band="5">5 GHz</button>
|
||||
</div>
|
||||
<div id="wifiChannelChart" class="wifi-channel-chart"></div>
|
||||
<div class="wifi-analysis-panel-header">
|
||||
<span class="panel-title" id="wifiRightPanelTitle">Channel Heatmap</span>
|
||||
<button class="wifi-detail-back-btn" id="wifiDetailBackBtn"
|
||||
style="display:none" onclick="WiFiMode.closeDetail()">← Back</button>
|
||||
</div>
|
||||
<div class="wifi-security-section">
|
||||
<h5>Security Overview</h5>
|
||||
<div class="wifi-security-stats">
|
||||
<div class="wifi-security-item wpa3">
|
||||
<span class="wifi-security-dot"></span>
|
||||
<span>WPA3</span>
|
||||
<span class="wifi-security-count" id="wpa3Count">0</span>
|
||||
|
||||
<!-- Default: heatmap + security ring -->
|
||||
<div id="wifiHeatmapView" style="display:flex; flex-direction:column; flex:1; overflow:hidden;">
|
||||
<div class="wifi-heatmap-wrap">
|
||||
<div class="wifi-heatmap-label">
|
||||
2.4 GHz · Last <span id="wifiHeatmapCount">0</span> scans
|
||||
</div>
|
||||
<div class="wifi-security-item wpa2">
|
||||
<span class="wifi-security-dot"></span>
|
||||
<span>WPA2</span>
|
||||
<span class="wifi-security-count" id="wpa2Count">0</span>
|
||||
<div class="wifi-heatmap-ch-labels" id="wifiHeatmapChLabels">
|
||||
<!-- 11 channel labels (1–11), generated once by JS -->
|
||||
</div>
|
||||
<div class="wifi-security-item wep">
|
||||
<span class="wifi-security-dot"></span>
|
||||
<span>WEP</span>
|
||||
<span class="wifi-security-count" id="wepCount">0</span>
|
||||
</div>
|
||||
<div class="wifi-security-item open">
|
||||
<span class="wifi-security-dot"></span>
|
||||
<span>Open</span>
|
||||
<span class="wifi-security-count" id="openCount">0</span>
|
||||
<div class="wifi-heatmap-grid" id="wifiHeatmapGrid"></div>
|
||||
<div class="wifi-heatmap-legend">
|
||||
<span>Low</span>
|
||||
<div class="wifi-heatmap-legend-grad"></div>
|
||||
<span>High</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wifi-security-ring-wrap">
|
||||
<svg id="wifiSecurityRingSvg" viewBox="0 0 48 48" width="48" height="48">
|
||||
<circle cx="24" cy="24" r="9" fill="var(--bg-primary)"/>
|
||||
</svg>
|
||||
<div class="wifi-security-ring-legend" id="wifiSecurityRingLegend"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- On network click: detail panel (wired in Task 5) -->
|
||||
<div id="wifiDetailView" style="display:none; flex:1; overflow-y:auto;">
|
||||
<!-- populated in Task 5 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user