列表分页与服务端搜索:分页控件、全量导出、排序索引
This commit is contained in:
1 parent
897828f506
commit
f69e0a5a69
2 files changed
+137
-23
No files matched your search
+99
-23
@@ -362,6 +362,9 @@ class Store:
|
||||
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(
|
||||
"""CREATE TABLE IF NOT EXISTS events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -579,7 +582,29 @@ class Store:
|
||||
"SELECT * FROM ip_fused WHERE ip = ?", (ip,)).fetchone()
|
||||
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:
|
||||
rows = self.conn.execute(
|
||||
"""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
|
||||
LEFT JOIN ip_stats s ON s.ip = f.ip
|
||||
LEFT JOIN behavior_profiles b ON b.ip = f.ip
|
||||
""" + where + """
|
||||
ORDER BY f.first_banned_at DESC, f.ip ASC
|
||||
LIMIT ?""", (int(limit),)).fetchall()
|
||||
LIMIT ? OFFSET ?""",
|
||||
params + [int(limit), int(offset)]).fetchall()
|
||||
items = []
|
||||
for row in rows:
|
||||
item = dict(row)
|
||||
@@ -1837,6 +1864,7 @@ a:hover{text-decoration:underline}
|
||||
</table>
|
||||
<div class="empty" id="empty" hidden>没有匹配的记录</div>
|
||||
</div>
|
||||
<div class="pager" id="pager"></div>
|
||||
<div class="hint" id="listHint"></div>
|
||||
</section>
|
||||
|
||||
@@ -1901,7 +1929,8 @@ const RULE_TITLES = {
|
||||
exploit_payload: '注入/穿越载荷', login_attack: '登录失败尝试', login_burst: '登录接口高频',
|
||||
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');
|
||||
function openModal(title, text) {
|
||||
@@ -1974,26 +2003,47 @@ async function loadStats() {
|
||||
|
||||
async function loadList() {
|
||||
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 || [];
|
||||
render();
|
||||
renderPager();
|
||||
$('#listHint').textContent =
|
||||
`共 ${state.items.length} 条 · 点击任意一行查看融合证据 · 按发现时间倒序(最新在前)· 每 60 秒更新`;
|
||||
`共 ${fmt(state.total)} 条 · 第 ${state.page}/${state.pages} 页 · ` +
|
||||
`按发现时间倒序(最新在前)· 每 30 秒更新`;
|
||||
} catch (err) {
|
||||
$('#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() {
|
||||
const q = state.q.trim().toLowerCase();
|
||||
if (!q) return state.items;
|
||||
return state.items.filter(item => {
|
||||
const hay = [item.ip, item.country_name, item.region, item.asn_brand,
|
||||
item.asn_org, item.asn, item.sources,
|
||||
...(item.reasons || []),
|
||||
...Object.keys(item.rules || {})].join(' ').toLowerCase();
|
||||
return hay.includes(q);
|
||||
});
|
||||
return state.items;
|
||||
}
|
||||
|
||||
function renderPager() {
|
||||
const pager = $('#pager');
|
||||
if (!pager) return;
|
||||
const sizes = [20, 50, 100].map(n =>
|
||||
`<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;
|
||||
@@ -2032,7 +2082,7 @@ async function copyText(text, message) {
|
||||
}
|
||||
|
||||
async function copyIps(separator) {
|
||||
const rows = filteredItems();
|
||||
const rows = await fetchAllItems();
|
||||
if (!rows.length) { toast('当前列表为空'); return; }
|
||||
const newline = separator === 'newline';
|
||||
const text = rows.map(item => item.ip).join(newline ? '\n' : ',');
|
||||
@@ -2061,8 +2111,8 @@ function downloadFile(content, filename, mime) {
|
||||
URL.revokeObjectURL(link.href);
|
||||
}
|
||||
|
||||
function exportList(format) {
|
||||
const rows = filteredItems();
|
||||
async function exportList(format) {
|
||||
const rows = await fetchAllItems();
|
||||
if (!rows.length) { toast('当前列表为空'); return; }
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
if (format === 'txt') {
|
||||
@@ -2372,7 +2422,12 @@ function route() {
|
||||
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 => {
|
||||
const target = e.target.closest('[data-copyip]');
|
||||
if (!target) return;
|
||||
@@ -2399,6 +2454,18 @@ $('#exportMenu').addEventListener('click', e => {
|
||||
closeMenus();
|
||||
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 => {
|
||||
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)
|
||||
|
||||
if path == "/api/ips":
|
||||
limit = min(2000, int(query.get("limit", 500)))
|
||||
fused = store.fused_items(limit)
|
||||
page_size = int(query.get("page_size", query.get("limit", 50)) or 50)
|
||||
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
|
||||
items = []
|
||||
for row in fused:
|
||||
@@ -2519,9 +2592,12 @@ def make_handler(store, window, interval, detector=None, path_prefix=""):
|
||||
"asn_type": row.get("asn_type") or "",
|
||||
"asn_icon": row.get("asn_icon") or "",
|
||||
})
|
||||
return self._send(200, {"count": len(items),
|
||||
"updated_at": store.stats().get("updated_at"),
|
||||
"items": 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"),
|
||||
"items": items})
|
||||
if path in ("/api/export.csv", "/api/export.txt"):
|
||||
scope = query.get("scope", "core")
|
||||
if scope not in ("core", "all"):
|
||||
|
||||
@@ -341,6 +341,44 @@ class PathPrefixTests(unittest.TestCase):
|
||||
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):
|
||||
def test_status_endpoint(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
|
||||
Reference in new issue
Block a user