@@ -503,18 +503,19 @@ def build_response_panel(result: Dict[str, Any], chips: Optional[List] = None) -
503503 title = action ["title" ],
504504 ))
505505 sp_items .append (html .Div (item_children , className = "sp-item" ))
506+ header_children = [
507+ html .Button (
508+ html .I (className = "bi bi-layout-sidebar-reverse" ),
509+ id = "sp-toggle-btn" ,
510+ className = "sp-toggle" ,
511+ n_clicks = 0 ,
512+ title = "Collapse/expand panel" ,
513+ ),
514+ html .Span (f"{ len (chips )} items" , className = "sp-header-text" ),
515+ ]
506516 side_panel = html .Div ([
507517 html .Div (className = "sp-resize-handle" ),
508- html .Div ([
509- html .Button (
510- html .I (className = "bi bi-layout-sidebar-reverse" ),
511- id = "sp-toggle-btn" ,
512- className = "sp-toggle" ,
513- n_clicks = 0 ,
514- title = "Collapse/expand panel" ,
515- ),
516- html .Span (f"{ len (chips )} items" , className = "sp-header-text" ),
517- ], className = "sp-header" ),
518+ html .Div (header_children , className = "sp-header" ),
518519 html .Div (sp_items , className = "sp-list" ),
519520 ], id = "side-panel" , className = "side-panel" )
520521 else :
@@ -930,7 +931,7 @@ def _custom_section():
930931 dcc .Store (id = "curl-dummy" , data = None ), # dummy output for curl copy clientside CB
931932 dcc .Store (id = "sso-pending" , data = None ), # {"host": "..."} while browser OAuth is running
932933 dcc .Interval (id = "sso-poller" , interval = 1000 , disabled = True , n_intervals = 0 ),
933- dcc .Interval (id = "page-ticker" , interval = 500 , disabled = False , n_intervals = 0 ),
934+ dcc .Interval (id = "page-ticker" , interval = 500 , disabled = True , n_intervals = 0 ),
934935
935936 dcc .Store (id = "dropdown-open" , data = False ), # tracks dropdown visibility
936937 dcc .Store (id = "response-cache" , data = {}), # {endpoint_id: {result, chips}} — cached API responses
@@ -946,7 +947,17 @@ def _custom_section():
946947 html .Div ([
947948 html .Div (WELCOME , id = "endpoint-detail" , className = "form-panel" ),
948949 html .Div ([
949- html .Div (id = "fetch-status-bar" , className = "fetch-status-bar" ),
950+ html .Div ([
951+ html .Div (id = "fetch-status-bar" , className = "fetch-status-bar" ),
952+ html .Button (
953+ [html .I (className = "bi bi-cloud-download me-1" ), "Load All" ],
954+ id = "sp-load-all-btn" ,
955+ n_clicks = 0 ,
956+ className = "sp-load-all-btn" ,
957+ title = "Fetch all remaining pages" ,
958+ style = {"display" : "none" },
959+ ),
960+ ], className = "fetch-bar-row" ),
950961 html .Div (_RESPONSE_EMPTY , id = "response-container" , className = "response-container" ),
951962 ], className = "response-panel" ),
952963 ], className = "main-content" ),
@@ -1436,6 +1447,7 @@ def restore_cached_response(endpoint, cache):
14361447 Output ("spinner-off" , "data" ),
14371448 Output ("chips-store" , "data" ),
14381449 Output ("response-cache" , "data" , allow_duplicate = True ),
1450+ Output ("sp-load-all-btn" , "style" ),
14391451 Input ("execute-btn" , "n_clicks" ),
14401452 State ("selected-endpoint" , "data" ),
14411453 State ({"type" : "param-input" , "name" : ALL }, "value" ),
@@ -1447,7 +1459,7 @@ def restore_cached_response(endpoint, cache):
14471459)
14481460def execute_api_call (n_clicks , endpoint , param_values , param_ids , body_text , conn_config , cache ):
14491461 if not n_clicks or not endpoint :
1450- return no_update , no_update , no_update , no_update , no_update
1462+ return no_update , no_update , no_update , no_update , no_update , no_update
14511463
14521464 params : Dict [str , Any ] = {}
14531465 ep_param_map = {p ["name" ]: p for p in endpoint .get ("params" , [])}
@@ -1467,7 +1479,7 @@ def execute_api_call(n_clicks, endpoint, param_values, param_ids, body_text, con
14671479 for pp in endpoint .get ("path_params" , []):
14681480 val = params .pop (pp , "" )
14691481 if not val :
1470- return build_error_panel (f"Path parameter '{ pp } ' is required." ), no_update , time .time (), None , no_update
1482+ return build_error_panel (f"Path parameter '{ pp } ' is required." ), no_update , time .time (), None , no_update , { "display" : "none" }
14711483 path = path .replace (f"{{{ pp } }}" , str (val ))
14721484
14731485 method = endpoint .get ("method" , "GET" )
@@ -1476,19 +1488,21 @@ def execute_api_call(n_clicks, endpoint, param_values, param_ids, body_text, con
14761488 try :
14771489 body = json .loads (body_text )
14781490 except json .JSONDecodeError as e :
1479- return build_error_panel (f"Invalid JSON body: { e } " ), no_update , time .time (), None , no_update
1491+ return build_error_panel (f"Invalid JSON body: { e } " ), no_update , time .time (), None , no_update , { "display" : "none" }
14801492
14811493 host , token = _resolve_conn (conn_config )
14821494 if not token :
1483- return build_error_panel ("No auth token. Configure a connection in the user menu." ), no_update , time .time (), None , no_update
1495+ return build_error_panel ("No auth token. Configure a connection in the user menu." ), no_update , time .time (), None , no_update , { "display" : "none" }
14841496 if not host :
1485- return build_error_panel ("No workspace host. Configure a connection in the user menu." ), no_update , time .time (), None , no_update
1497+ return build_error_panel ("No workspace host. Configure a connection in the user menu." ), no_update , time .time (), None , no_update , { "display" : "none" }
14861498
14871499 result = make_api_call (
14881500 method = method , path = path , token = token , host = host ,
14891501 query_params = params if method == "GET" else None , body = body ,
14901502 )
14911503 chips = extract_chips (endpoint .get ("id" , "" ), result ["data" ]) if result ["success" ] else []
1504+ resp_data = result ["data" ] if isinstance (result ["data" ], dict ) else {}
1505+ has_more = bool (resp_data .get ("has_more" ))
14921506 last_req = {
14931507 "path" : path ,
14941508 "method" : method ,
@@ -1503,23 +1517,29 @@ def execute_api_call(n_clicks, endpoint, param_values, param_ids, body_text, con
15031517 ep_id = endpoint .get ("id" , "" )
15041518 new_cache = dict (cache or {})
15051519 new_cache [ep_id ] = {"result" : result , "chips" : chips or None }
1506- return build_response_panel (result , chips ), last_req , time .time (), chips or None , new_cache
1520+ btn_style = {"display" : "inline-flex" } if has_more else {"display" : "none" }
1521+ return build_response_panel (result , chips ), last_req , time .time (), chips or None , new_cache , btn_style
15071522
15081523
15091524# 11. Signal tick_fetch to start a new pagination run whenever execute fires.
15101525# Writes to page-trigger (separate store) so tick_fetch stays the sole
15111526# primary writer of page-state — ensuring sync_status_bar fires on every tick.
15121527@app .callback (
15131528 Output ("page-trigger" , "data" ),
1529+ Output ("page-ticker" , "disabled" ),
15141530 Input ("last-request" , "data" ),
15151531 prevent_initial_call = True ,
15161532)
15171533def start_pagination (last_req ):
15181534 if not last_req :
1519- return {}
1535+ return {}, True
15201536 initial_data = last_req .get ("initial_data" , {})
1537+ # Skip auto-pagination when has_more is present — "Load All" handles those
1538+ if isinstance (initial_data , dict ) and initial_data .get ("has_more" ):
1539+ return {"run_id" : time .time ()}, True
15211540 next_token = _detect_next_page_token (initial_data )
15221541 list_key = _find_list_key (initial_data ) if next_token else None
1542+ should_paginate = next_token is not None
15231543 return {
15241544 "run_id" : time .time (),
15251545 "next_token" : next_token ,
@@ -1533,7 +1553,7 @@ def start_pagination(last_req):
15331553 "method" : last_req .get ("method" , "GET" ),
15341554 "query_params" : last_req .get ("query_params" ),
15351555 "body" : last_req .get ("body" ),
1536- }
1556+ }, not should_paginate
15371557
15381558
15391559# 11b. PRIMARY writer of page-state — no allow_duplicate, so sync_status_bar
@@ -1680,14 +1700,15 @@ def sync_status_bar(page_state):
16801700@app .callback (
16811701 Output ("response-container" , "children" , allow_duplicate = True ),
16821702 Output ("response-cache" , "data" , allow_duplicate = True ),
1703+ Output ("page-ticker" , "disabled" , allow_duplicate = True ),
16831704 Input ("page-state" , "data" ),
16841705 State ("response-cache" , "data" ),
16851706 prevent_initial_call = True ,
16861707)
16871708def render_when_done (page_state , cache ):
16881709 state = page_state or {}
16891710 if state .get ("running" ) or not state .get ("items" ):
1690- return no_update , no_update
1711+ return no_update , no_update , no_update
16911712 initial_data = state .get ("initial_data" , {})
16921713 list_key = state .get ("list_key" )
16931714 if not list_key :
@@ -1708,7 +1729,7 @@ def render_when_done(page_state, cache):
17081729 new_cache = dict (cache or {})
17091730 if ep_id :
17101731 new_cache [ep_id ] = {"result" : merged_result , "chips" : chips or None }
1711- return build_response_panel (merged_result , chips ), new_cache
1732+ return build_response_panel (merged_result , chips ), new_cache , True
17121733
17131734
17141735# 11g. Abort button — cancel in-flight pagination
@@ -1725,6 +1746,90 @@ def abort_pagination(n_clicks, page_state):
17251746 return {** state , "running" : False , "error" : "Cancelled" }
17261747
17271748
1749+ # 11h. "Load All" — fetch all offset-paginated pages in one synchronous callback
1750+ @app .callback (
1751+ Output ("response-container" , "children" , allow_duplicate = True ),
1752+ Output ("response-cache" , "data" , allow_duplicate = True ),
1753+ Output ("chips-store" , "data" , allow_duplicate = True ),
1754+ Output ("fetch-status-bar" , "children" , allow_duplicate = True ),
1755+ Output ("sp-load-all-btn" , "style" , allow_duplicate = True ),
1756+ Input ("sp-load-all-btn" , "n_clicks" ),
1757+ State ("last-request" , "data" ),
1758+ State ("conn-config" , "data" ),
1759+ State ("response-cache" , "data" ),
1760+ prevent_initial_call = True ,
1761+ )
1762+ def load_all_pages (n_clicks , last_req , conn_config , cache ):
1763+ NO = (no_update , no_update , no_update , no_update , no_update )
1764+ if not n_clicks or not last_req :
1765+ return NO
1766+ initial_data = last_req .get ("initial_data" , {})
1767+ if not isinstance (initial_data , dict ) or not initial_data .get ("has_more" ):
1768+ return NO
1769+ list_key = _find_list_key (initial_data )
1770+ if not list_key :
1771+ return NO
1772+
1773+ host , token = _resolve_conn (conn_config )
1774+ if not token or not host :
1775+ return NO
1776+
1777+ items = list (initial_data .get (list_key , []))
1778+ limit = len (items ) or 25
1779+ offset = len (items )
1780+ next_token = _detect_next_page_token (initial_data )
1781+ use_token = next_token is not None
1782+ total_elapsed = last_req .get ("elapsed_ms" , 0 )
1783+ pages = 1
1784+
1785+ while pages < 200 :
1786+ qp = dict (last_req .get ("query_params" ) or {})
1787+ if use_token and next_token :
1788+ qp ["page_token" ] = next_token
1789+ else :
1790+ qp ["offset" ] = str (offset )
1791+ qp ["limit" ] = str (limit )
1792+
1793+ t0 = time .perf_counter ()
1794+ r = make_api_call (last_req ["method" ], last_req ["path" ], token , host , query_params = qp , body = last_req .get ("body" ))
1795+ total_elapsed += int ((time .perf_counter () - t0 ) * 1000 )
1796+
1797+ if not r ["success" ]:
1798+ break
1799+
1800+ page_data = r ["data" ]
1801+ new_page_items = page_data .get (list_key , [])
1802+ items .extend (new_page_items )
1803+ pages += 1
1804+ offset += limit
1805+ next_token = _detect_next_page_token (page_data )
1806+
1807+ if not page_data .get ("has_more" ):
1808+ break
1809+
1810+ merged_data = {** initial_data , list_key : items }
1811+ merged_data .pop ("has_more" , None )
1812+ merged_result = {
1813+ "status_code" : last_req .get ("status_code" , 200 ),
1814+ "elapsed_ms" : total_elapsed ,
1815+ "data" : merged_data ,
1816+ "success" : True , "error" : None ,
1817+ "url" : last_req .get ("url" , "" ),
1818+ }
1819+ chips = extract_chips (last_req .get ("endpoint_id" , "" ), merged_data )
1820+ ep_id = last_req .get ("endpoint_id" , "" )
1821+ new_cache = dict (cache or {})
1822+ if ep_id :
1823+ new_cache [ep_id ] = {"result" : merged_result , "chips" : chips or None }
1824+
1825+ status = html .Div ([
1826+ html .I (className = "bi bi-check-circle-fill me-2" ),
1827+ f"All pages loaded — { len (items ):,} items · { pages } pages · { total_elapsed } ms" ,
1828+ ], className = "fetch-status-inner done" )
1829+
1830+ return build_response_panel (merged_result , chips ), new_cache , chips or None , status , {"display" : "none" }
1831+
1832+
17281833# 13. Search filter
17291834@app .callback (
17301835 Output ({"type" : "endpoint-btn" , "id" : ALL }, "style" ),
0 commit comments