diff --git a/backend/src/api/routes/dashboards.py b/backend/src/api/routes/dashboards.py index efa8255..f02ecc7 100644 --- a/backend/src/api/routes/dashboards.py +++ b/backend/src/api/routes/dashboards.py @@ -61,6 +61,7 @@ class LastTask(BaseModel): None, pattern="^PENDING|RUNNING|SUCCESS|FAILED|ERROR|AWAITING_INPUT|WAITING_INPUT|AWAITING_MAPPING$", ) + validation_status: Optional[str] = Field(None, pattern="^PASS|FAIL|WARN|UNKNOWN$") # [/DEF:LastTask:DataClass] # [DEF:DashboardItem:DataClass] @@ -70,6 +71,9 @@ class DashboardItem(BaseModel): slug: Optional[str] = None url: Optional[str] = None last_modified: Optional[str] = None + created_by: Optional[str] = None + modified_by: Optional[str] = None + owners: Optional[List[str]] = None git_status: Optional[GitStatus] = None last_task: Optional[LastTask] = None # [/DEF:DashboardItem:DataClass] diff --git a/backend/src/core/superset_client.py b/backend/src/core/superset_client.py index 37b97e7..e13e9d6 100644 --- a/backend/src/core/superset_client.py +++ b/backend/src/core/superset_client.py @@ -106,7 +106,17 @@ class SupersetClient: def get_dashboards_summary(self) -> List[Dict]: with belief_scope("SupersetClient.get_dashboards_summary"): query = { - "columns": ["id", "dashboard_title", "changed_on_utc", "published"] + "columns": [ + "id", + "dashboard_title", + "changed_on_utc", + "published", + "created_by", + "created_by_name", + "changed_by", + "changed_by_name", + "owners", + ] } _, dashboards = self.get_dashboards(query=query) @@ -117,11 +127,83 @@ class SupersetClient: "id": dash.get("id"), "title": dash.get("dashboard_title"), "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( + dash.get("created_by_name"), + 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")), }) 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. + # @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): + return [] + + normalized: List[str] = [] + for owner in owners_payload: + label: Optional[str] = None + if isinstance(owner, dict): + label = self._extract_user_display(None, owner) + else: + label = self._sanitize_user_text(owner) + if label and label not in normalized: + normalized.append(label) + return normalized + # [/DEF:_extract_owner_labels:Function] + + # [DEF:_extract_user_display:Function] + # @PURPOSE: Normalize user payload to a stable display name. + # @PRE: user payload can be string, dict or None. + # @POST: Returns compact non-empty display value or None. + # @RETURN: Optional[str] + def _extract_user_display(self, preferred_value: Optional[str], user_payload: Optional[Dict]) -> Optional[str]: + preferred = self._sanitize_user_text(preferred_value) + if preferred: + return preferred + + if isinstance(user_payload, dict): + full_name = self._sanitize_user_text(user_payload.get("full_name")) + if full_name: + return full_name + first_name = self._sanitize_user_text(user_payload.get("first_name")) or "" + last_name = self._sanitize_user_text(user_payload.get("last_name")) or "" + combined = " ".join(part for part in [first_name, last_name] if part).strip() + if combined: + return combined + username = self._sanitize_user_text(user_payload.get("username")) + if username: + return username + email = self._sanitize_user_text(user_payload.get("email")) + if email: + return email + return None + # [/DEF:_extract_user_display:Function] + + # [DEF:_sanitize_user_text:Function] + # @PURPOSE: Convert scalar value to non-empty user-facing text. + # @PRE: value can be any scalar type. + # @POST: Returns trimmed string or None. + # @RETURN: Optional[str] + def _sanitize_user_text(self, value: Optional[Union[str, int]]) -> Optional[str]: + if value is None: + return None + normalized = str(value).strip() + if not normalized: + return None + return normalized + # [/DEF:_sanitize_user_text:Function] + # [DEF:get_dashboard:Function] # @PURPOSE: Fetches a single dashboard by ID. # @PRE: Client is authenticated and dashboard_id exists. diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index ddd008a..1e7171a 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -305,6 +305,10 @@ "running": "Running...", "git_status": "Git Status", "last_task": "Last Task", + "llm_status": "LLM Validation Status", + "changed_on": "Changed On", + "owners": "Owners", + "column_filter": "Column filter", "actions": "Actions", "action_migrate": "Migrate", "action_backup": "Backup", diff --git a/frontend/src/lib/i18n/locales/ru.json b/frontend/src/lib/i18n/locales/ru.json index 24a150e..239c140 100644 --- a/frontend/src/lib/i18n/locales/ru.json +++ b/frontend/src/lib/i18n/locales/ru.json @@ -304,6 +304,10 @@ "running": "Запуск...", "git_status": "Статус Git", "last_task": "Последняя задача", + "llm_status": "Статус LLM пров.", + "changed_on": "Дата изменения", + "owners": "Владельцы", + "column_filter": "Фильтр по колонке", "actions": "Действия", "action_migrate": "Мигрировать", "action_backup": "Создать бэкап", diff --git a/frontend/src/routes/dashboards/+page.svelte b/frontend/src/routes/dashboards/+page.svelte index 5f2c0d0..e9e396e 100644 --- a/frontend/src/routes/dashboards/+page.svelte +++ b/frontend/src/routes/dashboards/+page.svelte @@ -47,7 +47,9 @@ // State let selectedEnv = null; + let allDashboards = []; let dashboards = []; + let filteredDashboards = []; let isLoading = true; let error = null; @@ -64,6 +66,23 @@ // Search state let searchQuery = ""; + let sortColumn = "title"; + let sortDirection = "asc"; + let openFilterColumn = null; + let columnFilterSearch = { + title: "", + git_status: "", + llm_status: "", + changed_on: "", + actor: "", + }; + let columnFilters = { + title: new Set(), + git_status: new Set(), + llm_status: new Set(), + changed_on: new Set(), + actor: new Set(), + }; // Bulk action modal state let showMigrateModal = false; @@ -94,7 +113,8 @@ // Debounced search function const debouncedSearch = debounce((query) => { searchQuery = query; - loadDashboards(); + currentPage = 1; + applyGridTransforms(); }, 300); // Load environments and dashboards on mount @@ -111,6 +131,9 @@ if (!event.target.closest(".action-dropdown")) { closeActionDropdown(); } + if (!event.target.closest(".column-filter")) { + openFilterColumn = null; + } } // Add document click listener @@ -119,51 +142,175 @@ } // Load dashboards from API + // [DEF:DashboardHub.normalizeTaskStatus:Function] + /** + * @PURPOSE: Normalize raw task status to stable lowercase token for UI. + * @PRE: status can be enum-like string or null. + * @POST: returns null or normalized token without enum namespace. + */ + function normalizeTaskStatus(status) { + if (!status) return null; + const raw = String(status).trim(); + const plain = raw.includes(".") ? raw.split(".").pop() : raw; + return plain ? plain.toLowerCase() : null; + } + + // [/DEF:DashboardHub.normalizeTaskStatus:Function] + + // [DEF:DashboardHub.normalizeValidationStatus:Function] + /** + * @PURPOSE: Normalize validation status to pass/fail/warn/unknown. + * @PRE: status can be any scalar. + * @POST: returns one of pass|fail|warn|unknown. + */ + function normalizeValidationStatus(status) { + if (!status) return "unknown"; + const normalized = String(status).trim().toUpperCase(); + if (normalized === "PASS") return "pass"; + if (normalized === "FAIL") return "fail"; + if (normalized === "WARN") return "warn"; + return "unknown"; + } + + // [/DEF:DashboardHub.normalizeValidationStatus:Function] + + // [DEF:DashboardHub.getValidationBadgeClass:Function] + /** + * @PURPOSE: Map validation level to badge class tuple. + * @PRE: level in pass|fail|warn|unknown. + * @POST: returns deterministic tailwind class string. + */ + function getValidationBadgeClass(level) { + if (level === "pass") return "bg-emerald-100 text-emerald-700 border-emerald-200"; + if (level === "fail") return "bg-rose-100 text-rose-700 border-rose-200"; + if (level === "warn") return "bg-amber-100 text-amber-700 border-amber-200"; + return "bg-slate-100 text-slate-700 border-slate-200"; + } + + // [/DEF:DashboardHub.getValidationBadgeClass:Function] + + // [DEF:DashboardHub.getValidationLabel:Function] + /** + * @PURPOSE: Map normalized validation level to compact UI label. + * @PRE: level in pass|fail|warn|unknown. + * @POST: returns uppercase status label. + */ + function getValidationLabel(level) { + if (level === "pass") return "PASS"; + if (level === "fail") return "FAIL"; + if (level === "warn") return "WARN"; + return "UNKNOWN"; + } + + // [/DEF:DashboardHub.getValidationLabel:Function] + + // [DEF:DashboardHub.normalizeOwners:Function] + /** + * @PURPOSE: Normalize owners payload to unique non-empty display labels. + * @PRE: owners can be null, list of strings, or list of user objects. + * @POST: Returns owner labels preserving source order. + */ + function normalizeOwners(owners) { + if (!Array.isArray(owners)) return []; + + const result = []; + for (const owner of owners) { + let label = null; + if (typeof owner === "string") { + const trimmed = owner.trim(); + if (trimmed) label = trimmed; + } else if (owner && typeof owner === "object") { + const firstName = typeof owner.first_name === "string" ? owner.first_name.trim() : ""; + const lastName = typeof owner.last_name === "string" ? owner.last_name.trim() : ""; + const fullName = [firstName, lastName].filter(Boolean).join(" "); + label = + fullName || + (typeof owner.full_name === "string" ? owner.full_name.trim() : "") || + (typeof owner.username === "string" ? owner.username.trim() : "") || + (typeof owner.email === "string" ? owner.email.trim() : ""); + } + + if (label && !result.includes(label)) { + result.push(label); + } + } + + return result; + } + + // [/DEF:DashboardHub.normalizeOwners:Function] + + // [DEF:DashboardHub.loadDashboards:Function] + /** + * @PURPOSE: Load full dashboard dataset for current environment and hydrate grid projection. + * @PRE: selectedEnv is not null. + * @POST: allDashboards, dashboards, pagination and selection state are synchronized. + * @UX_STATE: Loading -> true during request lifecycle. + * @UX_STATE: Error -> `error` populated when request fails. + */ async function loadDashboards() { if (!selectedEnv) return; isLoading = true; error = null; try { - const response = await api.getDashboards(selectedEnv, { - search: searchQuery || undefined, - page: currentPage, - page_size: pageSize, + const firstResponse = await api.getDashboards(selectedEnv, { + page: 1, + page_size: 100, }); - // Preserve selected IDs across pagination - const newSelectedIds = new Set(); - response.dashboards.forEach((d) => { - if (selectedIds.has(d.id)) { - newSelectedIds.add(d.id); + const allPages = [firstResponse]; + if (firstResponse.total_pages > 1) { + const pageRequests = []; + for (let page = 2; page <= firstResponse.total_pages; page += 1) { + pageRequests.push( + api.getDashboards(selectedEnv, { + page, + page_size: 100, + }), + ); } + const nextPages = await Promise.all(pageRequests); + allPages.push(...nextPages); + } + + const rawDashboards = allPages.flatMap((response) => response.dashboards); + + allDashboards = rawDashboards.map((d) => { + const owners = normalizeOwners(d.owners); + return { + id: d.id, + title: d.title, + slug: d.slug, + changedOn: d.last_modified || null, + changedOnLabel: formatDate(d.last_modified), + owners, + actorLabel: owners.length > 0 ? owners.join(", ") : "-", + git: { + status: d.git_status?.sync_status?.toLowerCase() || "no_repo", + branch: d.git_status?.branch || null, + hasRepo: d.git_status?.has_repo === true, + hasChangesForCommit: d.git_status?.has_changes_for_commit === true, + }, + lastTask: d.last_task + ? { + status: normalizeTaskStatus(d.last_task.status), + validationStatus: normalizeValidationStatus( + d.last_task.validation_status, + ), + id: d.last_task.task_id, + } + : null, + actions: ["migrate", "backup"], + }; }); - selectedIds = newSelectedIds; - dashboards = response.dashboards.map((d) => ({ - id: d.id, - title: d.title, - slug: d.slug, - git: { - status: d.git_status?.sync_status?.toLowerCase() || "no_repo", - branch: d.git_status?.branch || null, - hasRepo: d.git_status?.has_repo === true, - hasChangesForCommit: d.git_status?.has_changes_for_commit === true, - }, - lastTask: d.last_task - ? { - status: d.last_task.status?.toLowerCase() || null, - id: d.last_task.task_id, - } - : null, - actions: ["migrate", "backup"], // All dashboards have migrate and backup options - })); - - // Update pagination state - total = response.total; - totalPages = response.total_pages; - - // Update selection state + selectedIds = new Set( + Array.from(selectedIds).filter((id) => + allDashboards.some((dashboard) => dashboard.id === id), + ), + ); + applyGridTransforms(); updateSelectionState(); } catch (err) { error = err.message || $t.dashboard?.load_failed; @@ -181,20 +328,20 @@ // Handle page change function handlePageChange(page) { currentPage = page; - loadDashboards(); + applyGridTransforms(); } // Handle page size change function handlePageSizeChange(event) { pageSize = parseInt(event.target.value); currentPage = 1; - loadDashboards(); + applyGridTransforms(); } // Update selection state based on current selection function updateSelectionState() { const visibleCount = dashboards.length; - const totalCount = total; + const totalCount = filteredDashboards.length; isAllSelected = selectedIds.size === totalCount && totalCount > 0; isAllVisibleSelected = @@ -217,7 +364,7 @@ if (isAllSelected) { selectedIds.clear(); } else { - dashboards.forEach((d) => selectedIds.add(d.id)); + filteredDashboards.forEach((d) => selectedIds.add(d.id)); } selectedIds = selectedIds; // Trigger reactivity updateSelectionState(); @@ -234,6 +381,303 @@ updateSelectionState(); } + // [/DEF:DashboardHub.loadDashboards:Function] + + // [DEF:DashboardHub.formatDate:Function] + /** + * @PURPOSE: Convert ISO timestamp to locale date string. + * @PRE: value may be null or invalid date string. + * @POST: returns formatted date or "-". + */ + function formatDate(value) { + if (!value) return "-"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "-"; + return date.toLocaleDateString(); + } + + // [/DEF:DashboardHub.formatDate:Function] + + // [DEF:DashboardHub.getGitSummaryLabel:Function] + /** + * @PURPOSE: Compute stable text label for git state column. + * @PRE: dashboard has git projection fields. + * @POST: returns localized summary string. + */ + function getGitSummaryLabel(dashboard) { + if (!dashboard.git?.hasRepo) { + return $t.dashboard?.status_no_repo || "No Repo"; + } + return dashboard.git?.hasChangesForCommit + ? $t.dashboard?.status_changes || "Diff" + : $t.dashboard?.status_no_changes || "Synced"; + } + + // [/DEF:DashboardHub.getGitSummaryLabel:Function] + + // [DEF:DashboardHub.getLlmSummaryLabel:Function] + /** + * @PURPOSE: Compute normalized LLM validation summary label. + * @PRE: dashboard may have null lastTask. + * @POST: returns UNKNOWN fallback for missing status. + */ + function getLlmSummaryLabel(dashboard) { + return getValidationLabel(dashboard.lastTask?.validationStatus || "unknown"); + } + + // [/DEF:DashboardHub.getLlmSummaryLabel:Function] + + // [DEF:DashboardHub.getColumnCellValue:Function] + /** + * @PURPOSE: Resolve comparable/filterable display value for any grid column. + * @PRE: column belongs to filterable column set. + * @POST: returns non-empty scalar display value. + */ + function getColumnCellValue(dashboard, column) { + if (column === "title") return dashboard.title || "-"; + if (column === "git_status") return getGitSummaryLabel(dashboard); + if (column === "llm_status") return getLlmSummaryLabel(dashboard); + if (column === "changed_on") return dashboard.changedOnLabel || "-"; + if (column === "actor") return dashboard.actorLabel || "-"; + return "-"; + } + + // [/DEF:DashboardHub.getColumnCellValue:Function] + + // [DEF:DashboardHub.getFilterOptions:Function] + /** + * @PURPOSE: Build unique sorted value list for a column filter dropdown. + * @PRE: allDashboards is hydrated. + * @POST: returns de-duplicated sorted options. + */ + function getFilterOptions(column) { + const options = allDashboards + .map((dashboard) => getColumnCellValue(dashboard, column)) + .filter((value) => Boolean(value)); + return Array.from(new Set(options)).sort((a, b) => + a.localeCompare(b, "ru"), + ); + } + + // [/DEF:DashboardHub.getFilterOptions:Function] + + // [DEF:DashboardHub.getVisibleFilterOptions:Function] + /** + * @PURPOSE: Apply in-dropdown search over full filter options. + * @PRE: columnFilterSearch contains search token for column. + * @POST: returns subset for current filter popover list. + */ + function getVisibleFilterOptions(column) { + const searchText = (columnFilterSearch[column] || "").toLowerCase(); + return getFilterOptions(column).filter((value) => + value.toLowerCase().includes(searchText), + ); + } + + // [/DEF:DashboardHub.getVisibleFilterOptions:Function] + + // [DEF:DashboardHub.toggleFilterDropdown:Function] + /** + * @PURPOSE: Toggle active column filter popover. + * @PRE: column is valid filter key. + * @POST: openFilterColumn updated. + */ + function toggleFilterDropdown(column) { + openFilterColumn = openFilterColumn === column ? null : column; + } + + // [/DEF:DashboardHub.toggleFilterDropdown:Function] + + // [DEF:DashboardHub.toggleFilterValue:Function] + /** + * @PURPOSE: Add/remove specific filter value and reapply projection. + * @PRE: value comes from option list of the same column. + * @POST: columnFilters updated and grid reprojected from page 1. + */ + function toggleFilterValue(column, value, checked) { + const next = new Set(columnFilters[column]); + if (checked) { + next.add(value); + } else { + next.delete(value); + } + columnFilters = { ...columnFilters, [column]: next }; + currentPage = 1; + applyGridTransforms(); + } + + // [/DEF:DashboardHub.toggleFilterValue:Function] + + // [DEF:DashboardHub.clearColumnFilter:Function] + /** + * @PURPOSE: Reset selected values for one column. + * @PRE: column is valid filter key. + * @POST: filter cleared and projection refreshed. + */ + function clearColumnFilter(column) { + columnFilters = { ...columnFilters, [column]: new Set() }; + currentPage = 1; + applyGridTransforms(); + } + + // [/DEF:DashboardHub.clearColumnFilter:Function] + + // [DEF:DashboardHub.selectAllColumnFilterValues:Function] + /** + * @PURPOSE: Select all currently visible values in filter popover. + * @PRE: visible options computed for current search token. + * @POST: column filter equals current visible option set. + */ + function selectAllColumnFilterValues(column) { + columnFilters = { + ...columnFilters, + [column]: new Set(getVisibleFilterOptions(column)), + }; + currentPage = 1; + applyGridTransforms(); + } + + // [/DEF:DashboardHub.selectAllColumnFilterValues:Function] + + // [DEF:DashboardHub.updateColumnFilterSearch:Function] + /** + * @PURPOSE: Update local search token for one filter popover. + * @PRE: value is text from input. + * @POST: columnFilterSearch updated immutably. + */ + function updateColumnFilterSearch(column, value) { + columnFilterSearch = { ...columnFilterSearch, [column]: value }; + } + + // [/DEF:DashboardHub.updateColumnFilterSearch:Function] + + // [DEF:DashboardHub.hasColumnFilter:Function] + /** + * @PURPOSE: Determine if column has active selected values. + * @PRE: column is valid filter key. + * @POST: returns boolean activation marker. + */ + function hasColumnFilter(column) { + return columnFilters[column]?.size > 0; + } + + // [/DEF:DashboardHub.hasColumnFilter:Function] + + // [DEF:DashboardHub.doesDashboardPassColumnFilters:Function] + /** + * @PURPOSE: Evaluate dashboard row against all active column filters. + * @PRE: dashboard contains projected values for each filterable column. + * @POST: returns true only when row matches every active filter. + */ + function doesDashboardPassColumnFilters(dashboard) { + const columns = Object.keys(columnFilters); + for (const column of columns) { + const values = columnFilters[column]; + if (values.size === 0) continue; + const dashboardValue = getColumnCellValue(dashboard, column); + if (!values.has(dashboardValue)) return false; + } + return true; + } + + // [/DEF:DashboardHub.doesDashboardPassColumnFilters:Function] + + // [DEF:DashboardHub.getSortValue:Function] + /** + * @PURPOSE: Compute stable comparable sort key for chosen column. + * @PRE: column belongs to sortable set. + * @POST: returns string/number key suitable for deterministic comparison. + */ + function getSortValue(dashboard, column) { + if (column === "title") return (dashboard.title || "").toLowerCase(); + if (column === "git_status") return (getGitSummaryLabel(dashboard) || "").toLowerCase(); + if (column === "llm_status") return (getLlmSummaryLabel(dashboard) || "").toLowerCase(); + if (column === "changed_on") return dashboard.changedOn ? new Date(dashboard.changedOn).getTime() : 0; + if (column === "actor") return (dashboard.actorLabel || "").toLowerCase(); + return ""; + } + + // [/DEF:DashboardHub.getSortValue:Function] + + // [DEF:DashboardHub.handleSort:Function] + /** + * @PURPOSE: Toggle or switch sort order and reapply grid projection. + * @PRE: column belongs to sortable set. + * @POST: sortColumn/sortDirection updated and page reset to 1. + */ + function handleSort(column) { + if (sortColumn === column) { + sortDirection = sortDirection === "asc" ? "desc" : "asc"; + } else { + sortColumn = column; + sortDirection = "asc"; + } + currentPage = 1; + applyGridTransforms(); + } + + // [/DEF:DashboardHub.handleSort:Function] + + // [DEF:DashboardHub.getSortIndicator:Function] + /** + * @PURPOSE: Return visual indicator for active/inactive sort header. + * @PRE: column belongs to sortable set. + * @POST: returns one of ↕ | ↑ | ↓. + */ + function getSortIndicator(column) { + if (sortColumn !== column) return "↕"; + return sortDirection === "asc" ? "↑" : "↓"; + } + + // [/DEF:DashboardHub.getSortIndicator:Function] + + // [DEF:DashboardHub.applyGridTransforms:Function] + /** + * @PURPOSE: Apply search + column filters + sort + pagination to grid data. + * @PRE: allDashboards is current source collection. + * @POST: filteredDashboards/dashboards/total/totalPages are synchronized. + * @UX_STATE: Loaded -> visible rows reflect all active controls deterministically. + */ + function applyGridTransforms() { + const searchText = searchQuery.trim().toLowerCase(); + const nextFiltered = allDashboards + .filter((dashboard) => { + if (!searchText) return true; + const haystack = [ + dashboard.title, + dashboard.actorLabel, + getGitSummaryLabel(dashboard), + getLlmSummaryLabel(dashboard), + dashboard.changedOnLabel, + ] + .filter((value) => Boolean(value)) + .join(" ") + .toLowerCase(); + return haystack.includes(searchText); + }) + .filter((dashboard) => doesDashboardPassColumnFilters(dashboard)); + + nextFiltered.sort((a, b) => { + const aValue = getSortValue(a, sortColumn); + const bValue = getSortValue(b, sortColumn); + if (aValue < bValue) return sortDirection === "asc" ? -1 : 1; + if (aValue > bValue) return sortDirection === "asc" ? 1 : -1; + return 0; + }); + + filteredDashboards = nextFiltered; + total = filteredDashboards.length; + totalPages = Math.max(1, Math.ceil(total / pageSize)); + if (currentPage > totalPages) { + currentPage = totalPages; + } + const start = (currentPage - 1) * pageSize; + const end = start + pageSize; + dashboards = filteredDashboards.slice(start, end); + updateSelectionState(); + } + // [/DEF:DashboardHub.applyGridTransforms:Function] + // Toggle action dropdown function toggleActionDropdown(dashboardId, event) { event.stopPropagation(); @@ -766,25 +1210,31 @@ {#if isLoading} -
+
-
-
-
-
-
+
+
+
+
+
+
+
{#each Array(5) as _}
-
-
-
-
-
+
+
+
+
+
+
+
{/each}
@@ -846,118 +1296,323 @@
-
- -
-
-
- {$t.dashboard?.title} -
-
- {$t.dashboard?.git_status} -
-
{$t.dashboard?.last_task}
-
- {$t.dashboard?.actions} -
-
- - - {#each dashboards as dashboard} +
+
+
- -
- handleCheckboxChange(dashboard, e)} - /> -
- - -
- -
- - -
-
- + + {#if openFilterColumn === "title"} +
+ updateColumnFilterSearch("title", event.target.value)} + /> +
+ + +
+
+ {#each getVisibleFilterOptions("title") as value} + + {/each} +
+
{/if}
- - -
- {#if dashboard.lastTask} -
handleTaskStatusClick(dashboard)} - on:keydown={(e) => - (e.key === "Enter" || e.key === " ") && - handleTaskStatusClick(dashboard)} - role="button" - tabindex="0" - aria-label={$t.dashboard?.view_task} +
+ +
+
- {:else} - - - {/if} + ▾ + + {#if openFilterColumn === "git_status"} +
+ updateColumnFilterSearch("git_status", event.target.value)} + /> +
+ + +
+
+ {#each getVisibleFilterOptions("git_status") as value} + + {/each} +
+
+ {/if} +
+
+ +
+ + {#if openFilterColumn === "llm_status"} +
+ updateColumnFilterSearch("llm_status", event.target.value)} + /> +
+ + +
+
+ {#each getVisibleFilterOptions("llm_status") as value} + + {/each} +
+
+ {/if} +
+
+
+ +
+ + {#if openFilterColumn === "changed_on"} +
+ updateColumnFilterSearch("changed_on", event.target.value)} + /> +
+ + +
+
+ {#each getVisibleFilterOptions("changed_on") as value} + + {/each} +
+
+ {/if} +
+
+
+ +
+ + {#if openFilterColumn === "actor"} +
+ updateColumnFilterSearch("actor", event.target.value)} + /> +
+ + +
+
+ {#each getVisibleFilterOptions("actor") as value} + + {/each} +
+
+ {/if} +
+
+
+ {$t.dashboard?.actions} +
+
- -
+ + {#each dashboards as dashboard} +
+ +
+ handleCheckboxChange(dashboard, e)} + /> +
+ + +
+ +
+ + +
+
+ + {dashboard.git?.hasRepo + ? $t.dashboard?.status_repo || "Repo" + : $t.dashboard?.status_no_repo || "No Repo"} + + {#if dashboard.git?.hasRepo} + + {dashboard.git?.hasChangesForCommit + ? $t.dashboard?.status_changes || "Diff" + : $t.dashboard?.status_no_changes || "Synced"} + + {/if} +
+
+ + +
+ {#if dashboard.lastTask} +
handleTaskStatusClick(dashboard)} + on:keydown={(e) => + (e.key === "Enter" || e.key === " ") && + handleTaskStatusClick(dashboard)} + role="button" + tabindex="0" + aria-label={$t.dashboard?.view_task} + > + {getValidationLabel(dashboard.lastTask.validationStatus)} +
+ {:else} + - + {/if} +
+ + +
+ {dashboard.changedOnLabel} +
+ + +
+
{$t.dashboard?.owners || "Owners"}
+ {#if dashboard.owners?.length > 0} +
+ {#each dashboard.owners as owner (owner)} + + {owner} + + {/each} +
+ {:else} +
-
+ {/if} +
+ + +
{#if !dashboard.git?.hasRepo} {:else} {/if}
{/if} +
{#if selectedIds.size > 0} @@ -1503,7 +2159,7 @@ class="max-h-40 overflow-y-auto border border-gray-200 rounded-lg bg-gray-50 p-2" > {#each Array.from(selectedIds) as id} - {#each dashboards as d} + {#each allDashboards as d} {#if d.id === id}
@@ -1611,7 +2267,7 @@ class="max-h-40 overflow-y-auto border border-gray-200 rounded-lg bg-gray-50 p-2" > {#each Array.from(selectedIds) as id} - {#each dashboards as d} + {#each allDashboards as d} {#if d.id === id}