@@ -358,6 +358,68 @@ function updateTopClientsTable(blocked) {
358358 } ) ;
359359}
360360
361+ // Populate the subscription-list dropdown on the "Top Blocked Domains" card.
362+ // Only enabled blocklists are shown; allowlists are excluded.
363+ function populateBlockedListFilter ( ) {
364+ const select = $ ( "#ad-frequency-list-filter" ) ;
365+ // Preserve any previously selected value
366+ const previousVal = select . val ( ) ;
367+
368+ $ . getJSON ( document . body . dataset . apiurl + "/lists" , data => {
369+ // Remove all options except the first "All lists" placeholder
370+ select . find ( "option:not(:first)" ) . remove ( ) ;
371+
372+ if ( ! data . lists ) return ;
373+
374+ data . lists . forEach ( list => {
375+ if ( list . type === "block" && list . enabled ) {
376+ const label =
377+ list . address . length > 50 ? list . address . substring ( 0 , 47 ) + "..." : list . address ;
378+ select . append ( $ ( "<option>" , { value : list . id , text : label , title : list . address } ) ) ;
379+ }
380+ } ) ;
381+
382+ // Restore selection if the same list is still present
383+ if ( previousVal ) select . val ( previousVal ) ;
384+ } ) ;
385+ }
386+
387+ // Fetch the set of adlist IDs a domain belongs to via the search API.
388+ // Returns a Promise resolving to a Set of numeric IDs.
389+ // Response shape: { search: { gravity: [ { id: N, address: "...", ... }, ... ] } }
390+ function fetchDomainAdlistIds ( domain ) {
391+ return new Promise ( resolve => {
392+ $ . getJSON ( document . body . dataset . apiurl + "/search/" + encodeURIComponent ( domain ) )
393+ . then ( data => {
394+ const ids = new Set ( ) ;
395+ if ( data . search && Array . isArray ( data . search . gravity ) ) {
396+ data . search . gravity . forEach ( entry => {
397+ if ( typeof entry . id === "number" ) ids . add ( entry . id ) ;
398+ } ) ;
399+ }
400+ resolve ( ids ) ;
401+ } )
402+ . fail ( ( ) => resolve ( new Set ( ) ) ) ;
403+ } ) ;
404+ }
405+
406+ // Render a list of domain items into the blocked-domains table.
407+ function renderBlockedDomainRows ( items , sum , domaintable ) {
408+ items . forEach ( item => {
409+ const domain = encodeURIComponent ( item . domain ) ;
410+ const urlText = domain === "" ? "." : item . domain ;
411+ const url = '<a href="queries?domain=' + domain + '&upstream=blocklist">' + urlText + "</a>" ;
412+ const percentage = ( item . count / sum ) * 100 ;
413+ domaintable . append (
414+ "<tr> " +
415+ utils . addTD ( url ) +
416+ utils . addTD ( item . count ) +
417+ utils . addTD ( utils . colorBar ( percentage , sum , "queries-blocked" ) ) +
418+ "</tr> "
419+ ) ;
420+ } ) ;
421+ }
422+
361423function updateTopDomainsTable ( blocked ) {
362424 let api ;
363425 let style ;
@@ -366,12 +428,73 @@ function updateTopDomainsTable(blocked) {
366428 let overlay ;
367429 let domaintable ;
368430 if ( blocked ) {
369- api = document . body . dataset . apiurl + "/stats/top_domains?blocked=true" ;
370431 style = "queries-blocked" ;
371432 table = $ ( "#ad-frequency" ) ;
372433 tablecontent = $ ( "#ad-frequency td" ) . parent ( ) ;
373434 overlay = $ ( "#ad-frequency .overlay" ) ;
374435 domaintable = $ ( "#ad-frequency" ) . find ( "tbody:last" ) ;
436+
437+ const selectedList = $ ( "#ad-frequency-list-filter" ) . val ( ) ;
438+ const adlistId = selectedList ? parseInt ( selectedList , 10 ) : - 1 ;
439+
440+ if ( adlistId >= 0 ) {
441+ // ── Filtered mode ────────────────────────────────────────────────────
442+ // Client-side filtering: fetch a larger pool of blocked domains, then
443+ // use /api/search/<domain> for each to check which adlist it belongs
444+ // to. This works with the stock FTL binary (no rebuild required).
445+ // When FTL is eventually rebuilt with the ?list= server-side param,
446+ // this code can be replaced by a single API call.
447+ const POOL_SIZE = 25 ;
448+ tablecontent . remove ( ) ;
449+ overlay . show ( ) ;
450+
451+ $ . getJSON (
452+ document . body . dataset . apiurl + "/stats/top_domains?blocked=true&count=" + POOL_SIZE ,
453+ data => {
454+ const domains = data . domains || [ ] ;
455+ const sum = data . blocked_queries ;
456+
457+ if ( domains . length === 0 ) {
458+ domaintable . append ( '<tr><td colspan="3" class="text-center">- No data -</td></tr>' ) ;
459+ overlay . hide ( ) ;
460+ return ;
461+ }
462+
463+ // Check every domain in parallel; then filter and render.
464+ Promise . all (
465+ domains . map ( item =>
466+ fetchDomainAdlistIds ( item . domain ) . then ( ids => ( {
467+ item,
468+ inList : ids . has ( adlistId ) ,
469+ } ) )
470+ )
471+ ) . then ( results => {
472+ const matched = results
473+ . filter ( r => r . inList )
474+ . slice ( 0 , 10 )
475+ . map ( r => r . item ) ;
476+
477+ tablecontent . remove ( ) ;
478+ if ( matched . length === 0 ) {
479+ domaintable . append (
480+ '<tr><td colspan="3" class="text-center">- No blocked domains from this list -</td></tr>'
481+ ) ;
482+ } else {
483+ renderBlockedDomainRows ( matched , sum , domaintable ) ;
484+ }
485+ overlay . hide ( ) ;
486+ } ) ;
487+ }
488+ ) . fail ( data => {
489+ apiFailure ( data ) ;
490+ } ) ;
491+
492+ // Early return — rendering happens inside the async callbacks above.
493+ return ;
494+ }
495+
496+ // ── Unfiltered mode (All lists) ───────────────────────────────────────
497+ api = document . body . dataset . apiurl + "/stats/top_domains?blocked=true" ;
375498 } else {
376499 api = document . body . dataset . apiurl + "/stats/top_domains" ;
377500 style = "queries-permitted" ;
@@ -828,6 +951,14 @@ $(() => {
828951
829952 // Initialize privacy level before loading any data that depends on it
830953 initPrivacyLevel ( ) . then ( ( ) => {
954+ // Populate the subscription-list dropdown, then load the top lists
955+ populateBlockedListFilter ( ) ;
956+
957+ // Re-fetch the blocked table whenever the user changes the list filter
958+ $ ( "#ad-frequency-list-filter" ) . on ( "change" , ( ) => {
959+ updateTopDomainsTable ( true ) ;
960+ } ) ;
961+
831962 // After privacy level is initialized, load the top lists
832963 updateTopLists ( ) ;
833964 } ) ;
0 commit comments