dashboard changes for Session tab
This commit is contained in:
@@ -407,6 +407,107 @@ def build_presence() -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
SESSION_GAP_SECS = 120 # gap > 2 min between events from same src = new session
|
||||||
|
|
||||||
|
|
||||||
|
def _finalize_session(active: dict) -> dict:
|
||||||
|
reasons = active["reasons"]
|
||||||
|
dominant = max(set(reasons), key=reasons.count) if reasons else 0
|
||||||
|
_, classification = deauth_reason_info(dominant)
|
||||||
|
|
||||||
|
rssi_vals = [r for r in active["rssi_vals"] if r is not None]
|
||||||
|
start_dt = datetime.datetime.fromisoformat(active["start"])
|
||||||
|
end_dt = datetime.datetime.fromisoformat(active["end"])
|
||||||
|
duration = int((end_dt - start_dt).total_seconds())
|
||||||
|
frames = len(active["frames"])
|
||||||
|
|
||||||
|
if classification == "attack" and frames >= 5:
|
||||||
|
severity = "attack"
|
||||||
|
elif frames >= 3 or classification in ("attack", "suspicious"):
|
||||||
|
severity = "suspicious"
|
||||||
|
else:
|
||||||
|
severity = "normal"
|
||||||
|
|
||||||
|
src_vendor, src_rand = oui_lookup(active["src"])
|
||||||
|
return {
|
||||||
|
"src": active["src"],
|
||||||
|
"src_vendor": src_vendor,
|
||||||
|
"src_randomized": src_rand,
|
||||||
|
"start": active["start"],
|
||||||
|
"end": active["end"],
|
||||||
|
"duration_secs": duration,
|
||||||
|
"frame_count": frames,
|
||||||
|
"unique_bssids": list(active["bssids"]),
|
||||||
|
"unique_targets": list(active["targets"]),
|
||||||
|
"node_count": len(active["nodes"]),
|
||||||
|
"nodes": list(active["nodes"]),
|
||||||
|
"peak_rssi": max(rssi_vals) if rssi_vals else None,
|
||||||
|
"avg_rssi": round(sum(rssi_vals) / len(rssi_vals), 1) if rssi_vals else None,
|
||||||
|
"dominant_reason": dominant,
|
||||||
|
"severity": severity,
|
||||||
|
"events": active["frames"][:50],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_sessions() -> list[dict]:
|
||||||
|
cutoff = (datetime.datetime.utcnow() - datetime.timedelta(days=7)).isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
rows = query("""
|
||||||
|
SELECT src, dst, bssid, reason, rssi, received_at, node_id, subtype
|
||||||
|
FROM deauth_events
|
||||||
|
WHERE received_at > ?
|
||||||
|
ORDER BY src, received_at
|
||||||
|
""", (cutoff,))
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
return []
|
||||||
|
|
||||||
|
sessions: list[dict] = []
|
||||||
|
active: dict | None = None
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
src = row["src"]
|
||||||
|
ts = row["received_at"]
|
||||||
|
|
||||||
|
new_session = False
|
||||||
|
if active is None or active["src"] != src:
|
||||||
|
new_session = True
|
||||||
|
else:
|
||||||
|
last_dt = datetime.datetime.fromisoformat(active["end"])
|
||||||
|
curr_dt = datetime.datetime.fromisoformat(ts)
|
||||||
|
if (curr_dt - last_dt).total_seconds() > SESSION_GAP_SECS:
|
||||||
|
new_session = True
|
||||||
|
|
||||||
|
if new_session:
|
||||||
|
if active is not None:
|
||||||
|
sessions.append(_finalize_session(active))
|
||||||
|
active = {
|
||||||
|
"src": src,
|
||||||
|
"start": ts,
|
||||||
|
"end": ts,
|
||||||
|
"bssids": set(),
|
||||||
|
"targets": set(),
|
||||||
|
"nodes": set(),
|
||||||
|
"reasons": [],
|
||||||
|
"rssi_vals": [],
|
||||||
|
"frames": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
active["end"] = ts
|
||||||
|
active["bssids"].add(row["bssid"])
|
||||||
|
active["targets"].add(row["dst"])
|
||||||
|
active["nodes"].add(row["node_id"])
|
||||||
|
active["reasons"].append(row["reason"])
|
||||||
|
active["rssi_vals"].append(row["rssi"])
|
||||||
|
active["frames"].append(row)
|
||||||
|
|
||||||
|
if active is not None:
|
||||||
|
sessions.append(_finalize_session(active))
|
||||||
|
|
||||||
|
sessions.sort(key=lambda s: s["start"], reverse=True)
|
||||||
|
return sessions[:100]
|
||||||
|
|
||||||
|
|
||||||
def build_alerts() -> dict:
|
def build_alerts() -> dict:
|
||||||
now = datetime.datetime.utcnow()
|
now = datetime.datetime.utcnow()
|
||||||
cutoff_5m = (now - datetime.timedelta(minutes=5)).isoformat(timespec="seconds")
|
cutoff_5m = (now - datetime.timedelta(minutes=5)).isoformat(timespec="seconds")
|
||||||
@@ -881,6 +982,11 @@ async def api_rssi_history(bssid: str, hours: int = 2):
|
|||||||
return series
|
return series
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/sessions")
|
||||||
|
async def api_sessions():
|
||||||
|
return build_sessions()
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/search")
|
@app.get("/api/search")
|
||||||
async def api_search(q: str = ""):
|
async def api_search(q: str = ""):
|
||||||
return build_search(q)
|
return build_search(q)
|
||||||
|
|||||||
@@ -450,6 +450,59 @@ th.group-active { color: var(--green-hi); cursor: pointer; }
|
|||||||
th.group-col { cursor: pointer; }
|
th.group-col { cursor: pointer; }
|
||||||
th.group-col:hover { color: var(--text); }
|
th.group-col:hover { color: var(--text); }
|
||||||
.nodes-multi { color: var(--green-hi); }
|
.nodes-multi { color: var(--green-hi); }
|
||||||
|
.mono { font-family: monospace; font-size: 11px; }
|
||||||
|
|
||||||
|
/* ── Sessions ── */
|
||||||
|
.sev-attack { border-left: 2px solid #c0392b; }
|
||||||
|
.sev-suspicious { border-left: 2px solid #d4ac0d; }
|
||||||
|
.sev-normal { border-left: 2px solid transparent; }
|
||||||
|
|
||||||
|
.sev-label { font-size: 10px; font-weight: 500; letter-spacing: 0.06em; text-transform: uppercase; }
|
||||||
|
.sev-label.sev-attack { color: #e74c3c; }
|
||||||
|
.sev-label.sev-suspicious { color: #f1c40f; }
|
||||||
|
.sev-label.sev-normal { color: var(--text-muted); }
|
||||||
|
|
||||||
|
tr.session-row { cursor: pointer; }
|
||||||
|
tr.session-row:hover td { background: var(--bg-hover); }
|
||||||
|
|
||||||
|
.expand-col { width: 24px; text-align: center; }
|
||||||
|
.expand-btn { font-size: 9px; color: var(--text-muted); user-select: none; }
|
||||||
|
|
||||||
|
td.session-detail-cell { padding: 0; }
|
||||||
|
.session-detail-inner {
|
||||||
|
padding: 10px 16px 12px;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.session-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.session-meta strong { color: var(--text); }
|
||||||
|
|
||||||
|
.session-events-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.session-events-table th {
|
||||||
|
text-align: left;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 400;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.session-events-table td {
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.session-events-table tr:last-child td { border-bottom: none; }
|
||||||
|
|
||||||
|
|
||||||
/* ── Cross-node cells ── */
|
/* ── Cross-node cells ── */
|
||||||
.cn-best { color: var(--green-hi); font-weight: 500; }
|
.cn-best { color: var(--green-hi); font-weight: 500; }
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ let clientsData = [];
|
|||||||
let clientsGroupBy = null; // null | 'vendor' | 'ssid'
|
let clientsGroupBy = null; // null | 'vendor' | 'ssid'
|
||||||
let presenceData = {};
|
let presenceData = {};
|
||||||
let alertsData = {};
|
let alertsData = {};
|
||||||
|
let sessionsData = [];
|
||||||
|
let sessionSort = { col: 'frame_count', dir: 'desc' };
|
||||||
let chartBssid = null;
|
let chartBssid = null;
|
||||||
let chartHours = 2;
|
let chartHours = 2;
|
||||||
|
|
||||||
@@ -570,6 +572,150 @@ function renderDeauthHeatmap(rows) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Sessions view ─────────────────────────────────────────────────────────────
|
||||||
|
function initSessionSort() {
|
||||||
|
document.querySelectorAll('#sessions-table thead th.sortable').forEach(th => {
|
||||||
|
th.style.cursor = 'pointer';
|
||||||
|
th.addEventListener('click', () => {
|
||||||
|
const col = th.dataset.col;
|
||||||
|
if (sessionSort.col === col) {
|
||||||
|
sessionSort.dir = sessionSort.dir === 'desc' ? 'asc' : 'desc';
|
||||||
|
} else {
|
||||||
|
sessionSort.col = col;
|
||||||
|
sessionSort.dir = 'desc';
|
||||||
|
}
|
||||||
|
document.querySelectorAll('#sessions-table thead th.sortable').forEach(h => {
|
||||||
|
h.classList.remove('sort-active');
|
||||||
|
h.dataset.arrow = '';
|
||||||
|
});
|
||||||
|
th.classList.add('sort-active');
|
||||||
|
th.dataset.arrow = sessionSort.dir === 'desc' ? '↓' : '↑';
|
||||||
|
renderSessionsView();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSessions() {
|
||||||
|
sessionsData = await fetch('/api/sessions').then(r => r.json());
|
||||||
|
renderSessionsView();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSessionsView() {
|
||||||
|
const tbody = document.getElementById('sessions-tbody');
|
||||||
|
const empty = document.getElementById('sessions-empty');
|
||||||
|
const count = document.getElementById('sessions-count');
|
||||||
|
|
||||||
|
// Sort
|
||||||
|
const { col, dir } = sessionSort;
|
||||||
|
const sevOrder = { attack: 0, suspicious: 1, normal: 2 };
|
||||||
|
const data = [...sessionsData].sort((a, b) => {
|
||||||
|
let av, bv;
|
||||||
|
if (col === 'bssids') { av = a.unique_bssids.length; bv = b.unique_bssids.length; }
|
||||||
|
else if (col === 'targets') { av = a.unique_targets.length; bv = b.unique_targets.length; }
|
||||||
|
else if (col === 'severity') { av = sevOrder[a.severity] ?? 9; bv = sevOrder[b.severity] ?? 9; }
|
||||||
|
else if (col === 'peak_rssi') { av = a.peak_rssi ?? -200; bv = b.peak_rssi ?? -200; }
|
||||||
|
else { av = a[col]; bv = b[col]; }
|
||||||
|
if (av < bv) return dir === 'desc' ? 1 : -1;
|
||||||
|
if (av > bv) return dir === 'desc' ? -1 : 1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
count.textContent = `${data.length} session${data.length !== 1 ? 's' : ''}`;
|
||||||
|
|
||||||
|
if (!data.length) {
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
empty.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
empty.style.display = 'none';
|
||||||
|
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
|
||||||
|
data.forEach((s, idx) => {
|
||||||
|
const sevClass = `sev-${s.severity}`;
|
||||||
|
const duration = s.duration_secs < 60
|
||||||
|
? `${s.duration_secs}s`
|
||||||
|
: `${Math.floor(s.duration_secs / 60)}m ${s.duration_secs % 60}s`;
|
||||||
|
const vendor = s.src_randomized ? '<span class="muted">Randomized</span>' : (s.src_vendor || '<span class="muted">Unknown</span>');
|
||||||
|
|
||||||
|
const mainRow = document.createElement('tr');
|
||||||
|
mainRow.className = `session-row ${sevClass}`;
|
||||||
|
mainRow.dataset.idx = idx;
|
||||||
|
mainRow.innerHTML = `
|
||||||
|
<td class="expand-col"><span class="expand-btn">▶</span></td>
|
||||||
|
<td class="mono">${s.src}</td>
|
||||||
|
<td>${vendor}</td>
|
||||||
|
<td>${s.start.replace('T', ' ')}</td>
|
||||||
|
<td>${duration}</td>
|
||||||
|
<td>${s.frame_count}</td>
|
||||||
|
<td>${s.unique_bssids.length}</td>
|
||||||
|
<td>${s.unique_targets.length}</td>
|
||||||
|
<td>${s.node_count}</td>
|
||||||
|
<td>${s.peak_rssi != null ? s.peak_rssi + ' dBm' : '—'}</td>
|
||||||
|
<td><span class="sev-label ${sevClass}">${s.severity}</span></td>
|
||||||
|
`;
|
||||||
|
tbody.appendChild(mainRow);
|
||||||
|
|
||||||
|
// Build detail row
|
||||||
|
const detailRow = document.createElement('tr');
|
||||||
|
detailRow.className = 'session-detail';
|
||||||
|
detailRow.dataset.sessionIdx = idx;
|
||||||
|
|
||||||
|
const bssidList = s.unique_bssids.join(', ') || '—';
|
||||||
|
const targetList = s.unique_targets.join(', ') || '—';
|
||||||
|
const nodeList = (s.nodes || []).join(', ') || '—';
|
||||||
|
|
||||||
|
let evRows = '';
|
||||||
|
(s.events || []).forEach(ev => {
|
||||||
|
evRows += `<tr>
|
||||||
|
<td>${ev.received_at.replace('T', ' ')}</td>
|
||||||
|
<td>${ev.node_id}</td>
|
||||||
|
<td>${ev.subtype}</td>
|
||||||
|
<td class="mono">${ev.src}</td>
|
||||||
|
<td class="mono">${ev.dst}</td>
|
||||||
|
<td class="mono">${ev.bssid}</td>
|
||||||
|
<td>${ev.reason}</td>
|
||||||
|
<td>${ev.rssi} dBm</td>
|
||||||
|
</tr>`;
|
||||||
|
});
|
||||||
|
const truncNote = s.frame_count > 50
|
||||||
|
? `<div class="muted" style="font-size:10px;margin-top:4px">Showing first 50 of ${s.frame_count} events</div>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
detailRow.innerHTML = `
|
||||||
|
<td colspan="11" class="session-detail-cell">
|
||||||
|
<div class="session-detail-inner">
|
||||||
|
<div class="session-meta">
|
||||||
|
<span><strong>BSSIDs:</strong> ${bssidList}</span>
|
||||||
|
<span><strong>Targets:</strong> ${targetList}</span>
|
||||||
|
<span><strong>Nodes:</strong> ${nodeList}</span>
|
||||||
|
<span><strong>Avg RSSI:</strong> ${s.avg_rssi != null ? s.avg_rssi + ' dBm' : '—'}</span>
|
||||||
|
<span><strong>Reason:</strong> ${s.dominant_reason}</span>
|
||||||
|
</div>
|
||||||
|
<table class="session-events-table">
|
||||||
|
<thead><tr>
|
||||||
|
<th>Time</th><th>Node</th><th>Type</th><th>Src</th>
|
||||||
|
<th>Dst</th><th>BSSID</th><th>Reason</th><th>RSSI</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>${evRows}</tbody>
|
||||||
|
</table>
|
||||||
|
${truncNote}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
|
|
||||||
|
detailRow.style.display = 'none';
|
||||||
|
|
||||||
|
tbody.appendChild(detailRow);
|
||||||
|
|
||||||
|
mainRow.addEventListener('click', () => {
|
||||||
|
const isOpen = detailRow.style.display !== 'none';
|
||||||
|
detailRow.style.display = isOpen ? 'none' : '';
|
||||||
|
mainRow.querySelector('.expand-btn').textContent = isOpen ? '▶' : '▼';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Alerts view ───────────────────────────────────────────────────────────────
|
// ── Alerts view ───────────────────────────────────────────────────────────────
|
||||||
async function fetchAlerts() {
|
async function fetchAlerts() {
|
||||||
alertsData = await fetch('/api/alerts').then(r => r.json());
|
alertsData = await fetch('/api/alerts').then(r => r.json());
|
||||||
@@ -1022,6 +1168,7 @@ function setView(view) {
|
|||||||
document.getElementById('view-detail').classList.toggle('active', view === 'detail');
|
document.getElementById('view-detail').classList.toggle('active', view === 'detail');
|
||||||
document.getElementById('view-presence').classList.toggle('active', view === 'presence');
|
document.getElementById('view-presence').classList.toggle('active', view === 'presence');
|
||||||
document.getElementById('view-alerts').classList.toggle('active', view === 'alerts');
|
document.getElementById('view-alerts').classList.toggle('active', view === 'alerts');
|
||||||
|
document.getElementById('view-sessions').classList.toggle('active', view === 'sessions');
|
||||||
document.getElementById('view-analysis').classList.toggle('active', view === 'analysis');
|
document.getElementById('view-analysis').classList.toggle('active', view === 'analysis');
|
||||||
document.getElementById('view-search').classList.toggle('active', view === 'search');
|
document.getElementById('view-search').classList.toggle('active', view === 'search');
|
||||||
document.getElementById('tab-feed').classList.toggle('active', view === 'feed' || view === 'detail');
|
document.getElementById('tab-feed').classList.toggle('active', view === 'feed' || view === 'detail');
|
||||||
@@ -1030,6 +1177,7 @@ function setView(view) {
|
|||||||
document.getElementById('tab-clients').classList.toggle('active', view === 'clients');
|
document.getElementById('tab-clients').classList.toggle('active', view === 'clients');
|
||||||
document.getElementById('tab-presence').classList.toggle('active', view === 'presence');
|
document.getElementById('tab-presence').classList.toggle('active', view === 'presence');
|
||||||
document.getElementById('tab-alerts').classList.toggle('active', view === 'alerts');
|
document.getElementById('tab-alerts').classList.toggle('active', view === 'alerts');
|
||||||
|
document.getElementById('tab-sessions').classList.toggle('active', view === 'sessions');
|
||||||
document.getElementById('tab-analysis').classList.toggle('active', view === 'analysis');
|
document.getElementById('tab-analysis').classList.toggle('active', view === 'analysis');
|
||||||
document.getElementById('tab-search').classList.toggle('active', view === 'search');
|
document.getElementById('tab-search').classList.toggle('active', view === 'search');
|
||||||
}
|
}
|
||||||
@@ -1052,6 +1200,8 @@ async function showPresence() { setView('presence'); await fetchPresence(); }
|
|||||||
|
|
||||||
async function showAlerts() { setView('alerts'); await fetchAlerts(); }
|
async function showAlerts() { setView('alerts'); await fetchAlerts(); }
|
||||||
|
|
||||||
|
async function showSessions() { setView('sessions'); initSessionSort(); await fetchSessions(); }
|
||||||
|
|
||||||
async function showAnalysis() {
|
async function showAnalysis() {
|
||||||
setView('analysis');
|
setView('analysis');
|
||||||
const data = await fetch('/api/alerts').then(r => r.json());
|
const data = await fetch('/api/alerts').then(r => r.json());
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
<button class="tab-btn" id="tab-clients" onclick="showClients()">Clients</button>
|
<button class="tab-btn" id="tab-clients" onclick="showClients()">Clients</button>
|
||||||
<button class="tab-btn" id="tab-presence" onclick="showPresence()">Presence</button>
|
<button class="tab-btn" id="tab-presence" onclick="showPresence()">Presence</button>
|
||||||
<button class="tab-btn" id="tab-alerts" onclick="showAlerts()">Alerts</button>
|
<button class="tab-btn" id="tab-alerts" onclick="showAlerts()">Alerts</button>
|
||||||
|
<button class="tab-btn" id="tab-sessions" onclick="showSessions()">Sessions</button>
|
||||||
<button class="tab-btn" id="tab-analysis" onclick="showAnalysis()">Analysis</button>
|
<button class="tab-btn" id="tab-analysis" onclick="showAnalysis()">Analysis</button>
|
||||||
<button class="tab-btn" id="tab-search" onclick="showSearch()">Search</button>
|
<button class="tab-btn" id="tab-search" onclick="showSearch()">Search</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -404,6 +405,36 @@
|
|||||||
|
|
||||||
</div><!-- /#view-alerts -->
|
</div><!-- /#view-alerts -->
|
||||||
|
|
||||||
|
<!-- Sessions view -->
|
||||||
|
<div id="view-sessions" class="view">
|
||||||
|
<div class="section-header">
|
||||||
|
<span class="section-title">Attack Sessions</span>
|
||||||
|
<span class="section-count" id="sessions-count"></span>
|
||||||
|
<span class="presence-sub">last 7 days · deauth events grouped by source MAC + 2-min gap</span>
|
||||||
|
</div>
|
||||||
|
<div class="tbl-wrap">
|
||||||
|
<table id="sessions-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th></th>
|
||||||
|
<th>Source MAC</th>
|
||||||
|
<th>Vendor</th>
|
||||||
|
<th class="sortable" data-col="start" data-arrow="">Start</th>
|
||||||
|
<th class="sortable" data-col="duration_secs" data-arrow="">Duration</th>
|
||||||
|
<th class="sortable sort-active" data-col="frame_count" data-arrow="↓">Frames</th>
|
||||||
|
<th class="sortable" data-col="bssids" data-arrow="">BSSIDs</th>
|
||||||
|
<th class="sortable" data-col="targets" data-arrow="">Targets</th>
|
||||||
|
<th class="sortable" data-col="node_count" data-arrow="">Nodes</th>
|
||||||
|
<th class="sortable" data-col="peak_rssi" data-arrow="">Peak RSSI</th>
|
||||||
|
<th class="sortable" data-col="severity" data-arrow="">Severity</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="sessions-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
<div class="empty" id="sessions-empty" style="display:none">No sessions in the last 7 days.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Analysis view -->
|
<!-- Analysis view -->
|
||||||
<div id="view-analysis" class="view">
|
<div id="view-analysis" class="view">
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user