列表分页与服务端搜索:分页控件、全量导出、排序索引
This commit is contained in:
1 parent
897828f506
commit
f69e0a5a69
2 files changed
+135
-21
No files matched your search
+97
-21
@@ -362,6 +362,9 @@ class Store:
|
|||||||
fused_reason TEXT, updated_at TEXT
|
fused_reason TEXT, updated_at TEXT
|
||||||
)"""
|
)"""
|
||||||
)
|
)
|
||||||
|
self.conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_ip_fused_first "
|
||||||
|
"ON ip_fused(first_banned_at DESC, ip)")
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"""CREATE TABLE IF NOT EXISTS events (
|
"""CREATE TABLE IF NOT EXISTS events (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -579,7 +582,29 @@ class Store:
|
|||||||
"SELECT * FROM ip_fused WHERE ip = ?", (ip,)).fetchone()
|
"SELECT * FROM ip_fused WHERE ip = ?", (ip,)).fetchone()
|
||||||
return dict(row) if row else None
|
return dict(row) if row else None
|
||||||
|
|
||||||
def fused_items(self, limit=500):
|
@staticmethod
|
||||||
|
def _fused_filter(q):
|
||||||
|
if not q:
|
||||||
|
return "", []
|
||||||
|
like = "%" + q.replace("%", "").replace("_", "") + "%"
|
||||||
|
fields = ("f.ip", "f.fused_reason", "f.sources",
|
||||||
|
"s.country_name", "s.region", "s.asn_brand", "s.asn_org",
|
||||||
|
"b.country_name", "b.region", "b.asn_brand", "b.asn_org")
|
||||||
|
clause = " WHERE (" + " OR ".join("%s LIKE ?" % field
|
||||||
|
for field in fields) + ")"
|
||||||
|
return clause, [like] * len(fields)
|
||||||
|
|
||||||
|
def fused_count(self, q=None):
|
||||||
|
where, params = self._fused_filter(q)
|
||||||
|
with self.lock:
|
||||||
|
return self.conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM ip_fused f "
|
||||||
|
"LEFT JOIN ip_stats s ON s.ip = f.ip "
|
||||||
|
"LEFT JOIN behavior_profiles b ON b.ip = f.ip" + where,
|
||||||
|
params).fetchone()[0]
|
||||||
|
|
||||||
|
def fused_items(self, limit=500, offset=0, q=None):
|
||||||
|
where, params = self._fused_filter(q)
|
||||||
with self.lock:
|
with self.lock:
|
||||||
rows = self.conn.execute(
|
rows = self.conn.execute(
|
||||||
"""SELECT f.*, s.tier AS tier, s.total AS total, s.rules AS s_rules,
|
"""SELECT f.*, s.tier AS tier, s.total AS total, s.rules AS s_rules,
|
||||||
@@ -597,8 +622,10 @@ class Store:
|
|||||||
FROM ip_fused f
|
FROM ip_fused f
|
||||||
LEFT JOIN ip_stats s ON s.ip = f.ip
|
LEFT JOIN ip_stats s ON s.ip = f.ip
|
||||||
LEFT JOIN behavior_profiles b ON b.ip = f.ip
|
LEFT JOIN behavior_profiles b ON b.ip = f.ip
|
||||||
|
""" + where + """
|
||||||
ORDER BY f.first_banned_at DESC, f.ip ASC
|
ORDER BY f.first_banned_at DESC, f.ip ASC
|
||||||
LIMIT ?""", (int(limit),)).fetchall()
|
LIMIT ? OFFSET ?""",
|
||||||
|
params + [int(limit), int(offset)]).fetchall()
|
||||||
items = []
|
items = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
item = dict(row)
|
item = dict(row)
|
||||||
@@ -1837,6 +1864,7 @@ a:hover{text-decoration:underline}
|
|||||||
</table>
|
</table>
|
||||||
<div class="empty" id="empty" hidden>没有匹配的记录</div>
|
<div class="empty" id="empty" hidden>没有匹配的记录</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="pager" id="pager"></div>
|
||||||
<div class="hint" id="listHint"></div>
|
<div class="hint" id="listHint"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -1901,7 +1929,8 @@ const RULE_TITLES = {
|
|||||||
exploit_payload: '注入/穿越载荷', login_attack: '登录失败尝试', login_burst: '登录接口高频',
|
exploit_payload: '注入/穿越载荷', login_attack: '登录失败尝试', login_burst: '登录接口高频',
|
||||||
bad_ua: '扫描器 UA', bot_ua: '爬虫 UA', not_found: '404 探测'
|
bad_ua: '扫描器 UA', bot_ua: '爬虫 UA', not_found: '404 探测'
|
||||||
};
|
};
|
||||||
const state = { q: '', items: [], timer: null, inDetail: false, detailSamples: [] };
|
const state = { q: '', items: [], page: 1, pageSize: 50, total: 0, pages: 1,
|
||||||
|
timer: null, inDetail: false, detailSamples: [] };
|
||||||
|
|
||||||
const modal = $('#modal');
|
const modal = $('#modal');
|
||||||
function openModal(title, text) {
|
function openModal(title, text) {
|
||||||
@@ -1974,26 +2003,47 @@ async function loadStats() {
|
|||||||
|
|
||||||
async function loadList() {
|
async function loadList() {
|
||||||
try {
|
try {
|
||||||
const data = await jget(BASE + '/api/ips?limit=500');
|
const params = new URLSearchParams({
|
||||||
|
page: state.page, page_size: state.pageSize,
|
||||||
|
});
|
||||||
|
if (state.q.trim()) params.set('q', state.q.trim());
|
||||||
|
const data = await jget(BASE + '/api/ips?' + params.toString());
|
||||||
|
state.total = data.total || 0;
|
||||||
|
state.pages = data.pages || 1;
|
||||||
|
if (state.page > state.pages) { state.page = state.pages; return loadList(); }
|
||||||
state.items = data.items || [];
|
state.items = data.items || [];
|
||||||
render();
|
render();
|
||||||
|
renderPager();
|
||||||
$('#listHint').textContent =
|
$('#listHint').textContent =
|
||||||
`共 ${state.items.length} 条 · 点击任意一行查看融合证据 · 按发现时间倒序(最新在前)· 每 60 秒更新`;
|
`共 ${fmt(state.total)} 条 · 第 ${state.page}/${state.pages} 页 · ` +
|
||||||
|
`按发现时间倒序(最新在前)· 每 30 秒更新`;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
$('#rows').innerHTML = `<tr><td colspan="10" class="empty">加载失败:${esc(err.message)}</td></tr>`;
|
$('#rows').innerHTML = `<tr><td colspan="10" class="empty">加载失败:${esc(err.message)}</td></tr>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchAllItems() {
|
||||||
|
const params = new URLSearchParams({ limit: 2000 });
|
||||||
|
if (state.q.trim()) params.set('q', state.q.trim());
|
||||||
|
const data = await jget(BASE + '/api/ips?' + params.toString());
|
||||||
|
return data.items || [];
|
||||||
|
}
|
||||||
|
|
||||||
function filteredItems() {
|
function filteredItems() {
|
||||||
const q = state.q.trim().toLowerCase();
|
return state.items;
|
||||||
if (!q) return state.items;
|
}
|
||||||
return state.items.filter(item => {
|
|
||||||
const hay = [item.ip, item.country_name, item.region, item.asn_brand,
|
function renderPager() {
|
||||||
item.asn_org, item.asn, item.sources,
|
const pager = $('#pager');
|
||||||
...(item.reasons || []),
|
if (!pager) return;
|
||||||
...Object.keys(item.rules || {})].join(' ').toLowerCase();
|
const sizes = [20, 50, 100].map(n =>
|
||||||
return hay.includes(q);
|
`<option value="${n}" ${n === state.pageSize ? 'selected' : ''}>${n} 条/页</option>`
|
||||||
});
|
).join('');
|
||||||
|
pager.innerHTML =
|
||||||
|
`<button class="btn" data-page="${state.page - 1}" ${state.page <= 1 ? 'disabled' : ''}>‹ 上一页</button>` +
|
||||||
|
`<span class="pagemeta">第 <b>${state.page}</b> / ${state.pages} 页 · 共 ${fmt(state.total)} 条</span>` +
|
||||||
|
`<button class="btn" data-page="${state.page + 1}" ${state.page >= state.pages ? 'disabled' : ''}>下一页 ›</button>` +
|
||||||
|
`<select id="pageSize">${sizes}</select>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
let toastTimer = null;
|
let toastTimer = null;
|
||||||
@@ -2032,7 +2082,7 @@ async function copyText(text, message) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function copyIps(separator) {
|
async function copyIps(separator) {
|
||||||
const rows = filteredItems();
|
const rows = await fetchAllItems();
|
||||||
if (!rows.length) { toast('当前列表为空'); return; }
|
if (!rows.length) { toast('当前列表为空'); return; }
|
||||||
const newline = separator === 'newline';
|
const newline = separator === 'newline';
|
||||||
const text = rows.map(item => item.ip).join(newline ? '\n' : ',');
|
const text = rows.map(item => item.ip).join(newline ? '\n' : ',');
|
||||||
@@ -2061,8 +2111,8 @@ function downloadFile(content, filename, mime) {
|
|||||||
URL.revokeObjectURL(link.href);
|
URL.revokeObjectURL(link.href);
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportList(format) {
|
async function exportList(format) {
|
||||||
const rows = filteredItems();
|
const rows = await fetchAllItems();
|
||||||
if (!rows.length) { toast('当前列表为空'); return; }
|
if (!rows.length) { toast('当前列表为空'); return; }
|
||||||
const stamp = new Date().toISOString().slice(0, 10);
|
const stamp = new Date().toISOString().slice(0, 10);
|
||||||
if (format === 'txt') {
|
if (format === 'txt') {
|
||||||
@@ -2372,7 +2422,12 @@ function route() {
|
|||||||
else showList();
|
else showList();
|
||||||
}
|
}
|
||||||
|
|
||||||
$('#search').addEventListener('input', e => { state.q = e.target.value; render(); });
|
let searchTimer = null;
|
||||||
|
$('#search').addEventListener('input', e => {
|
||||||
|
state.q = e.target.value;
|
||||||
|
clearTimeout(searchTimer);
|
||||||
|
searchTimer = setTimeout(() => { state.page = 1; loadList(); }, 250);
|
||||||
|
});
|
||||||
document.addEventListener('click', e => {
|
document.addEventListener('click', e => {
|
||||||
const target = e.target.closest('[data-copyip]');
|
const target = e.target.closest('[data-copyip]');
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
@@ -2399,6 +2454,18 @@ $('#exportMenu').addEventListener('click', e => {
|
|||||||
closeMenus();
|
closeMenus();
|
||||||
exportList(item.dataset.export);
|
exportList(item.dataset.export);
|
||||||
});
|
});
|
||||||
|
$('#pager').addEventListener('click', e => {
|
||||||
|
const btn = e.target.closest('button[data-page]');
|
||||||
|
if (!btn || btn.disabled) return;
|
||||||
|
state.page = Number(btn.dataset.page);
|
||||||
|
loadList();
|
||||||
|
});
|
||||||
|
$('#pager').addEventListener('change', e => {
|
||||||
|
if (e.target.id !== 'pageSize') return;
|
||||||
|
state.pageSize = Number(e.target.value);
|
||||||
|
state.page = 1;
|
||||||
|
loadList();
|
||||||
|
});
|
||||||
$('#auto').addEventListener('change', e => {
|
$('#auto').addEventListener('change', e => {
|
||||||
if (e.target.checked) startTimer(); else clearInterval(state.timer);
|
if (e.target.checked) startTimer(); else clearInterval(state.timer);
|
||||||
});
|
});
|
||||||
@@ -2485,8 +2552,14 @@ def make_handler(store, window, interval, detector=None, path_prefix=""):
|
|||||||
return self._send(200, status)
|
return self._send(200, status)
|
||||||
|
|
||||||
if path == "/api/ips":
|
if path == "/api/ips":
|
||||||
limit = min(2000, int(query.get("limit", 500)))
|
page_size = int(query.get("page_size", query.get("limit", 50)) or 50)
|
||||||
fused = store.fused_items(limit)
|
page_size = max(1, min(2000, page_size))
|
||||||
|
page = max(1, int(query.get("page", 1) or 1))
|
||||||
|
offset = max(0, int(query.get("offset",
|
||||||
|
(page - 1) * page_size) or 0))
|
||||||
|
search_q = (query.get("q") or "").strip()
|
||||||
|
total = store.fused_count(search_q)
|
||||||
|
fused = store.fused_items(page_size, offset, search_q)
|
||||||
window = detector.window if detector is not None else 3600
|
window = detector.window if detector is not None else 3600
|
||||||
items = []
|
items = []
|
||||||
for row in fused:
|
for row in fused:
|
||||||
@@ -2519,7 +2592,10 @@ def make_handler(store, window, interval, detector=None, path_prefix=""):
|
|||||||
"asn_type": row.get("asn_type") or "",
|
"asn_type": row.get("asn_type") or "",
|
||||||
"asn_icon": row.get("asn_icon") or "",
|
"asn_icon": row.get("asn_icon") or "",
|
||||||
})
|
})
|
||||||
return self._send(200, {"count": len(items),
|
pages = max(1, (total + page_size - 1) // page_size)
|
||||||
|
return self._send(200, {
|
||||||
|
"count": len(items), "total": total, "page": page,
|
||||||
|
"page_size": page_size, "pages": pages, "q": search_q,
|
||||||
"updated_at": store.stats().get("updated_at"),
|
"updated_at": store.stats().get("updated_at"),
|
||||||
"items": items})
|
"items": items})
|
||||||
if path in ("/api/export.csv", "/api/export.txt"):
|
if path in ("/api/export.csv", "/api/export.txt"):
|
||||||
|
|||||||
@@ -341,6 +341,44 @@ class PathPrefixTests(unittest.TestCase):
|
|||||||
store.close()
|
store.close()
|
||||||
|
|
||||||
|
|
||||||
|
class PaginationTests(unittest.TestCase):
|
||||||
|
def test_pagination_and_search(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
store = watch.Store(os.path.join(directory, "ips.db"))
|
||||||
|
for index, ip in enumerate(("203.0.113.9", "203.0.113.10",
|
||||||
|
"198.51.100.77")):
|
||||||
|
store.upsert({"ip": ip, "tier": watch.TIER_MALICIOUS,
|
||||||
|
"score": 90.0, "total": 100 + index,
|
||||||
|
"window": 3600, "first_seen":
|
||||||
|
"2026-10-17T16:0%d:00Z" % index,
|
||||||
|
"rules": {"probe_java": 2},
|
||||||
|
"reasons": ["敏感路径探测"]},
|
||||||
|
"2026-10-17T16:30:00Z")
|
||||||
|
store.sync_fused()
|
||||||
|
server = ThreadingHTTPServer(("127.0.0.1", 0),
|
||||||
|
watch.make_handler(store, 3600, 60))
|
||||||
|
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||||
|
base = "http://127.0.0.1:%d" % server.server_address[1]
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(
|
||||||
|
base + "/api/ips?page_size=2&page=2", timeout=5) as response:
|
||||||
|
page = json.loads(response.read().decode("utf-8"))
|
||||||
|
self.assertEqual(page["total"], 3)
|
||||||
|
self.assertEqual(page["pages"], 2)
|
||||||
|
self.assertEqual(page["page"], 2)
|
||||||
|
self.assertEqual(len(page["items"]), 1)
|
||||||
|
|
||||||
|
with urllib.request.urlopen(
|
||||||
|
base + "/api/ips?q=203.0.113", timeout=5) as response:
|
||||||
|
filtered = json.loads(response.read().decode("utf-8"))
|
||||||
|
self.assertEqual(filtered["total"], 2)
|
||||||
|
self.assertEqual(len(filtered["items"]), 2)
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
|
||||||
class StatusIndicatorTests(unittest.TestCase):
|
class StatusIndicatorTests(unittest.TestCase):
|
||||||
def test_status_endpoint(self):
|
def test_status_endpoint(self):
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
|||||||
Reference in new issue
Block a user