feat: отмена скана на сервере, стабильнее scan_id, упрощённый UI
- Runner: context, POST /api/scans/{id}/cancel, частичный canceled; Start до ответа createScan
- API: scanPathID (PathUnescape), лог при 404; Postgres GetScan логирует ошибки чтения
- newScanID: суффикс 8 hex; тест обновлён
- Web UI: убраны строки scan_id/from-to; скрытое поле scanId; cancel при таймауте/новом скане
Made-with: Cursor
This commit is contained in:
+45
-179
@@ -37,8 +37,9 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<input type="hidden" id="scanId" value="" />
|
||||
<h2>NetTopo: просмотр топологии</h2>
|
||||
<div class="hint">После скана нажмите «Загрузить»: граф раскладывается пружинной моделью по LLDP, стрелки — от локального порта к соседу; полный список связей — блок под графом. Таблица устройств: сортировка по заголовкам. <strong>Поиск MAC</strong> — блок с фиолетовой рамкой сразу под полем scan_id: на каком коммутаторе и порту виден адрес (FDB в БД).</div>
|
||||
<div class="hint">После скана граф и таблица обновляются автоматически; граф — пружинная модель по LLDP, стрелки от локального порта к соседу; полный список связей — блок под графом. Таблица устройств: сортировка по заголовкам. <strong>Поиск MAC</strong> — блок с фиолетовой рамкой ниже (нужен текущий scan после «Создать scan и ждать»): на каком коммутаторе и порту виден адрес (FDB в БД).</div>
|
||||
<div class="pipeline">
|
||||
<strong>Один scan = один полный проход по сети.</strong> Для каждого IP из CIDR (с исключениями): <strong>ping</strong> → для ответивших — проверка <strong>TCP-портов</strong> → если включён SNMP — опрос <strong>системы (sysName и др.)</strong> и сразу обход <strong>LLDP-MIB</strong> (соседи по портам). Отдельной кнопки «только LLDP» нет: соседи попадают в этот же цикл. Кнопка «Создать scan» ставит задачу и сама ждёт завершения, показывая прогресс в строке статуса ниже.
|
||||
</div>
|
||||
@@ -47,18 +48,10 @@
|
||||
<button id="createScanBtn">Создать scan и ждать</button>
|
||||
<button id="watchScanBtn">Ждать завершения (текущий scan_id)</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<input id="scanId" placeholder="scan_id" style="min-width: 380px;" />
|
||||
<select id="scanSelect" style="min-width: 360px;"></select>
|
||||
<button id="loadBtn">Загрузить</button>
|
||||
<button id="latestBtn">Последний scan</button>
|
||||
<button id="exportTopologyBtn">Экспорт topology JSON</button>
|
||||
<button id="fitBtn">По центру</button>
|
||||
</div>
|
||||
|
||||
<div id="macSearchPanel" style="margin:10px 0;padding:12px 14px;background:#eef2ff;border:2px solid #6366f1;border-radius:10px;box-shadow:0 1px 3px rgba(99,102,241,0.2);">
|
||||
<div style="font-weight:700;font-size:15px;color:#1e1b4b;margin-bottom:6px;">Где этот MAC подключён? (таблица FDB по выбранному scan)</div>
|
||||
<p class="hint" style="margin:0 0 10px;">Сначала укажите <strong>scan_id</strong> в поле выше (или «Последний scan» / «Загрузить»). Введите MAC. Строки сортируются: сначала порты с малым числом уникальных MAC на том же <strong>VLAN</strong> и порту (вероятный доступ), затем «между порогами», затем транк/uplink.</p>
|
||||
<p class="hint" style="margin:0 0 10px;">Нужен <strong>текущий scan</strong> (после «Создать scan и ждать» или «Ждать завершения»). Введите MAC. Строки сортируются: сначала порты с малым числом уникальных MAC на том же <strong>VLAN</strong> и порту (вероятный доступ), затем «между порогами», затем транк/uplink.</p>
|
||||
<div class="row" style="align-items:center;flex-wrap:wrap;gap:8px;">
|
||||
<label for="macSearchInput" style="font-weight:600;color:#312e81;">MAC</label>
|
||||
<input id="macSearchInput" placeholder="aa:bb:cc:dd:ee:ff" style="min-width:260px;font-family:monospace;" autocomplete="off" />
|
||||
@@ -88,20 +81,12 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="macSearchTableBody">
|
||||
<tr><td colspan="10" style="padding:10px;color:#64748b;">Введите MAC и нажмите кнопку (нужен scan_id в поле выше).</td></tr>
|
||||
<tr><td colspan="10" style="padding:10px;color:#64748b;">Введите MAC и нажмите кнопку (нужен активный scan после скана).</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<input id="fromScanId" placeholder="from scan_id" style="min-width: 280px;" />
|
||||
<input id="toScanId" placeholder="to scan_id" style="min-width: 280px;" />
|
||||
<select id="fromScanSelect" style="min-width: 240px;"></select>
|
||||
<select id="toScanSelect" style="min-width: 240px;"></select>
|
||||
<button id="diffBtn">Сравнить</button>
|
||||
<button id="exportDiffBtn">Экспорт diff JSON</button>
|
||||
</div>
|
||||
<details class="admin-purge" style="margin:10px 0;padding:8px 12px;background:#fff7ed;border:1px solid #fed7aa;border-radius:8px;max-width:720px;">
|
||||
<summary style="cursor:pointer;font-weight:600;color:#9a3412;">Админ: начать всё заново — очистить данные сканов в БД</summary>
|
||||
<p class="hint" style="margin:8px 0;">На сервере должен быть задан <code>NETTOPO_RESET_DB_SECRET</code>. Ключ ниже передаётся только в заголовке запроса и нигде не сохраняется.</p>
|
||||
@@ -130,7 +115,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="deviceTableBody">
|
||||
<tr><td colspan="5" style="padding:10px;color:#64748b;">Укажите scan_id или создайте scan.</td></tr>
|
||||
<tr><td colspan="5" style="padding:10px;color:#64748b;">Создайте scan или дождитесь завершения — таблица обновится автоматически.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -209,19 +194,8 @@
|
||||
<script>
|
||||
const scanIdInput = document.getElementById("scanId");
|
||||
const cidrInput = document.getElementById("cidrInput");
|
||||
const scanSelect = document.getElementById("scanSelect");
|
||||
const fromScanIdInput = document.getElementById("fromScanId");
|
||||
const toScanIdInput = document.getElementById("toScanId");
|
||||
const fromScanSelect = document.getElementById("fromScanSelect");
|
||||
const toScanSelect = document.getElementById("toScanSelect");
|
||||
const loadBtn = document.getElementById("loadBtn");
|
||||
const createScanBtn = document.getElementById("createScanBtn");
|
||||
const watchScanBtn = document.getElementById("watchScanBtn");
|
||||
const latestBtn = document.getElementById("latestBtn");
|
||||
const diffBtn = document.getElementById("diffBtn");
|
||||
const exportTopologyBtn = document.getElementById("exportTopologyBtn");
|
||||
const exportDiffBtn = document.getElementById("exportDiffBtn");
|
||||
const fitBtn = document.getElementById("fitBtn");
|
||||
const nodeFilter = document.getElementById("nodeFilter");
|
||||
const statusEl = document.getElementById("status");
|
||||
const diffBox = document.getElementById("diffBox");
|
||||
@@ -238,7 +212,6 @@
|
||||
const NS = "http://www.w3.org/2000/svg";
|
||||
let lastTopology = null;
|
||||
let lastHighlight = null;
|
||||
let lastDiff = null;
|
||||
/** @type {{ip:string,ping:string,name:string,snmp:string,lldp:string,lldpCount:number}[]} */
|
||||
let deviceRowsCache = [];
|
||||
let deviceSort = { key: "ip", dir: 1 };
|
||||
@@ -264,6 +237,20 @@
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Просит сервер остановить скан (отмена воркера + статус canceled в БД). */
|
||||
async function cancelServerScan(id) {
|
||||
const s = String(id || "").trim();
|
||||
if (!s) return;
|
||||
try {
|
||||
const res = await fetch(`/api/scans/${encodeURIComponent(s)}/cancel`, { method: "POST" });
|
||||
if (!res.ok && res.status !== 404) {
|
||||
console.warn("cancel scan", s, res.status, await res.text());
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("cancel scan", s, e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Человекочитаемое описание задачи для строки статуса */
|
||||
function describeScanProgress(job) {
|
||||
const id = job.id || "";
|
||||
@@ -294,20 +281,25 @@
|
||||
if (st === "failed") {
|
||||
return `Скан ${id}: помечен как failed (смотрите journalctl на сервере).`;
|
||||
}
|
||||
if (st === "canceled") {
|
||||
return `Скан ${id}: отменён. Частичные результаты (если были) остаются в БД; прогресс ${p}%, адресов в задании: ${t}.`;
|
||||
}
|
||||
return `Скан ${id}: статус «${st}», прогресс ${p}%.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Опрашивает /api/scans/{id} до done/failed или таймаута.
|
||||
* @param {{ intervalMs?: number, maxWaitMs?: number, onDone?: function }} opts
|
||||
* Опрашивает /api/scans/{id} до done/failed/canceled или таймаута.
|
||||
* @param {{ intervalMs?: number, maxWaitMs?: number, cancelOnSeqAbort?: boolean, onDone?: function }} opts
|
||||
*/
|
||||
async function pollScanUntilDone(scanId, opts = {}) {
|
||||
const cancelOnSeqAbort = opts.cancelOnSeqAbort !== false;
|
||||
const mySeq = ++scanPollSeq;
|
||||
const intervalMs = opts.intervalMs ?? 1000;
|
||||
const maxWaitMs = opts.maxWaitMs ?? 20 * 60 * 1000;
|
||||
const started = Date.now();
|
||||
while (Date.now() - started < maxWaitMs) {
|
||||
if (mySeq !== scanPollSeq) {
|
||||
if (cancelOnSeqAbort) await cancelServerScan(scanId);
|
||||
return null;
|
||||
}
|
||||
let job;
|
||||
@@ -320,6 +312,7 @@
|
||||
return null;
|
||||
}
|
||||
if (mySeq !== scanPollSeq) {
|
||||
if (cancelOnSeqAbort) await cancelServerScan(scanId);
|
||||
return null;
|
||||
}
|
||||
setStatus(describeScanProgress(job));
|
||||
@@ -341,7 +334,8 @@
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
if (mySeq === scanPollSeq) {
|
||||
setStatus(`Таймаут ожидания скана ${scanId} (${Math.round(maxWaitMs / 60000)} мин).`);
|
||||
setStatus(`Таймаут ожидания скана ${scanId} (${Math.round(maxWaitMs / 60000)} мин). Запрашиваю отмену на сервере…`);
|
||||
await cancelServerScan(scanId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -668,7 +662,7 @@
|
||||
async function selectDeviceAndLoadLinks(ip) {
|
||||
const scanId = scanIdInput.value.trim();
|
||||
if (!scanId) {
|
||||
setStatus("Укажите scan_id, чтобы загрузить связи.");
|
||||
setStatus("Нет scan_id: сначала создайте scan или дождитесь его завершения.");
|
||||
return;
|
||||
}
|
||||
selectedDeviceIP = ip;
|
||||
@@ -784,7 +778,8 @@
|
||||
if (!scanId) {
|
||||
deviceRowsCache = [];
|
||||
clearDeviceDetailPanel();
|
||||
deviceTableBody.innerHTML = '<tr><td colspan="5" style="padding:10px;color:#64748b;">Нет scan_id.</td></tr>';
|
||||
deviceTableBody.innerHTML =
|
||||
'<tr><td colspan="5" style="padding:10px;color:#64748b;">Нет scan_id — создайте scan или дождитесь завершения.</td></tr>';
|
||||
updateDeviceSortIndicators();
|
||||
return;
|
||||
}
|
||||
@@ -839,17 +834,6 @@
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
function setDiff(text) { diffBox.textContent = text; }
|
||||
function downloadJSON(filename, data) {
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function clearSVG() {
|
||||
while (svg.firstChild) svg.removeChild(svg.firstChild);
|
||||
@@ -1107,35 +1091,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
function fillSelect(selectEl, scans, placeholder) {
|
||||
selectEl.innerHTML = "";
|
||||
const empty = document.createElement("option");
|
||||
empty.value = "";
|
||||
empty.textContent = placeholder;
|
||||
selectEl.appendChild(empty);
|
||||
scans.forEach(s => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = s.id;
|
||||
opt.textContent = `${s.id} (${s.status})`;
|
||||
selectEl.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadScanOptions() {
|
||||
try {
|
||||
const res = await fetch("/api/scans?limit=20");
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const scans = data.items || [];
|
||||
fillSelect(scanSelect, scans, "Выбрать scan");
|
||||
fillSelect(fromScanSelect, scans, "from scan");
|
||||
fillSelect(toScanSelect, scans, "to scan");
|
||||
if (scans[0] && scans[0].id) {
|
||||
scanIdInput.value = scans[0].id;
|
||||
toScanIdInput.value = scans[0].id;
|
||||
}
|
||||
if (scans[1] && scans[1].id) {
|
||||
fromScanIdInput.value = scans[1].id;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
@@ -1143,7 +1106,7 @@
|
||||
async function loadTopology() {
|
||||
const id = scanIdInput.value.trim();
|
||||
if (!id) {
|
||||
setStatus("Введите scan_id");
|
||||
setStatus("Нет текущего scan_id: сначала создайте scan или нажмите «Ждать завершения».");
|
||||
return;
|
||||
}
|
||||
setStatus("Загружаю topology...");
|
||||
@@ -1172,6 +1135,11 @@
|
||||
}
|
||||
setStatus("Отправка запроса на сервер: создание задачи scan…");
|
||||
try {
|
||||
const prevScanId = scanIdInput.value.trim();
|
||||
if (prevScanId) {
|
||||
setStatus(`Отменяю предыдущий скан ${prevScanId} на сервере…`);
|
||||
await cancelServerScan(prevScanId);
|
||||
}
|
||||
const payload = {
|
||||
name: `manual-${new Date().toISOString()}`,
|
||||
cidrs: [cidr],
|
||||
@@ -1197,7 +1165,6 @@
|
||||
const d = await res.json();
|
||||
const sid = d.scan_id;
|
||||
scanIdInput.value = sid;
|
||||
toScanIdInput.value = sid;
|
||||
setStatus(`Задача ${sid} создана (статус: ${d.status || "queued"}). Фоновый воркер запустил полный цикл по сети — жду завершения…`);
|
||||
await loadScanOptions();
|
||||
|
||||
@@ -1207,7 +1174,7 @@
|
||||
if (job.status === "done") {
|
||||
await loadDeviceTable(sid);
|
||||
await loadTopology();
|
||||
} else if (job.status === "failed") {
|
||||
} else if (job.status === "failed" || job.status === "canceled") {
|
||||
await loadDeviceTable(sid);
|
||||
setStatus(describeScanProgress(job) + " Частичные результаты (если есть) в таблице.");
|
||||
}
|
||||
@@ -1221,87 +1188,26 @@
|
||||
async function watchScan() {
|
||||
const id = scanIdInput.value.trim();
|
||||
if (!id) {
|
||||
setStatus("Введите scan_id или выберите из списка.");
|
||||
setStatus("Нет текущего scan: сначала «Создать scan и ждать» или «Ждать завершения» для уже созданной задачи.");
|
||||
return;
|
||||
}
|
||||
setStatus(`Подключаюсь к задаче ${id}, буду опрашивать статус…`);
|
||||
await pollScanUntilDone(id, {
|
||||
intervalMs: 1500,
|
||||
cancelOnSeqAbort: false,
|
||||
onDone: async (job) => {
|
||||
await loadScanOptions();
|
||||
setStatus(describeScanProgress(job));
|
||||
if (job.status === "done") {
|
||||
await loadDeviceTable(id);
|
||||
await loadTopology();
|
||||
} else if (job.status === "failed") {
|
||||
} else if (job.status === "failed" || job.status === "canceled") {
|
||||
await loadDeviceTable(id);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function loadLatestScan() {
|
||||
setStatus("Ищу последний scan...");
|
||||
try {
|
||||
const res = await fetch("/api/scans?limit=1");
|
||||
if (!res.ok) {
|
||||
setStatus(`Ошибка ${res.status} при получении scan list`);
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
const latest = data.items && data.items[0];
|
||||
if (!latest || !latest.id) {
|
||||
setStatus("Сканы не найдены");
|
||||
return;
|
||||
}
|
||||
scanIdInput.value = latest.id;
|
||||
toScanIdInput.value = latest.id;
|
||||
setStatus(`Найден последний scan: ${latest.id}`);
|
||||
await loadDeviceTable(latest.id);
|
||||
await loadTopology();
|
||||
} catch (e) {
|
||||
setStatus(`Ошибка загрузки последнего scan: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDiff() {
|
||||
const fromId = fromScanIdInput.value.trim();
|
||||
const toId = toScanIdInput.value.trim();
|
||||
if (!fromId || !toId) {
|
||||
setStatus("Введите from и to scan_id");
|
||||
return;
|
||||
}
|
||||
setStatus("Сравниваю сканы...");
|
||||
try {
|
||||
const url = `/api/scans/diff?from=${encodeURIComponent(fromId)}&to=${encodeURIComponent(toId)}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
const t = await res.text();
|
||||
setStatus(`Ошибка ${res.status}: ${t}`);
|
||||
return;
|
||||
}
|
||||
const d = await res.json();
|
||||
lastDiff = d;
|
||||
setDiff(JSON.stringify(d, null, 2));
|
||||
const topoRes = await fetch(`/api/scans/${encodeURIComponent(toId)}/topology?limit=5000`);
|
||||
if (topoRes.ok) {
|
||||
const topo = await topoRes.json();
|
||||
const highlight = {
|
||||
newHosts: new Set(d.new_hosts || []),
|
||||
missingHosts: new Set(d.missing_hosts || []),
|
||||
};
|
||||
lastTopology = topo;
|
||||
lastHighlight = highlight;
|
||||
drawTopology(topo, highlight);
|
||||
}
|
||||
setStatus(
|
||||
`Diff готов: +hosts ${d.new_hosts?.length || 0}, -hosts ${d.missing_hosts?.length || 0}, +links ${d.new_links?.length || 0}, -links ${d.missing_links?.length || 0}`
|
||||
);
|
||||
} catch (e) {
|
||||
setStatus(`Ошибка diff: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function macPortKindRu(kind) {
|
||||
if (kind === "access") return "доступ (мало MAC)";
|
||||
if (kind === "strong_transit") return "транк / uplink";
|
||||
@@ -1313,7 +1219,7 @@
|
||||
const scanId = scanIdInput.value.trim();
|
||||
const mac = (macSearchInput && macSearchInput.value.trim()) || "";
|
||||
if (!scanId) {
|
||||
setStatus("Укажите scan_id для поиска MAC.");
|
||||
setStatus("Сначала выполните скан («Создать scan и ждать»), затем повторите поиск MAC.");
|
||||
return;
|
||||
}
|
||||
if (!mac) {
|
||||
@@ -1400,10 +1306,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
loadBtn.addEventListener("click", async () => {
|
||||
await loadTopology();
|
||||
await loadDeviceTable(scanIdInput.value.trim());
|
||||
});
|
||||
refreshDevicesBtn.addEventListener("click", () => loadDeviceTable(scanIdInput.value.trim()));
|
||||
macSearchBtn?.addEventListener("click", runMacSearch);
|
||||
macSearchInput?.addEventListener("keydown", (ev) => {
|
||||
@@ -1441,58 +1343,22 @@
|
||||
return;
|
||||
}
|
||||
scanIdInput.value = "";
|
||||
fromScanIdInput.value = "";
|
||||
toScanIdInput.value = "";
|
||||
clearDeviceDetailPanel();
|
||||
deviceRowsCache = [];
|
||||
deviceTableBody.innerHTML =
|
||||
'<tr><td colspan="5" style="padding:10px;color:#64748b;">Нет scan_id. Загрузите после нового скана.</td></tr>';
|
||||
'<tr><td colspan="5" style="padding:10px;color:#64748b;">Нет scan_id. Создайте новый scan.</td></tr>';
|
||||
lastTopology = null;
|
||||
lastDiff = null;
|
||||
lastHighlight = null;
|
||||
clearSVG();
|
||||
edgeListPanel.style.display = "none";
|
||||
diffBox.textContent = "";
|
||||
await loadScanOptions();
|
||||
setStatus("Данные сканов удалены. Создайте новый scan или выберите из списка.");
|
||||
setStatus("Данные сканов удалены. Создайте новый scan.");
|
||||
} catch (e) {
|
||||
setStatus(`Ошибка: ${e.message}`);
|
||||
}
|
||||
});
|
||||
watchScanBtn.addEventListener("click", watchScan);
|
||||
scanSelect.addEventListener("change", () => {
|
||||
if (scanSelect.value) scanIdInput.value = scanSelect.value;
|
||||
});
|
||||
fromScanSelect.addEventListener("change", () => {
|
||||
if (fromScanSelect.value) fromScanIdInput.value = fromScanSelect.value;
|
||||
});
|
||||
toScanSelect.addEventListener("change", () => {
|
||||
if (toScanSelect.value) toScanIdInput.value = toScanSelect.value;
|
||||
});
|
||||
latestBtn.addEventListener("click", loadLatestScan);
|
||||
diffBtn.addEventListener("click", loadDiff);
|
||||
exportTopologyBtn.addEventListener("click", () => {
|
||||
const scanId = scanIdInput.value.trim() || "unknown";
|
||||
if (!lastTopology) {
|
||||
setStatus("Сначала загрузите topology");
|
||||
return;
|
||||
}
|
||||
downloadJSON(`topology_${scanId}.json`, lastTopology);
|
||||
setStatus("Topology JSON экспортирован");
|
||||
});
|
||||
exportDiffBtn.addEventListener("click", () => {
|
||||
const fromId = fromScanIdInput.value.trim() || "from";
|
||||
const toId = toScanIdInput.value.trim() || "to";
|
||||
if (!lastDiff) {
|
||||
setStatus("Сначала выполните diff");
|
||||
return;
|
||||
}
|
||||
downloadJSON(`diff_${fromId}_to_${toId}.json`, lastDiff);
|
||||
setStatus("Diff JSON экспортирован");
|
||||
});
|
||||
fitBtn.addEventListener("click", () => {
|
||||
svg.setAttribute("viewBox", lastGraphViewBox || "0 0 1200 620");
|
||||
});
|
||||
nodeFilter.addEventListener("change", () => {
|
||||
if (lastTopology) {
|
||||
drawTopology(lastTopology, lastHighlight);
|
||||
|
||||
Reference in New Issue
Block a user