Skip to content

Commit 801ac6b

Browse files
committed
External sites list shows 50 sites a page
The list drew every site at once, each with its screenshot. It now shows 50 a page, highest combined risk first, with buttons for 100, 200, 500 and 1000 and Back and Next, at the top and the bottom of the table. The search, the filters and the chosen size carry across pages. A page past the end shows the last one, and a size not on the list shows 50. Thumbnails are only made in the background for the rows on the page.
1 parent 4dbf7a7 commit 801ac6b

3 files changed

Lines changed: 63 additions & 4 deletions

File tree

‎app/web/routes_external.py‎

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
import logging
2121
from datetime import datetime, timezone
2222

23-
from fastapi import APIRouter, Depends, HTTPException, Request
23+
from fastapi import APIRouter, Depends, HTTPException, Query, Request
2424
from fastapi.responses import FileResponse, JSONResponse, Response
2525

2626
from app.core import external, rbac
@@ -135,10 +135,17 @@ def repo_recommendations(hosts: str = "", application: str = "", tags: str = "",
135135

136136
# ------------------------------------------------------------------ list
137137

138+
# Rows a page of the list shows. The first is the default: a page of sites
139+
# is a page of screenshots, and fifty keep the first paint quick.
140+
PAGE_SIZES = [50, 100, 200, 500, 1000]
141+
142+
138143
@router.get("/external")
139144
def list_page(request: Request, q: str = "", tag: str = "", app: str = "",
140145
grade: str = "", dast: str = "", risk: str = "", scope: str = "",
141-
env: str = "", status: str = "", user: User = Depends(current_user)):
146+
env: str = "", status: str = "",
147+
page_no: str = Query("1", alias="page"), per_page: str = "",
148+
user: User = Depends(current_user)):
142149
"""Every external site, newest problems first."""
143150
with session_scope() as db:
144151
_guard(db, user)
@@ -206,11 +213,38 @@ def list_page(request: Request, q: str = "", tag: str = "", app: str = "",
206213
rows.append(row)
207214
order = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4, "unknown": 5}
208215
rows.sort(key=lambda r: (order.get(r["risk"]["level"], 9), r["a"].host))
216+
# One page of what matched. A size off the list, or a page past the
217+
# end, falls back to the nearest one there is rather than an error.
218+
try:
219+
size = int(per_page or PAGE_SIZES[0])
220+
except ValueError:
221+
size = PAGE_SIZES[0]
222+
if size not in PAGE_SIZES:
223+
size = PAGE_SIZES[0]
224+
matched = len(rows)
225+
pages = max(1, -(-matched // size))
226+
try:
227+
page_no = min(max(1, int(page_no or 1)), pages)
228+
except ValueError:
229+
page_no = 1
230+
offset = (page_no - 1) * size
231+
rows = rows[offset:offset + size]
232+
from urllib.parse import urlencode
233+
234+
filters = [(k, v) for k, v in (("q", q), ("tag", tag), ("app", app), ("grade", grade),
235+
("dast", dast), ("risk", risk), ("scope", scope),
236+
("env", env), ("status", status)) if v]
237+
paging = {"page": page_no, "pages": pages, "size": size, "sizes": PAGE_SIZES,
238+
"offset": offset, "matched": matched,
239+
# Filters, and the size when it is not the default, so
240+
# every page link keeps both.
241+
"query": urlencode(filters + ([("per_page", size)] if size != PAGE_SIZES[0] else [])),
242+
"filters": urlencode(filters)}
209243
all_tags = sorted({t.tag for t in db.query(ExternalAssetTag.tag).distinct()},
210244
key=str.casefold)
211245
apps = sorted({a.application for a in assets if a.application}, key=str.casefold)
212246
totals = {
213-
"sites": len(assets), "shown": len(rows),
247+
"sites": len(assets), "shown": matched,
214248
"domains": sum(1 for a in assets if a.kind == "domain"),
215249
"live": sum(1 for a in assets if a.category in ("live", "redir", "gated")),
216250
"never": sum(1 for a in assets if not a.last_dast_at and not a.no_scan
@@ -233,7 +267,7 @@ def list_page(request: Request, q: str = "", tag: str = "", app: str = "",
233267
profiles=dast_engines.PROFILES, default_profile=dast_engines.DEFAULT_PROFILE,
234268
auth_kinds=external_auth_mod.KINDS,
235269
q=q, tag=tag, app=app, grade=grade, dast=dast, risk=risk,
236-
scope=scope, all_tags=all_tags, apps=apps, opts=opts,
270+
scope=scope, all_tags=all_tags, apps=apps, opts=opts, paging=paging,
237271
env=env, status=status, environments=envs,
238272
statuses=external.STATUSES,
239273
servers=servers,

‎app/web/templates/external.html‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,10 +75,30 @@
7575
<a class="btn" href="{{ here }}">Clear</a>
7676
</form>
7777

78+
{% macro pager() %}
79+
{%- set p = paging %}
80+
{%- set link = here ~ '?' ~ (p.query ~ '&' if p.query else '') ~ 'page=' %}
81+
<div class="card-body flex-between">
82+
<span class="small faint">Showing {{ p.offset + 1 }} to {{ p.offset + rows|length }} of {{ p.matched }}</span>
83+
<span class="flex" style="gap:.4rem;align-items:center;flex-wrap:wrap">
84+
<span class="small faint">Rows</span>
85+
{% for s in p.sizes %}
86+
<a class="btn sm {{ 'primary' if p.size == s }}" href="{{ here }}?{{ (([p.filters] if p.filters else []) + (['per_page=' ~ s] if s != p.sizes[0] else [])) | join('&') }}">{{ s }}</a>
87+
{% endfor %}
88+
{% if p.pages > 1 %}
89+
{% if p.page > 1 %}<a class="btn sm" href="{{ link }}{{ p.page - 1 }}">Back</a>{% endif %}
90+
<span class="small faint">Page {{ p.page }} of {{ p.pages }}</span>
91+
{% if p.page < p.pages %}<a class="btn sm" href="{{ link }}{{ p.page + 1 }}">Next</a>{% endif %}
92+
{% endif %}
93+
</span>
94+
</div>
95+
{% endmacro %}
96+
7897
<div class="card">
7998
<div class="card-head">{{ totals.shown }} of {{ totals.sites }} site{{ '' if totals.sites == 1 else 's' }}
8099
<span class="faint small" style="font-weight:400;margin-left:.5rem">highest combined risk first</span></div>
81100
{% if rows %}
101+
{{ pager() }}
82102
<form method="post" action="{{ here }}/refresh" id="pick">
83103
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
84104
</form>
@@ -166,6 +186,7 @@
166186
data-ext-confirm="Remove the ticked sites and every scan recorded against them?">Delete the ticked sites</button>
167187
<span class="faint small" data-tick-count style="align-self:center"></span>
168188
</div>
189+
{{ pager() }}
169190
{% elif totals.sites %}
170191
<div class="empty">Nothing matches that. <a href="{{ here }}">Show every site</a></div>
171192
{% else %}

‎documentation/external-sites.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,10 @@ its screenshot is left out. A CDN answering for a site it cannot reach, such
108108
as a Cloudflare 502, is marked as the site being down instead, with its
109109
screenshot.
110110

111+
The list shows 50 sites a page, highest combined risk first, with 100, 200,
112+
500 and 1000 a click away at the top and bottom. Filters and the chosen size
113+
carry across pages.
114+
111115
The list shows a 256x160 JPEG made from each screenshot rather than the
112116
full 1280x800 picture, so a long list loads quickly. The thumbnail is made
113117
when the screenshot is taken. Screenshots from before that get theirs the

0 commit comments

Comments
 (0)