fix(dashboards): stabilize grid layout and remove owners N+1 fallback

This commit is contained in:
2026-02-28 10:46:47 +03:00
parent 066747de59
commit 7e43830144
2 changed files with 104 additions and 75 deletions

View File

@@ -14,7 +14,7 @@ import json
import re
import zipfile
from pathlib import Path
from typing import Dict, List, Optional, Tuple, Union, cast
from typing import Any, Dict, List, Optional, Tuple, Union, cast
from requests import Response
from datetime import datetime
from .logger import logger as app_logger, belief_scope
@@ -87,7 +87,17 @@ class SupersetClient:
app_logger.info("[get_dashboards][Enter] Fetching dashboards.")
validated_query = self._validate_query_params(query or {})
if 'columns' not in validated_query:
validated_query['columns'] = ["slug", "id", "changed_on_utc", "dashboard_title", "published"]
validated_query['columns'] = [
"slug",
"id",
"changed_on_utc",
"dashboard_title",
"published",
"created_by",
"changed_by",
"changed_by_name",
"owners",
]
paginated_data = self._fetch_all_pages(
endpoint="/dashboard/",
@@ -105,53 +115,56 @@ class SupersetClient:
# @RETURN: List[Dict]
def get_dashboards_summary(self) -> List[Dict]:
with belief_scope("SupersetClient.get_dashboards_summary"):
query = {
"columns": [
"id",
"dashboard_title",
"changed_on_utc",
"published",
"created_by",
"created_by_name",
"changed_by",
"changed_by_name",
"owners",
]
}
# Rely on list endpoint default projection to stay compatible
# across Superset versions and preserve owners in one request.
query: Dict[str, Any] = {}
_, dashboards = self.get_dashboards(query=query)
# Map fields to DashboardMetadata schema
result = []
for dash in dashboards:
owners = self._extract_owner_labels(dash.get("owners"))
# No per-dashboard detail requests here: keep list endpoint O(1).
if not owners:
owners = self._extract_owner_labels(
[dash.get("created_by"), dash.get("changed_by")],
)
result.append({
"id": dash.get("id"),
"title": dash.get("dashboard_title"),
"last_modified": dash.get("changed_on_utc"),
"status": "published" if dash.get("published") else "draft",
"created_by": self._extract_user_display(
dash.get("created_by_name"),
None,
dash.get("created_by"),
),
"modified_by": self._extract_user_display(
dash.get("changed_by_name"),
dash.get("changed_by"),
),
"owners": self._extract_owner_labels(dash.get("owners")),
"owners": owners,
})
return result
# [/DEF:get_dashboards_summary:Function]
# [DEF:_extract_owner_labels:Function]
# @PURPOSE: Normalize dashboard owners payload to stable display labels.
# @PRE: owners payload can be None, list of dicts or list of strings.
# @PRE: owners payload can be scalar, object or list.
# @POST: Returns deduplicated non-empty owner labels preserving order.
# @RETURN: List[str]
def _extract_owner_labels(self, owners_payload: Optional[List[Union[Dict, str]]]) -> List[str]:
if not isinstance(owners_payload, list):
def _extract_owner_labels(self, owners_payload: Any) -> List[str]:
if owners_payload is None:
return []
owners_list: List[Any]
if isinstance(owners_payload, list):
owners_list = owners_payload
else:
owners_list = [owners_payload]
normalized: List[str] = []
for owner in owners_payload:
for owner in owners_list:
label: Optional[str] = None
if isinstance(owner, dict):
label = self._extract_user_display(None, owner)

View File

@@ -69,6 +69,7 @@
let sortColumn = "title";
let sortDirection = "asc";
let openFilterColumn = null;
let filterDropdownPosition = { left: 0, top: 0 };
let columnFilterSearch = {
title: "",
git_status: "",
@@ -482,8 +483,23 @@
* @PRE: column is valid filter key.
* @POST: openFilterColumn updated.
*/
function toggleFilterDropdown(column) {
openFilterColumn = openFilterColumn === column ? null : column;
function toggleFilterDropdown(column, event, panelWidth = 256) {
event?.stopPropagation();
if (openFilterColumn === column) {
openFilterColumn = null;
return;
}
const trigger = event?.currentTarget;
if (trigger?.getBoundingClientRect) {
const rect = trigger.getBoundingClientRect();
const viewportWidth = typeof window !== "undefined" ? window.innerWidth : 1920;
const safeLeft = Math.max(8, Math.min(rect.left, viewportWidth - panelWidth - 8));
filterDropdownPosition = {
left: safeLeft,
top: rect.bottom + 8,
};
}
openFilterColumn = column;
}
// [/DEF:DashboardHub.toggleFilterDropdown:Function]
@@ -1212,8 +1228,8 @@
{#if isLoading}
<div class="bg-white border border-slate-200 rounded-xl shadow-sm overflow-hidden">
<div
class="grid min-w-[1520px] gap-4 px-6 py-3 bg-slate-50 border-b border-slate-200 font-semibold text-[11px] uppercase tracking-wide text-slate-600"
style="grid-template-columns: 40px minmax(250px,2.3fr) minmax(118px,0.9fr) minmax(150px,1fr) minmax(150px,1fr) minmax(300px,2.1fr) minmax(280px,2fr);"
class="grid gap-4 px-6 py-3 bg-slate-50 border-b border-slate-200 font-semibold text-[11px] uppercase tracking-wide text-slate-600"
style="width: max(100%, 1520px); grid-template-columns: 40px minmax(250px,2.3fr) minmax(118px,0.9fr) minmax(150px,1fr) minmax(280px,2fr) minmax(150px,1fr) minmax(300px,2.1fr);"
>
<div class="h-4 rounded bg-gray-200 animate-pulse"></div>
<div class="h-4 rounded bg-gray-200 animate-pulse"></div>
@@ -1225,8 +1241,8 @@
</div>
{#each Array(5) as _}
<div
class="grid min-w-[1520px] gap-4 px-6 py-4 border-b border-slate-200 last:border-b-0 hover:bg-slate-50 transition-colors"
style="grid-template-columns: 40px minmax(250px,2.3fr) minmax(118px,0.9fr) minmax(150px,1fr) minmax(150px,1fr) minmax(300px,2.1fr) minmax(280px,2fr);"
class="grid gap-4 px-6 py-4 border-b border-slate-200 last:border-b-0 hover:bg-slate-50 transition-colors"
style="width: max(100%, 1520px); grid-template-columns: 40px minmax(250px,2.3fr) minmax(118px,0.9fr) minmax(150px,1fr) minmax(280px,2fr) minmax(150px,1fr) minmax(300px,2.1fr);"
>
<div class="h-4 rounded bg-gray-200 animate-pulse"></div>
<div class="h-4 rounded bg-gray-200 animate-pulse"></div>
@@ -1297,11 +1313,11 @@
<!-- Dashboard Grid -->
<div class="bg-white border border-slate-200 rounded-xl shadow-sm">
<div class="overflow-x-auto">
<div class="overflow-x-auto overflow-y-visible">
<!-- Grid Header -->
<div
class="grid min-w-[1520px] gap-4 px-6 py-3 bg-slate-50 border-b border-slate-200 font-semibold text-[11px] uppercase tracking-wide text-slate-600"
style="grid-template-columns: 40px minmax(250px,2.3fr) minmax(118px,0.9fr) minmax(150px,1fr) minmax(150px,1fr) minmax(300px,2.1fr) minmax(280px,2fr);"
class="grid gap-4 px-6 py-3 bg-slate-50 border-b border-slate-200 font-semibold text-[11px] uppercase tracking-wide text-slate-600"
style="width: max(100%, 1520px); grid-template-columns: 40px minmax(250px,2.3fr) minmax(118px,0.9fr) minmax(150px,1fr) minmax(280px,2fr) minmax(150px,1fr) minmax(300px,2.1fr);"
>
<div></div>
<div class="flex items-center gap-2">
@@ -1311,13 +1327,13 @@
<div class="relative column-filter">
<button
class="rounded border px-1.5 py-0.5 text-xs transition-colors {hasColumnFilter('title') ? 'border-blue-400 text-blue-700 bg-blue-50' : 'border-gray-300 text-gray-500 hover:text-gray-700'}"
on:click={() => toggleFilterDropdown("title")}
on:click={(event) => toggleFilterDropdown("title", event, 256)}
title={$t.dashboard?.column_filter}
>
</button>
{#if openFilterColumn === "title"}
<div class="absolute z-30 mt-2 w-64 rounded-lg border border-gray-200 bg-white shadow-lg p-3">
<div class="fixed z-50 w-64 rounded-lg border border-gray-200 bg-white shadow-lg p-3" style="left: {filterDropdownPosition.left}px; top: {filterDropdownPosition.top}px;">
<input
type="text"
class="w-full rounded border border-gray-300 px-2 py-1 text-xs"
@@ -1352,13 +1368,13 @@
<div class="relative column-filter">
<button
class="rounded border px-1.5 py-0.5 text-xs transition-colors {hasColumnFilter('git_status') ? 'border-blue-400 text-blue-700 bg-blue-50' : 'border-gray-300 text-gray-500 hover:text-gray-700'}"
on:click={() => toggleFilterDropdown("git_status")}
on:click={(event) => toggleFilterDropdown("git_status", event, 256)}
title={$t.dashboard?.column_filter}
>
</button>
{#if openFilterColumn === "git_status"}
<div class="absolute z-30 mt-2 w-64 rounded-lg border border-gray-200 bg-white shadow-lg p-3">
<div class="fixed z-50 w-64 rounded-lg border border-gray-200 bg-white shadow-lg p-3" style="left: {filterDropdownPosition.left}px; top: {filterDropdownPosition.top}px;">
<input
type="text"
class="w-full rounded border border-gray-300 px-2 py-1 text-xs"
@@ -1393,13 +1409,13 @@
<div class="relative column-filter">
<button
class="rounded border px-1.5 py-0.5 text-xs transition-colors {hasColumnFilter('llm_status') ? 'border-blue-400 text-blue-700 bg-blue-50' : 'border-gray-300 text-gray-500 hover:text-gray-700'}"
on:click={() => toggleFilterDropdown("llm_status")}
on:click={(event) => toggleFilterDropdown("llm_status", event, 256)}
title={$t.dashboard?.column_filter}
>
</button>
{#if openFilterColumn === "llm_status"}
<div class="absolute z-30 mt-2 w-64 rounded-lg border border-gray-200 bg-white shadow-lg p-3">
<div class="fixed z-50 w-64 rounded-lg border border-gray-200 bg-white shadow-lg p-3" style="left: {filterDropdownPosition.left}px; top: {filterDropdownPosition.top}px;">
<input
type="text"
class="w-full rounded border border-gray-300 px-2 py-1 text-xs"
@@ -1427,6 +1443,9 @@
{/if}
</div>
</div>
<div class="flex items-center gap-2">
{$t.dashboard?.actions}
</div>
<div class="flex items-center gap-2">
<button class="hover:text-gray-900 transition-colors" on:click={() => handleSort("changed_on")}>
{$t.dashboard?.changed_on} {getSortIndicator("changed_on")}
@@ -1434,13 +1453,13 @@
<div class="relative column-filter">
<button
class="rounded border px-1.5 py-0.5 text-xs transition-colors {hasColumnFilter('changed_on') ? 'border-blue-400 text-blue-700 bg-blue-50' : 'border-gray-300 text-gray-500 hover:text-gray-700'}"
on:click={() => toggleFilterDropdown("changed_on")}
on:click={(event) => toggleFilterDropdown("changed_on", event, 256)}
title={$t.dashboard?.column_filter}
>
</button>
{#if openFilterColumn === "changed_on"}
<div class="absolute z-30 mt-2 w-64 rounded-lg border border-gray-200 bg-white shadow-lg p-3">
<div class="fixed z-50 w-64 rounded-lg border border-gray-200 bg-white shadow-lg p-3" style="left: {filterDropdownPosition.left}px; top: {filterDropdownPosition.top}px;">
<input
type="text"
class="w-full rounded border border-gray-300 px-2 py-1 text-xs"
@@ -1475,13 +1494,13 @@
<div class="relative column-filter">
<button
class="rounded border px-1.5 py-0.5 text-xs transition-colors {hasColumnFilter('actor') ? 'border-blue-400 text-blue-700 bg-blue-50' : 'border-gray-300 text-gray-500 hover:text-gray-700'}"
on:click={() => toggleFilterDropdown("actor")}
on:click={(event) => toggleFilterDropdown("actor", event, 288)}
title={$t.dashboard?.column_filter}
>
</button>
{#if openFilterColumn === "actor"}
<div class="absolute z-30 mt-2 w-72 rounded-lg border border-gray-200 bg-white shadow-lg p-3">
<div class="fixed z-50 w-72 rounded-lg border border-gray-200 bg-white shadow-lg p-3" style="left: {filterDropdownPosition.left}px; top: {filterDropdownPosition.top}px;">
<input
type="text"
class="w-full rounded border border-gray-300 px-2 py-1 text-xs"
@@ -1509,16 +1528,13 @@
{/if}
</div>
</div>
<div class="flex items-center">
{$t.dashboard?.actions}
</div>
</div>
<!-- Grid Rows -->
{#each dashboards as dashboard}
<div
class="grid min-w-[1520px] gap-4 px-6 py-4 border-b border-slate-200 last:border-b-0 hover:bg-slate-50 transition-colors text-[13px]"
style="grid-template-columns: 40px minmax(250px,2.3fr) minmax(118px,0.9fr) minmax(150px,1fr) minmax(150px,1fr) minmax(300px,2.1fr) minmax(280px,2fr);"
class="grid gap-4 px-6 py-4 border-b border-slate-200 last:border-b-0 hover:bg-slate-50 transition-colors text-[13px]"
style="width: max(100%, 1520px); grid-template-columns: 40px minmax(250px,2.3fr) minmax(118px,0.9fr) minmax(150px,1fr) minmax(280px,2fr) minmax(150px,1fr) minmax(300px,2.1fr);"
>
<!-- Checkbox -->
<div>
@@ -1586,33 +1602,12 @@
{/if}
</div>
<!-- Changed On -->
<div class="text-xs text-slate-600">
{dashboard.changedOnLabel}
</div>
<!-- Owners -->
<div class="text-xs">
<div class="text-slate-500 text-[10px] uppercase tracking-wide">{$t.dashboard?.owners || "Owners"}</div>
{#if dashboard.owners?.length > 0}
<div class="mt-0.5 flex flex-wrap items-center gap-1">
{#each dashboard.owners as owner (owner)}
<span class="rounded bg-slate-100 px-2 py-0.5 text-xs text-slate-700 truncate max-w-[180px]" title={owner}>
{owner}
</span>
{/each}
</div>
{:else}
<div class="mt-0.5 text-gray-400">-</div>
{/if}
</div>
<!-- Actions -->
<div class="flex items-center">
<div class="flex items-center gap-1">
<div class="flex items-center gap-1.5">
{#if !dashboard.git?.hasRepo}
<button
class="p-2 rounded border border-emerald-200 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 hover:border-emerald-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
class="inline-flex h-8 w-8 items-center justify-center rounded-md border border-slate-200 bg-white text-emerald-600 hover:bg-emerald-50 hover:border-emerald-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
on:click={() => handleGitInit(dashboard)}
disabled={isGitBusy(dashboard.id)}
title={$t.git?.init_repo || "Init Git repository"}
@@ -1630,7 +1625,7 @@
</button>
{:else}
<button
class="p-2 rounded border border-sky-200 bg-sky-50 text-sky-700 hover:bg-sky-100 hover:border-sky-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
class="inline-flex h-8 w-8 items-center justify-center rounded-md border border-slate-200 bg-white text-sky-600 hover:bg-sky-50 hover:border-sky-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
on:click={() => handleGitSync(dashboard)}
disabled={isGitBusy(dashboard.id)}
title={$t.git?.sync || "Sync from Superset"}
@@ -1648,7 +1643,7 @@
</svg>
</button>
<button
class="p-2 rounded border border-orange-200 bg-orange-50 text-orange-700 hover:bg-orange-100 hover:border-orange-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
class="inline-flex h-8 w-8 items-center justify-center rounded-md border border-slate-200 bg-white text-orange-600 hover:bg-orange-50 hover:border-orange-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
on:click={() => handleGitCommit(dashboard)}
disabled={isGitBusy(dashboard.id) ||
!dashboard.git?.hasChangesForCommit}
@@ -1668,7 +1663,7 @@
</svg>
</button>
<button
class="p-2 rounded border border-cyan-200 bg-cyan-50 text-cyan-700 hover:bg-cyan-100 hover:border-cyan-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
class="inline-flex h-8 w-8 items-center justify-center rounded-md border border-slate-200 bg-white text-cyan-600 hover:bg-cyan-50 hover:border-cyan-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
on:click={() => handleGitPull(dashboard)}
disabled={isGitBusy(dashboard.id)}
title={$t.git?.pull || "Pull"}
@@ -1687,7 +1682,7 @@
</svg>
</button>
<button
class="p-2 rounded border border-indigo-200 bg-indigo-50 text-indigo-700 hover:bg-indigo-100 hover:border-indigo-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
class="inline-flex h-8 w-8 items-center justify-center rounded-md border border-slate-200 bg-white text-indigo-600 hover:bg-indigo-50 hover:border-indigo-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
on:click={() => handleGitPush(dashboard)}
disabled={isGitBusy(dashboard.id)}
title={$t.git?.push || "Push"}
@@ -1707,7 +1702,7 @@
</button>
{/if}
<button
class="p-2 rounded border border-blue-200 bg-blue-50 text-blue-700 hover:bg-blue-100 hover:border-blue-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
class="inline-flex h-8 w-8 items-center justify-center rounded-md border border-slate-200 bg-white text-blue-600 hover:bg-blue-50 hover:border-blue-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
on:click={() => handleAction(dashboard, "migrate")}
title={$t.dashboard?.action_migrate}
>
@@ -1723,7 +1718,7 @@
</svg>
</button>
<button
class="p-2 rounded border border-emerald-200 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 hover:border-emerald-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
class="inline-flex h-8 w-8 items-center justify-center rounded-md border border-slate-200 bg-white text-teal-600 hover:bg-teal-50 hover:border-teal-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
on:click={() => handleValidate(dashboard)}
disabled={validatingIds.has(dashboard.id)}
title={$t.dashboard?.action_validate}
@@ -1761,7 +1756,7 @@
{/if}
</button>
<button
class="p-2 rounded border border-amber-200 bg-amber-50 text-amber-700 hover:bg-amber-100 hover:border-amber-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
class="inline-flex h-8 w-8 items-center justify-center rounded-md border border-slate-200 bg-white text-amber-600 hover:bg-amber-50 hover:border-amber-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
on:click={() => handleAction(dashboard, "backup")}
title={$t.dashboard?.action_backup}
>
@@ -1780,6 +1775,27 @@
</button>
</div>
</div>
<!-- Changed On -->
<div class="text-xs text-slate-600">
{dashboard.changedOnLabel}
</div>
<!-- Owners -->
<div class="text-xs">
<div class="text-slate-500 text-[10px] uppercase tracking-wide">{$t.dashboard?.owners || "Owners"}</div>
{#if dashboard.owners?.length > 0}
<div class="mt-0.5 flex flex-wrap items-center gap-1">
{#each dashboard.owners as owner (owner)}
<span class="rounded bg-slate-100 px-2 py-0.5 text-xs text-slate-700 truncate max-w-[180px]" title={owner}>
{owner}
</span>
{/each}
</div>
{:else}
<div class="mt-0.5 text-gray-400">-</div>
{/if}
</div>
</div>
{/each}
</div>