Skip to content

Commit 8ece01e

Browse files
guidooswaldDBclaude
andcommitted
Add response caching, UC catalog→schema→table drill-down, and side panel actions
- Cache API responses per endpoint and restore them when switching back - Wire up UC navigation chain: List Catalogs → List Schemas → List Tables → Get Table - Support extra params on chips for multi-param endpoints (e.g. catalog_name + schema_name) - Add side panel action buttons with volumes icon on schemas - Extend JS tree viewer idLink to pass extras via postMessage Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ac318ff commit 8ece01e

4 files changed

Lines changed: 194 additions & 50 deletions

File tree

api_catalog.py

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -587,17 +587,23 @@ def _p(name: str, desc: str, required: bool = False, type_: str = STR, default:
587587

588588

589589
# ── List → Get link map ────────────────────────────────────────────────────────
590-
# Format: list_endpoint_id → (get_endpoint_id, list_key, id_field, param_name, label_field)
591-
# list_key — top-level key in the response that holds the array of items
592-
# id_field — field within each item that contains the primary identifier
593-
# param_name — parameter name expected by the get endpoint (None if no get endpoint)
594-
# label_field — optional field for a human-friendly chip label (dotted path supported)
590+
# Format: list_endpoint_id → (get_endpoint_id, list_key, id_field, param_name, label_field[, extra_params[, actions]])
591+
# list_key — top-level key in the response that holds the array of items
592+
# id_field — field within each item that contains the primary identifier
593+
# param_name — parameter name expected by the get endpoint (None if no get endpoint)
594+
# label_field — optional field for a human-friendly chip label (dotted path supported)
595+
# extra_params — optional dict {target_param: source_item_field} for additional params
596+
# actions — optional list of (target_endpoint_id, icon_class, tooltip, {target_param: source_field})
595597
LIST_TO_GET: Dict[str, Any] = {
596598
"clusters-list": ("clusters-get", "clusters", "cluster_id", "cluster_id", "cluster_name"),
597599
"jobs-list": ("jobs-get", "jobs", "job_id", "job_id", "settings.name"),
598600
"jobs-runs-list": ("jobs-runs-get", "runs", "run_id", "run_id", None),
599601
"sql-warehouses-list": ("sql-warehouses-get", "warehouses", "id", "id", "name"),
600-
"uc-tables-list": ("uc-tables-get", "tables", "full_name", "full_name", None),
602+
"uc-catalogs-list": ("uc-schemas-list", "catalogs", "name", "catalog_name", None),
603+
"uc-schemas-list": ("uc-tables-list", "schemas", "name", "schema_name", None, {"catalog_name": "catalog_name"}, [
604+
("uc-volumes-list", "bi-archive", "List Volumes", {"catalog_name": "catalog_name", "schema_name": "name"}),
605+
]),
606+
"uc-tables-list": ("uc-tables-get", "tables", "full_name", "full_name", "name"),
601607
"mlflow-experiments-search": ("mlflow-experiments-get", "experiments", "experiment_id", "experiment_id", "name"),
602608
"serving-endpoints-list": ("serving-endpoints-get", "endpoints", "name", "name", None),
603609
"pipelines-list": ("pipelines-get", "statuses", "pipeline_id", "pipeline_id", "name"),
@@ -620,7 +626,9 @@ def extract_chips(endpoint_id: str, data: Any) -> List[Dict]:
620626
mapping = LIST_TO_GET.get(endpoint_id)
621627
if not mapping:
622628
return []
623-
get_id, list_key, id_field, param_name, label_field = mapping
629+
get_id, list_key, id_field, param_name, label_field = mapping[:5]
630+
extra_params = mapping[5] if len(mapping) > 5 else None
631+
actions_def = mapping[6] if len(mapping) > 6 else None
624632
if not get_id or not param_name:
625633
return []
626634
items = data.get(list_key, []) if isinstance(data, dict) else []
@@ -635,13 +643,30 @@ def extract_chips(endpoint_id: str, data: Any) -> List[Dict]:
635643
label = str(label) if label else str(value)
636644
if label != str(value):
637645
label = f"{label}" # show name only; value accessible via title
646+
extras = {}
647+
if extra_params:
648+
for target_param, source_field in extra_params.items():
649+
v = item.get(source_field)
650+
if v is not None:
651+
extras[target_param] = str(v)
652+
actions = []
653+
if actions_def:
654+
for act_id, act_icon, act_title, act_params in actions_def:
655+
act_p = {}
656+
for tp, sf in act_params.items():
657+
v = item.get(sf)
658+
if v is not None:
659+
act_p[tp] = str(v)
660+
actions.append({"gid": act_id, "icon": act_icon, "title": act_title, "params": act_p})
638661
chips.append({
639662
"get_id": get_id,
640663
"param": param_name,
641664
"id_field": id_field,
642665
"value": str(value),
643666
"label": label[:60],
644667
"title": str(value),
668+
"extras": extras,
669+
"actions": actions,
645670
})
646671
return chips
647672

app.py

Lines changed: 128 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -139,13 +139,13 @@ def _find_list_key(data: dict) -> Optional[str]:
139139
_TREE_JS = r"""
140140
var currentDepth=INITIAL_DEPTH;
141141
function mkEl(tag,cls,txt){var e=document.createElement(tag);if(cls)e.className=cls;if(txt!==undefined)e.textContent=txt;return e;}
142-
function idLink(cls,display,gid,par,val){var e=mkEl('span','id-link '+cls,display);e.dataset.gid=gid;e.dataset.par=par;e.dataset.val=val;e.onclick=function(){window.parent.postMessage({type:'id-link',gid:e.dataset.gid,par:e.dataset.par,val:e.dataset.val},'*');};return e;}
142+
function idLink(cls,display,gid,par,val,ext){var e=mkEl('span','id-link '+cls,display);e.dataset.gid=gid;e.dataset.par=par;e.dataset.val=val;if(ext)e.dataset.ext=JSON.stringify(ext);e.onclick=function(){var msg={type:'id-link',gid:e.dataset.gid,par:e.dataset.par,val:e.dataset.val};if(e.dataset.ext)msg.ext=JSON.parse(e.dataset.ext);window.parent.postMessage(msg,'*');};return e;}
143143
function renderValue(val,pKey,depth){
144144
if(val===null)return mkEl('span','jbn','null');
145145
var t=typeof val;
146146
if(t==='boolean')return mkEl('span','jb',String(val));
147-
if(t==='number'){var c=pKey&&LOOKUP[pKey]&&LOOKUP[pKey][String(val)];return c?idLink('jn',String(val),c.gid,c.par,String(val)):mkEl('span','jn',String(val));}
148-
if(t==='string'){var c2=pKey&&LOOKUP[pKey]&&LOOKUP[pKey][val];return c2?idLink('jv','"'+val+'"',c2.gid,c2.par,val):mkEl('span','jv','"'+val+'"');}
147+
if(t==='number'){var c=pKey&&LOOKUP[pKey]&&LOOKUP[pKey][String(val)];return c?idLink('jn',String(val),c.gid,c.par,String(val),c.ext):mkEl('span','jn',String(val));}
148+
if(t==='string'){var c2=pKey&&LOOKUP[pKey]&&LOOKUP[pKey][val];return c2?idLink('jv','"'+val+'"',c2.gid,c2.par,val,c2.ext):mkEl('span','jv','"'+val+'"');}
149149
if(Array.isArray(val))return renderArray(val,depth);
150150
if(t==='object')return renderObject(val,depth);
151151
return mkEl('span','jbn',String(val));
@@ -256,10 +256,13 @@ def _build_json_tree_html(data: Any, chips: Optional[List[Dict]] = None) -> str:
256256
field = chip["id_field"]
257257
if field not in link_lookup:
258258
link_lookup[field] = {}
259-
link_lookup[field][str(chip["value"])] = {
259+
entry: Dict[str, Any] = {
260260
"gid": chip["get_id"],
261261
"par": chip["param"],
262262
}
263+
if chip.get("extras"):
264+
entry["ext"] = chip["extras"]
265+
link_lookup[field][str(chip["value"])] = entry
263266
data_js = json.dumps(data, ensure_ascii=False, separators=(",", ":")).replace("</script>", r"<\/script>")
264267
lookup_js = json.dumps(link_lookup, ensure_ascii=False).replace("</script>", r"<\/script>")
265268
return "".join([
@@ -479,16 +482,27 @@ def build_response_panel(result: Dict[str, Any], chips: Optional[List] = None) -
479482
has_label = chip["label"] != chip["title"]
480483
name_text = chip["label"]
481484
id_text = chip["title"] if has_label else ""
482-
item_children = [html.Span(name_text, className="sp-name")]
485+
text_children = [html.Span(name_text, className="sp-name")]
483486
if id_text:
484-
item_children.append(html.Span(id_text, className="sp-id"))
485-
sp_items.append(html.Button(
486-
item_children,
487-
id={"type": "sp-item", "index": i},
488-
n_clicks=0,
489-
className="sp-item",
490-
title=f"{chip['label']} · {chip['title']}",
491-
))
487+
text_children.append(html.Span(id_text, className="sp-id"))
488+
item_children = [
489+
html.Button(
490+
text_children,
491+
id={"type": "sp-item", "index": i},
492+
n_clicks=0,
493+
className="sp-item-main",
494+
title=f"{chip['label']} · {chip['title']}",
495+
),
496+
]
497+
for j, action in enumerate(chip.get("actions") or []):
498+
item_children.append(html.Button(
499+
html.I(className=f"bi {action['icon']}"),
500+
id={"type": "sp-action", "index": i, "action": j},
501+
n_clicks=0,
502+
className="sp-action-btn",
503+
title=action["title"],
504+
))
505+
sp_items.append(html.Div(item_children, className="sp-item"))
492506
side_panel = html.Div([
493507
html.Div(className="sp-resize-handle"),
494508
html.Div([
@@ -909,6 +923,7 @@ def _custom_section():
909923
dcc.Interval(id="page-ticker", interval=500, disabled=False, n_intervals=0),
910924

911925
dcc.Store(id="dropdown-open", data=False), # tracks dropdown visibility
926+
dcc.Store(id="response-cache", data={}), # {endpoint_id: {result, chips}} — cached API responses
912927

913928
TOPBAR,
914929
_DROPDOWN_OVERLAY,
@@ -1376,23 +1391,45 @@ def render_endpoint_detail(endpoint: Optional[Dict]):
13761391
return content, "form-panel"
13771392

13781393

1394+
# 9b. Restore cached response when switching endpoints via sidebar
1395+
@app.callback(
1396+
Output("response-container", "children", allow_duplicate=True),
1397+
Output("chips-store", "data", allow_duplicate=True),
1398+
Input("selected-endpoint", "data"),
1399+
State("response-cache", "data"),
1400+
prevent_initial_call=True,
1401+
)
1402+
def restore_cached_response(endpoint, cache):
1403+
if not endpoint:
1404+
return _RESPONSE_EMPTY, None
1405+
ep_id = endpoint.get("id", "")
1406+
cached = (cache or {}).get(ep_id)
1407+
if not cached:
1408+
return _RESPONSE_EMPTY, None
1409+
result = cached["result"]
1410+
chips = cached.get("chips")
1411+
return build_response_panel(result, chips), chips
1412+
1413+
13791414
# 10. Execute API call
13801415
@app.callback(
13811416
Output("response-container", "children"),
13821417
Output("last-request", "data"),
13831418
Output("spinner-off", "data"),
13841419
Output("chips-store", "data"),
1420+
Output("response-cache", "data", allow_duplicate=True),
13851421
Input("execute-btn", "n_clicks"),
13861422
State("selected-endpoint", "data"),
13871423
State({"type": "param-input", "name": ALL}, "value"),
13881424
State({"type": "param-input", "name": ALL}, "id"),
13891425
State("body-textarea", "value"),
13901426
State("conn-config", "data"),
1427+
State("response-cache", "data"),
13911428
prevent_initial_call=True,
13921429
)
1393-
def execute_api_call(n_clicks, endpoint, param_values, param_ids, body_text, conn_config):
1430+
def execute_api_call(n_clicks, endpoint, param_values, param_ids, body_text, conn_config, cache):
13941431
if not n_clicks or not endpoint:
1395-
return no_update, no_update, no_update, no_update
1432+
return no_update, no_update, no_update, no_update, no_update
13961433

13971434
params: Dict[str, Any] = {}
13981435
ep_param_map = {p["name"]: p for p in endpoint.get("params", [])}
@@ -1412,7 +1449,7 @@ def execute_api_call(n_clicks, endpoint, param_values, param_ids, body_text, con
14121449
for pp in endpoint.get("path_params", []):
14131450
val = params.pop(pp, "")
14141451
if not val:
1415-
return build_error_panel(f"Path parameter '{pp}' is required."), no_update, time.time(), None
1452+
return build_error_panel(f"Path parameter '{pp}' is required."), no_update, time.time(), None, no_update
14161453
path = path.replace(f"{{{pp}}}", str(val))
14171454

14181455
method = endpoint.get("method", "GET")
@@ -1421,13 +1458,13 @@ def execute_api_call(n_clicks, endpoint, param_values, param_ids, body_text, con
14211458
try:
14221459
body = json.loads(body_text)
14231460
except json.JSONDecodeError as e:
1424-
return build_error_panel(f"Invalid JSON body: {e}"), no_update, time.time(), None
1461+
return build_error_panel(f"Invalid JSON body: {e}"), no_update, time.time(), None, no_update
14251462

14261463
host, token = _resolve_conn(conn_config)
14271464
if not token:
1428-
return build_error_panel("No auth token. Configure a connection in the user menu."), no_update, time.time(), None
1465+
return build_error_panel("No auth token. Configure a connection in the user menu."), no_update, time.time(), None, no_update
14291466
if not host:
1430-
return build_error_panel("No workspace host. Configure a connection in the user menu."), no_update, time.time(), None
1467+
return build_error_panel("No workspace host. Configure a connection in the user menu."), no_update, time.time(), None, no_update
14311468

14321469
result = make_api_call(
14331470
method=method, path=path, token=token, host=host,
@@ -1445,7 +1482,10 @@ def execute_api_call(n_clicks, endpoint, param_values, param_ids, body_text, con
14451482
"status_code": result["status_code"],
14461483
"url": result.get("url", ""),
14471484
}
1448-
return build_response_panel(result, chips), last_req, time.time(), chips or None
1485+
ep_id = endpoint.get("id", "")
1486+
new_cache = dict(cache or {})
1487+
new_cache[ep_id] = {"result": result, "chips": chips or None}
1488+
return build_response_panel(result, chips), last_req, time.time(), chips or None, new_cache
14491489

14501490

14511491
# 11. Signal tick_fetch to start a new pagination run whenever execute fires.
@@ -1621,17 +1661,19 @@ def sync_status_bar(page_state):
16211661
# 11c. Render final merged result when pagination completes
16221662
@app.callback(
16231663
Output("response-container", "children", allow_duplicate=True),
1664+
Output("response-cache", "data", allow_duplicate=True),
16241665
Input("page-state", "data"),
1666+
State("response-cache", "data"),
16251667
prevent_initial_call=True,
16261668
)
1627-
def render_when_done(page_state):
1669+
def render_when_done(page_state, cache):
16281670
state = page_state or {}
16291671
if state.get("running") or not state.get("items"):
1630-
return no_update
1672+
return no_update, no_update
16311673
initial_data = state.get("initial_data", {})
16321674
list_key = state.get("list_key")
16331675
if not list_key:
1634-
return no_update
1676+
return no_update, no_update
16351677
merged_data = {**initial_data, list_key: state["items"]}
16361678
merged_data.pop("next_page_token", None)
16371679
merged_data.pop("has_more", None)
@@ -1644,7 +1686,11 @@ def render_when_done(page_state):
16441686
"url": state.get("url", ""),
16451687
}
16461688
chips = extract_chips(state.get("endpoint_id", ""), merged_data)
1647-
return build_response_panel(merged_result, chips)
1689+
ep_id = state.get("endpoint_id", "")
1690+
new_cache = dict(cache or {})
1691+
if ep_id:
1692+
new_cache[ep_id] = {"result": merged_result, "chips": chips or None}
1693+
return build_response_panel(merged_result, chips), new_cache
16481694

16491695

16501696
# 11g. Abort button — cancel in-flight pagination
@@ -1734,43 +1780,51 @@ def filter_endpoints(query, btn_ids):
17341780
@app.callback(
17351781
Output("selected-endpoint", "data", allow_duplicate=True),
17361782
Output("response-container", "children", allow_duplicate=True),
1783+
Output("response-cache", "data", allow_duplicate=True),
17371784
Input("iframe-link-click", "data"),
17381785
State("conn-config", "data"),
1786+
State("response-cache", "data"),
17391787
prevent_initial_call=True,
17401788
)
1741-
def handle_iframe_link_click(link_data, conn_config):
1789+
def handle_iframe_link_click(link_data, conn_config, cache):
17421790
if not link_data:
1743-
return no_update, no_update
1791+
return no_update, no_update, no_update
17441792
get_id = link_data.get("gid", "")
17451793
param_name = link_data.get("par", "")
17461794
value = link_data.get("val", "")
17471795
if not get_id or not param_name or value == "":
1748-
return no_update, no_update
1796+
return no_update, no_update, no_update
17491797

17501798
endpoint = get_endpoint_by_id(get_id)
17511799
if not endpoint:
1752-
return no_update, no_update
1800+
return no_update, no_update, no_update
17531801

1802+
extras = link_data.get("ext") or {}
17541803
path = endpoint["path"]
17551804
query_params: Dict[str, Any] = {}
1756-
if param_name in endpoint.get("path_params", []):
1757-
path = path.replace(f"{{{param_name}}}", value)
1758-
else:
1759-
query_params[param_name] = value
1805+
prefill = {param_name: value, **extras}
1806+
for k, v in {param_name: value, **extras}.items():
1807+
if k in endpoint.get("path_params", []):
1808+
path = path.replace(f"{{{k}}}", v)
1809+
else:
1810+
query_params[k] = v
17601811

17611812
host, token = _resolve_conn(conn_config)
17621813
if not token:
1763-
return no_update, build_error_panel("No auth token.")
1814+
return no_update, build_error_panel("No auth token."), no_update
17641815
if not host:
1765-
return no_update, build_error_panel("No workspace host.")
1816+
return no_update, build_error_panel("No workspace host."), no_update
17661817

17671818
result = make_api_call(
17681819
method=endpoint.get("method", "GET"),
17691820
path=path, token=token, host=host,
17701821
query_params=query_params or None,
17711822
)
1772-
endpoint_with_prefill = {**endpoint, "_prefill": {param_name: value}}
1773-
return endpoint_with_prefill, build_response_panel(result)
1823+
chips = extract_chips(get_id, result["data"]) if result["success"] else []
1824+
endpoint_with_prefill = {**endpoint, "_prefill": prefill}
1825+
new_cache = dict(cache or {})
1826+
new_cache[get_id] = {"result": result, "chips": chips or None}
1827+
return endpoint_with_prefill, build_response_panel(result, chips), new_cache
17741828

17751829

17761830
# 17. Side-panel toggle — pure JS, no server round-trip
@@ -1808,7 +1862,44 @@ def handle_sp_item_click(n_clicks_list, chips_data):
18081862
if idx >= len(chips_data):
18091863
return no_update
18101864
chip = chips_data[idx]
1811-
return {"type": "id-link", "gid": chip["get_id"], "par": chip["param"], "val": chip["value"]}
1865+
msg = {"type": "id-link", "gid": chip["get_id"], "par": chip["param"], "val": chip["value"]}
1866+
if chip.get("extras"):
1867+
msg["ext"] = chip["extras"]
1868+
return msg
1869+
1870+
1871+
# 18b. Side-panel action button click → navigate to action endpoint
1872+
@app.callback(
1873+
Output("iframe-link-click", "data", allow_duplicate=True),
1874+
Input({"type": "sp-action", "index": ALL, "action": ALL}, "n_clicks"),
1875+
State("chips-store", "data"),
1876+
prevent_initial_call=True,
1877+
)
1878+
def handle_sp_action_click(n_clicks_list, chips_data):
1879+
from dash import callback_context
1880+
if not callback_context.triggered or not chips_data:
1881+
return no_update
1882+
triggered = callback_context.triggered[0]
1883+
if not triggered["value"]:
1884+
return no_update
1885+
tid = json.loads(triggered["prop_id"].split(".")[0])
1886+
idx, action_idx = tid["index"], tid["action"]
1887+
if idx >= len(chips_data):
1888+
return no_update
1889+
chip = chips_data[idx]
1890+
actions = chip.get("actions") or []
1891+
if action_idx >= len(actions):
1892+
return no_update
1893+
action = actions[action_idx]
1894+
# The action's params dict has all params needed; pick the first as the primary par/val
1895+
params = action["params"]
1896+
first_key = next(iter(params), "")
1897+
first_val = params.get(first_key, "")
1898+
ext = {k: v for k, v in params.items() if k != first_key}
1899+
msg = {"type": "id-link", "gid": action["gid"], "par": first_key, "val": first_val}
1900+
if ext:
1901+
msg["ext"] = ext
1902+
return msg
18121903

18131904

18141905
# 19. Build curl command below Execute button whenever a request completes

0 commit comments

Comments
 (0)