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

View File

@@ -69,6 +69,7 @@
let sortColumn = "title"; let sortColumn = "title";
let sortDirection = "asc"; let sortDirection = "asc";
let openFilterColumn = null; let openFilterColumn = null;
let filterDropdownPosition = { left: 0, top: 0 };
let columnFilterSearch = { let columnFilterSearch = {
title: "", title: "",
git_status: "", git_status: "",
@@ -482,8 +483,23 @@
* @PRE: column is valid filter key. * @PRE: column is valid filter key.
* @POST: openFilterColumn updated. * @POST: openFilterColumn updated.
*/ */
function toggleFilterDropdown(column) { function toggleFilterDropdown(column, event, panelWidth = 256) {
openFilterColumn = openFilterColumn === column ? null : column; 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] // [/DEF:DashboardHub.toggleFilterDropdown:Function]
@@ -1212,8 +1228,8 @@
{#if isLoading} {#if isLoading}
<div class="bg-white border border-slate-200 rounded-xl shadow-sm overflow-hidden"> <div class="bg-white border border-slate-200 rounded-xl shadow-sm overflow-hidden">
<div <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" 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="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);" 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>
<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> </div>
{#each Array(5) as _} {#each Array(5) as _}
<div <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" class="grid 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);" 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>
<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 --> <!-- Dashboard Grid -->
<div class="bg-white border border-slate-200 rounded-xl shadow-sm"> <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 --> <!-- Grid Header -->
<div <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" 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="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);" 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></div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -1311,13 +1327,13 @@
<div class="relative column-filter"> <div class="relative column-filter">
<button <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'}" 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} title={$t.dashboard?.column_filter}
> >
</button> </button>
{#if openFilterColumn === "title"} {#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 <input
type="text" type="text"
class="w-full rounded border border-gray-300 px-2 py-1 text-xs" class="w-full rounded border border-gray-300 px-2 py-1 text-xs"
@@ -1352,13 +1368,13 @@
<div class="relative column-filter"> <div class="relative column-filter">
<button <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'}" 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} title={$t.dashboard?.column_filter}
> >
</button> </button>
{#if openFilterColumn === "git_status"} {#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 <input
type="text" type="text"
class="w-full rounded border border-gray-300 px-2 py-1 text-xs" class="w-full rounded border border-gray-300 px-2 py-1 text-xs"
@@ -1393,13 +1409,13 @@
<div class="relative column-filter"> <div class="relative column-filter">
<button <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'}" 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} title={$t.dashboard?.column_filter}
> >
</button> </button>
{#if openFilterColumn === "llm_status"} {#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 <input
type="text" type="text"
class="w-full rounded border border-gray-300 px-2 py-1 text-xs" class="w-full rounded border border-gray-300 px-2 py-1 text-xs"
@@ -1427,6 +1443,9 @@
{/if} {/if}
</div> </div>
</div> </div>
<div class="flex items-center gap-2">
{$t.dashboard?.actions}
</div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button class="hover:text-gray-900 transition-colors" on:click={() => handleSort("changed_on")}> <button class="hover:text-gray-900 transition-colors" on:click={() => handleSort("changed_on")}>
{$t.dashboard?.changed_on} {getSortIndicator("changed_on")} {$t.dashboard?.changed_on} {getSortIndicator("changed_on")}
@@ -1434,13 +1453,13 @@
<div class="relative column-filter"> <div class="relative column-filter">
<button <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'}" 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} title={$t.dashboard?.column_filter}
> >
</button> </button>
{#if openFilterColumn === "changed_on"} {#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 <input
type="text" type="text"
class="w-full rounded border border-gray-300 px-2 py-1 text-xs" class="w-full rounded border border-gray-300 px-2 py-1 text-xs"
@@ -1475,13 +1494,13 @@
<div class="relative column-filter"> <div class="relative column-filter">
<button <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'}" 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} title={$t.dashboard?.column_filter}
> >
</button> </button>
{#if openFilterColumn === "actor"} {#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 <input
type="text" type="text"
class="w-full rounded border border-gray-300 px-2 py-1 text-xs" class="w-full rounded border border-gray-300 px-2 py-1 text-xs"
@@ -1509,16 +1528,13 @@
{/if} {/if}
</div> </div>
</div> </div>
<div class="flex items-center">
{$t.dashboard?.actions}
</div>
</div> </div>
<!-- Grid Rows --> <!-- Grid Rows -->
{#each dashboards as dashboard} {#each dashboards as dashboard}
<div <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]" 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="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);" 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 --> <!-- Checkbox -->
<div> <div>
@@ -1586,33 +1602,12 @@
{/if} {/if}
</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>
<!-- Actions --> <!-- Actions -->
<div class="flex items-center"> <div class="flex items-center">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1.5">
{#if !dashboard.git?.hasRepo} {#if !dashboard.git?.hasRepo}
<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-emerald-600 hover:bg-emerald-50 hover:border-emerald-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
on:click={() => handleGitInit(dashboard)} on:click={() => handleGitInit(dashboard)}
disabled={isGitBusy(dashboard.id)} disabled={isGitBusy(dashboard.id)}
title={$t.git?.init_repo || "Init Git repository"} title={$t.git?.init_repo || "Init Git repository"}
@@ -1630,7 +1625,7 @@
</button> </button>
{:else} {:else}
<button <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)} on:click={() => handleGitSync(dashboard)}
disabled={isGitBusy(dashboard.id)} disabled={isGitBusy(dashboard.id)}
title={$t.git?.sync || "Sync from Superset"} title={$t.git?.sync || "Sync from Superset"}
@@ -1648,7 +1643,7 @@
</svg> </svg>
</button> </button>
<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)} on:click={() => handleGitCommit(dashboard)}
disabled={isGitBusy(dashboard.id) || disabled={isGitBusy(dashboard.id) ||
!dashboard.git?.hasChangesForCommit} !dashboard.git?.hasChangesForCommit}
@@ -1668,7 +1663,7 @@
</svg> </svg>
</button> </button>
<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)} on:click={() => handleGitPull(dashboard)}
disabled={isGitBusy(dashboard.id)} disabled={isGitBusy(dashboard.id)}
title={$t.git?.pull || "Pull"} title={$t.git?.pull || "Pull"}
@@ -1687,7 +1682,7 @@
</svg> </svg>
</button> </button>
<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)} on:click={() => handleGitPush(dashboard)}
disabled={isGitBusy(dashboard.id)} disabled={isGitBusy(dashboard.id)}
title={$t.git?.push || "Push"} title={$t.git?.push || "Push"}
@@ -1707,7 +1702,7 @@
</button> </button>
{/if} {/if}
<button <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")} on:click={() => handleAction(dashboard, "migrate")}
title={$t.dashboard?.action_migrate} title={$t.dashboard?.action_migrate}
> >
@@ -1723,7 +1718,7 @@
</svg> </svg>
</button> </button>
<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)} on:click={() => handleValidate(dashboard)}
disabled={validatingIds.has(dashboard.id)} disabled={validatingIds.has(dashboard.id)}
title={$t.dashboard?.action_validate} title={$t.dashboard?.action_validate}
@@ -1761,7 +1756,7 @@
{/if} {/if}
</button> </button>
<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")} on:click={() => handleAction(dashboard, "backup")}
title={$t.dashboard?.action_backup} title={$t.dashboard?.action_backup}
> >
@@ -1780,6 +1775,27 @@
</button> </button>
</div> </div>
</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> </div>
{/each} {/each}
</div> </div>