feat(frontend): add DashboardHub components, EmptyState, and validation scaffold

- DashboardHubModel.svelte.ts: Screen Model for dashboard listing hub
  with filters, pagination, and selection state.
- DashboardRow.svelte: Individual dashboard card with Git/health badges.
- ColumnFilterPopover.svelte: Column-based filter dropdown for dashboards.
- dashboard-helpers.ts: Shared dashboard utility functions.
- EmptyState.svelte: Reusable empty state component with icon and CTA.
- validation route scaffold for per-dashboard validation views.
This commit is contained in:
2026-06-02 09:55:39 +03:00
parent a8ea1e9207
commit 48e6e9025c
6 changed files with 1993 additions and 0 deletions

View File

@@ -0,0 +1,586 @@
// #region DashboardHubModel [C:4] [TYPE Model] [SEMANTICS dashboard, hub, grid, filter, pagination, git, screen-model]
// @BRIEF State model for the dashboard hub page — all atoms, invariants, and actions in one class.
// @REJECTED Inline $state + handler functions in +page.svelte was rejected because it scattered 35+ atoms and 40+ functions across 2400+ lines, making testing impossible without DOM render and violating INV_7 (>400 lines, CC >10).
// @INVARIANT Changing filter (column, search, multi-select) resets pagination to page 1.
// @INVARIANT Changing source environment resets all dashboard, filter, selection, and git state.
// @INVARIANT Selection never contains IDs not present in allDashboards (cleaned on load).
// @INVARIANT dryRunResult is always null when selectedDashboardIds changes (handled by $effect in page).
// @STATE loading — API call in progress.
// @STATE loaded — Dashboard grid with status badges rendered.
// @STATE empty — Query returned zero results.
// @STATE error — API call failed, error message available.
// @ACTION loadDashboards() — Fetches dashboards for current env/page/search/filters.
// @ACTION setPage(n) — Navigates to page n.
// @ACTION setSort(column) — Toggles sort on column.
// @ACTION toggleFilterValue(col,val,chk) — Adds/removes filter value.
// @ACTION clearColumnFilter(col) — Resets one column filter.
// @ACTION handleGitInit(dashboard) — Initializes git repo.
// @ACTION handleGitSync(dashboard) — Syncs git repo.
// @ACTION handleGitCommit(dashboard) — Commits changes.
// @ACTION handleGitPull(dashboard) — Pulls from remote.
// @ACTION handleGitPush(dashboard) — Pushes to remote.
// @ACTION handleBulkBackup() — Executes bulk backup.
// @RELATION DEPENDS_ON -> [api]
// @RELATION CALLS -> [gitService]
// @RELATION BINDS_TO -> [environmentContextStore]
// @RELATION CALLS -> [log]
import { goto } from "$app/navigation";
import { log } from "$lib/cot-logger";
import { api } from "$lib/api.js";
import { gitService } from "../../services/gitService.js";
import { addToast } from "$lib/toasts.svelte.js";
import { openDrawerForTask, openDrawerForTaskIfPreferred } from "$lib/stores/taskDrawer.svelte.js";
import { environmentContextStore } from "$lib/stores/environmentContext.svelte.js";
import { MigrationModel } from "./MigrationModel.svelte.ts";
import { t } from "$lib/i18n/index.svelte.js";
import { normalizeTaskStatus, normalizeValidationStatus, normalizeOwners, formatDate } from "../../routes/dashboards/dashboard-helpers.js";
// ═══ Types ═══════════════════════════════════════════════════════
type SortDir = "asc" | "desc";
type FilterColumn = "title" | "git_status" | "validation_status" | "changed_on" | "actor";
type ColumnFilters = Record<FilterColumn, Set<string>>;
type ColumnFilterSearch = Record<FilterColumn, string>;
interface DashboardRow {
id: string; title: string; slug: string;
changedOn: string | null; changedOnLabel: string;
owners: string[]; actorLabel: string;
git: { status: string; branch: string | null; hasRepo: boolean | null; hasChangesForCommit: boolean };
lastTask: { status: string | null; validationStatus: string; id: string } | null;
actions: string[];
}
interface GitState { status: string; branch: string | null; hasRepo: boolean; hasChangesForCommit: boolean }
// ═══ Model ═══════════════════════════════════════════════════════
export class DashboardHubModel {
// ── Core state ────────────────────────────────────────────────
selectedEnv: string | null = $state(null);
allDashboards: DashboardRow[] = $state([]);
dashboards: DashboardRow[] = $state([]);
filteredDashboards: DashboardRow[] = $state([]);
isLoading: boolean = $state(true);
error: string | null = $state(null);
// ── Pagination ────────────────────────────────────────────────
currentPage: number = $state(1);
pageSize: number = $state(10);
totalPages: number = $state(1);
total: number = $state(0);
// ── Selection ─────────────────────────────────────────────────
selectedIds: Set<string> = $state(new Set());
isAllSelected: boolean = $state(false);
isAllVisibleSelected: boolean = $state(false);
// ── Search ────────────────────────────────────────────────────
searchQuery: string = $state("");
filterDashboardIds: string[] = $state([]);
searchableDashboardOptions: Array<{id: string; name: string; subtitle: string; hint: string}> = $state([]);
searchableDashboardLoading: boolean = $state(false);
sortColumn: FilterColumn = $state("title");
sortDirection: SortDir = $state("asc");
openFilterColumn: FilterColumn | null = $state(null);
filterDropdownPosition: { left: number; top: number } = $state({ left: 0, top: 0 });
columnFilterSearch: ColumnFilterSearch = $state({ title: "", git_status: "", validation_status: "", changed_on: "", actor: "" });
columnFilters: ColumnFilters = $state({ title: new Set(), git_status: new Set(), validation_status: new Set(), changed_on: new Set(), actor: new Set() });
// ── Validation popover ────────────────────────────────────────
validationStatuses: Record<string, any> = $state({});
openValidationPopover: string | null = $state(null);
validationPopoverPosition: { left: number; top: number } = $state({ left: 0, top: 0 });
validationStatusLoading: boolean = $state(false);
// ── Bulk modals ───────────────────────────────────────────────
showMigrateModal: boolean = $state(false);
showBackupModal: boolean = $state(false);
backupSchedule: string = $state("");
isSubmittingBackup: boolean = $state(false);
isEditingMappings: boolean = $state(false);
readonly bulkMigrateModel = new MigrationModel();
// ── Git ───────────────────────────────────────────────────────
openActionDropdown: string | null = $state(null);
gitBusyIds: Set<string> = $state(new Set());
gitResolvedIds: Set<string> = $state(new Set());
gitLoadingIds: Set<string> = $state(new Set());
cachedGitConfigs: any[] = $state([]);
dashboardsLoadSeq: number = $state(0);
lastLoadedEnvId: string | null = $state(null);
serverTotal: number = $state(0);
serverTotalPages: number = $state(1);
profileFilterOverrideShowAll: boolean = $state(false);
effectiveProfileFilter: any = $state(null);
// ── Derived (store-dependent — handled in page due to legacy store) ──────
// ═══ Data loading ══════════════════════════════════════════════
async loadDashboards(): Promise<void> {
if (!this.selectedEnv) return;
const requestSeq = this.dashboardsLoadSeq + 1;
this.dashboardsLoadSeq = requestSeq;
this.isLoading = true;
this.error = null;
try {
const firstResponse = await api.getDashboards(this.selectedEnv, {
page: this.currentPage, page_size: this.pageSize,
search: this.searchQuery.trim() || undefined,
page_context: "dashboards_main",
apply_profile_default: true,
override_show_all: this.profileFilterOverrideShowAll,
filters: {
title: Array.from(this.columnFilters.title || []),
git_status: Array.from(this.columnFilters.git_status || []),
changed_on: Array.from(this.columnFilters.changed_on || []),
actor: Array.from(this.columnFilters.actor || []),
},
});
if (requestSeq !== this.dashboardsLoadSeq) return;
const rawDashboards = firstResponse?.dashboards || [];
this.serverTotal = Number(firstResponse?.total || 0);
this.serverTotalPages = Math.max(1, Number(firstResponse?.total_pages || 1));
this.effectiveProfileFilter = firstResponse?.effective_profile_filter || null;
this.profileFilterOverrideShowAll = Boolean(firstResponse?.effective_profile_filter?.override_show_all);
this.gitResolvedIds = new Set();
this.gitLoadingIds = new Set();
this.allDashboards = rawDashboards.map((d: any) => {
const owners = normalizeOwners(d.owners);
const hasGitStatus = !!d.git_status;
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: hasGitStatus ? {
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,
} : { status: "pending", branch: null, hasRepo: null, hasChangesForCommit: false },
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"],
};
});
this.selectedIds = new Set(
Array.from(this.selectedIds).filter(id => this.allDashboards.some(d => d.id === id))
);
this.applyGridTransforms();
this.updateSelectionState();
} catch (err: any) {
if (requestSeq !== this.dashboardsLoadSeq) return;
this.error = err.message || t.dashboard?.load_failed;
log('DashboardHub', 'EXPLORE', 'Load dashboards Error', {}, err?.message || 'unknown');
} finally {
if (requestSeq === this.dashboardsLoadSeq) this.isLoading = false;
}
}
async loadDashboardSearchOptions(query: string = ''): Promise<void> {
if (!this.selectedEnv) return;
this.searchableDashboardLoading = true;
try {
const response = await api.getDashboards(this.selectedEnv, {
search: query || undefined, page_size: 100, page_context: 'other',
apply_profile_default: false, override_show_all: true,
});
this.searchableDashboardOptions = (response?.dashboards || []).map((d: any) => ({
id: d.id, name: d.title, subtitle: d.slug || '',
hint: d.last_modified ? formatDate(d.last_modified) : '',
}));
} catch { this.searchableDashboardOptions = []; }
finally { this.searchableDashboardLoading = false; }
}
async fetchValidationStatuses(): Promise<void> {
if (!this.selectedEnv || this.dashboards.length === 0) return;
this.validationStatusLoading = true;
try {
const ids = this.dashboards.map(d => d.id).filter(Boolean);
if (ids.length === 0) return;
const result = await api.getValidationStatusBatch(this.selectedEnv, ids.join(","));
if (result && typeof result === "object") this.validationStatuses = result;
} catch { /* graceful no-op */ }
finally { this.validationStatusLoading = false; }
}
// ═══ Pagination ════════════════════════════════════════════════
setPage(page: number): void {
if (page === this.currentPage) return;
this.currentPage = page;
void this.loadDashboards();
}
setPageSize(event: Event): void {
this.pageSize = parseInt((event.target as HTMLSelectElement).value);
this.currentPage = 1;
void this.loadDashboards();
}
// ═══ Profile filter ════════════════════════════════════════════
handleTemporaryShowAll(): void {
if (this.profileFilterOverrideShowAll) return;
this.profileFilterOverrideShowAll = true;
this.currentPage = 1;
void this.loadDashboards();
}
handleRestoreProfileFilter(): void {
if (!this.profileFilterOverrideShowAll) return;
this.profileFilterOverrideShowAll = false;
this.currentPage = 1;
void this.loadDashboards();
}
// ═══ Selection ═════════════════════════════════════════════════
updateSelectionState(): void {
this.isAllSelected = this.selectedIds.size === this.filteredDashboards.length && this.filteredDashboards.length > 0;
this.isAllVisibleSelected = this.selectedIds.size === this.dashboards.length && this.dashboards.length > 0;
}
replaceSelectedIds(next: Set<string>): void { this.selectedIds = next; this.updateSelectionState(); }
clearSelectedIds(): void { this.replaceSelectedIds(new Set()); }
handleCheckboxChange(dashboard: DashboardRow, event: Event): void {
const next = new Set(this.selectedIds);
(event.target as HTMLInputElement).checked ? next.add(dashboard.id) : next.delete(dashboard.id);
this.replaceSelectedIds(next);
}
handleSelectAll(): void {
if (this.isAllSelected) { this.clearSelectedIds(); return; }
const next = new Set(this.selectedIds);
this.filteredDashboards.forEach(d => next.add(d.id));
this.replaceSelectedIds(next);
}
handleSelectVisible(): void {
const next = new Set(this.selectedIds);
if (this.isAllVisibleSelected) { this.dashboards.forEach(d => next.delete(d.id)); }
else { this.dashboards.forEach(d => next.add(d.id)); }
this.replaceSelectedIds(next);
}
// ═══ Column filters ═══════════════════════════════════════════
getValidationLabelForCell(dashboard: DashboardRow): string {
const vs = this.validationStatuses[dashboard.id];
return (!vs || !vs.status) ? "unknown" : String(vs.status).toLowerCase();
}
getColumnCellValue(dashboard: DashboardRow, column: FilterColumn): string {
if (column === "title") return dashboard.title || "-";
if (column === "git_status") return String(dashboard.git?.status || "pending").toLowerCase();
if (column === "validation_status") return this.getValidationLabelForCell(dashboard);
if (column === "changed_on") return dashboard.changedOn ? String(dashboard.changedOn).slice(0, 10) : "-";
if (column === "actor") return dashboard.actorLabel || "-";
return "-";
}
getFilterOptions(column: FilterColumn): string[] {
return Array.from(new Set(
this.allDashboards.map(d => this.getColumnCellValue(d, column)).filter(Boolean)
)).sort((a, b) => a.localeCompare(b, "ru"));
}
getVisibleFilterOptions(column: FilterColumn): string[] {
const searchText = (this.columnFilterSearch[column] || "").toLowerCase();
return this.getFilterOptions(column).filter(v => v.toLowerCase().includes(searchText));
}
toggleFilterDropdown(column: FilterColumn, event: Event | undefined, panelWidth = 256): void {
event?.stopPropagation();
if (this.openFilterColumn === column) { this.openFilterColumn = null; return; }
const trigger = (event as any)?.currentTarget;
if (trigger?.getBoundingClientRect) {
const rect = trigger.getBoundingClientRect();
const vw = typeof window !== "undefined" ? window.innerWidth : 1920;
this.filterDropdownPosition = { left: Math.max(8, Math.min(rect.left, vw - panelWidth - 8)), top: rect.bottom + 8 };
}
this.openFilterColumn = column;
}
toggleFilterValue(column: FilterColumn, value: string, checked: boolean): void {
const next = new Set(this.columnFilters[column]);
checked ? next.add(value) : next.delete(value);
this.columnFilters = { ...this.columnFilters, [column]: next };
this.currentPage = 1;
void this.loadDashboards();
}
clearColumnFilter(column: FilterColumn): void {
this.columnFilters = { ...this.columnFilters, [column]: new Set() };
this.currentPage = 1;
void this.loadDashboards();
}
selectAllColumnFilterValues(column: FilterColumn): void {
this.columnFilters = { ...this.columnFilters, [column]: new Set(this.getVisibleFilterOptions(column)) };
this.currentPage = 1;
void this.loadDashboards();
}
updateColumnFilterSearch(column: FilterColumn, value: string): void {
this.columnFilterSearch = { ...this.columnFilterSearch, [column]: value };
}
hasColumnFilter(column: FilterColumn): boolean { return this.columnFilters[column]?.size > 0; }
// ═══ Sort ══════════════════════════════════════════════════════
getSortValue(dashboard: DashboardRow, column: FilterColumn): string | number {
if (column === "title") return (dashboard.title || "").toLowerCase();
if (column === "git_status") return (this.getGitSummaryLabel(dashboard) || "").toLowerCase();
if (column === "validation_status") return (this.getValidationLabelForCell(dashboard) || "").toLowerCase();
if (column === "changed_on") return dashboard.changedOn ? new Date(dashboard.changedOn).getTime() : 0;
if (column === "actor") return (dashboard.actorLabel || "").toLowerCase();
return "";
}
handleSort(column: FilterColumn): void {
if (this.sortColumn === column) {
this.sortDirection = this.sortDirection === "asc" ? "desc" : "asc";
} else {
this.sortColumn = column;
this.sortDirection = "asc";
}
this.currentPage = 1;
this.applyGridTransforms();
}
// ═══ Grid transforms ═══════════════════════════════════════════
applyGridTransforms(): void {
let next = this.allDashboards;
if (this.filterDashboardIds.length > 0) {
next = next.filter(d => this.filterDashboardIds.includes(d.id));
}
next = [...next].sort((a, b) => {
const aV = this.getSortValue(a, this.sortColumn);
const bV = this.getSortValue(b, this.sortColumn);
if (aV < bV) return this.sortDirection === "asc" ? -1 : 1;
if (aV > bV) return this.sortDirection === "asc" ? 1 : -1;
return 0;
});
this.filteredDashboards = next;
this.dashboards = this.filteredDashboards;
this.total = this.serverTotal;
this.totalPages = this.serverTotalPages;
this.updateSelectionState();
void this.hydrateVisibleGitStatusesBatch();
void this.fetchValidationStatuses();
}
// ═══ Git helpers ═══════════════════════════════════════════════
getGitSummaryLabel(dashboard: DashboardRow): string {
if (dashboard.git?.hasRepo === null) return t.common?.loading || "Loading";
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";
}
isGitBusy(dashboardId: string): boolean { return this.gitBusyIds.has(dashboardId); }
setGitBusy(dashboardId: string, busy: boolean): void {
if (busy) this.gitBusyIds.add(dashboardId); else this.gitBusyIds.delete(dashboardId);
this.gitBusyIds = new Set(this.gitBusyIds);
}
async ensureGitConfigs(): Promise<any[]> {
if (this.cachedGitConfigs.length > 0) return this.cachedGitConfigs;
this.cachedGitConfigs = await gitService.getConfigs();
return this.cachedGitConfigs;
}
updateDashboardGitState(dashboardId: string, nextGit: Partial<GitState>): void {
const merge = (col: DashboardRow[]) => col.map(d => d.id === dashboardId ? { ...d, git: { ...d.git, ...nextGit } } : d);
this.allDashboards = merge(this.allDashboards);
this.filteredDashboards = merge(this.filteredDashboards);
this.dashboards = merge(this.dashboards);
}
normalizeRepositoryStatusPayload(status: any): GitState {
const syncState = String(status?.sync_state || "").toUpperCase();
const syncStatus = String(status?.sync_status || "").toUpperCase();
const explicitHasRepo = status?.has_repo;
const hasRepo = explicitHasRepo === true ? true : explicitHasRepo === false ? false : !(syncState === "NO_REPO" || syncStatus === "NO_REPO");
if (!hasRepo) return { status: "no_repo", branch: null, hasRepo: false, hasChangesForCommit: false };
const hasChanges = syncState === "CHANGES" || syncStatus === "DIFF" || Boolean(status?.is_dirty) ||
(status?.untracked_files?.length || 0) > 0 || (status?.modified_files?.length || 0) > 0 || (status?.staged_files?.length || 0) > 0;
return { status: hasChanges ? "diff" : "ok", branch: status?.current_branch || status?.branch || null, hasRepo: true, hasChangesForCommit: hasChanges };
}
async fetchDashboardGitStatusesBatch(dashboardIds: string[], force = false): Promise<void> {
const uniqueIds = Array.from(new Set(dashboardIds));
if (uniqueIds.length === 0) return;
if (force) { uniqueIds.forEach(id => this.gitResolvedIds.delete(id)); this.gitResolvedIds = new Set(this.gitResolvedIds); }
const pendingIds = uniqueIds.filter(id => !this.gitResolvedIds.has(id) && !this.gitLoadingIds.has(id));
if (pendingIds.length === 0) return;
pendingIds.forEach(id => this.gitLoadingIds.add(id));
this.gitLoadingIds = new Set(this.gitLoadingIds);
try {
const batchResult = await gitService.getStatusesBatch(pendingIds);
const statuses = batchResult?.statuses || {};
pendingIds.forEach(id => {
const st = statuses[id] ?? statuses[String(id)];
this.updateDashboardGitState(id, st ? this.normalizeRepositoryStatusPayload(st) : { status: "no_repo", branch: null, hasRepo: false, hasChangesForCommit: false });
});
} catch {
pendingIds.forEach(id => this.updateDashboardGitState(id, { status: "no_repo", branch: null, hasRepo: false, hasChangesForCommit: false }));
} finally {
pendingIds.forEach(id => { this.gitLoadingIds.delete(id); this.gitResolvedIds.add(id); });
this.gitLoadingIds = new Set(this.gitLoadingIds);
this.gitResolvedIds = new Set(this.gitResolvedIds);
}
}
async refreshDashboardGitState(dashboardId: string, force = false): Promise<void> { await this.fetchDashboardGitStatusesBatch([dashboardId], force); }
async hydrateVisibleGitStatusesBatch(): Promise<void> {
const pending = this.dashboards.filter(d => d.git?.hasRepo === null && !this.gitResolvedIds.has(d.id) && !this.gitLoadingIds.has(d.id)).map(d => d.id);
if (pending.length > 0) await this.fetchDashboardGitStatusesBatch(pending, false);
}
async handleGitInit(dashboard: DashboardRow): Promise<void> {
this.setGitBusy(dashboard.id, true);
try {
const configs = await this.ensureGitConfigs();
if (!configs.length) { addToast(t.git?.no_servers_configured || "No Git config found", "error"); return; }
const config = configs[0];
const defaultRemote = config?.default_repository ? `${String(config.url || "").replace(/\/$/, "")}/${config.default_repository}.git` : "";
const remoteUrl = prompt(t.git?.remote_url || "Remote URL", defaultRemote);
if (!remoteUrl) return;
await gitService.initRepository(dashboard.id, config.id, remoteUrl.trim());
addToast(t.git?.init_success || "Repository initialized", "success");
await this.refreshDashboardGitState(dashboard.id, true);
} catch (err: any) { addToast(err?.message || "Git init failed", "error"); }
finally { this.setGitBusy(dashboard.id, false); }
}
async handleGitSync(dashboard: DashboardRow): Promise<void> {
this.setGitBusy(dashboard.id, true);
try { await gitService.sync(dashboard.id, this.selectedEnv || null); addToast(t.git?.sync_success || "Synced", "success"); await this.refreshDashboardGitState(dashboard.id, true); }
catch (err: any) { addToast(err?.message || "Git sync failed", "error"); }
finally { this.setGitBusy(dashboard.id, false); }
}
async handleGitCommit(dashboard: DashboardRow): Promise<void> {
if (!dashboard.git?.hasRepo) { addToast(t.git?.not_linked || "Repository not linked", "error"); return; }
if (!dashboard.git?.hasChangesForCommit) { addToast(t.git?.nothing_to_commit || "No changes to commit", "error"); return; }
const message = prompt(t.git?.commit_message || "Commit message", `Update dashboard ${dashboard.title}`);
if (!message?.trim()) return;
this.setGitBusy(dashboard.id, true);
try { await gitService.commit(dashboard.slug || dashboard.id, message.trim()); addToast(t.git?.commit_success || "Committed", "success"); await this.refreshDashboardGitState(dashboard.id, true); }
catch (err: any) { addToast(err?.message || "Git commit failed", "error"); }
finally { this.setGitBusy(dashboard.id, false); }
}
async handleGitPull(dashboard: DashboardRow): Promise<void> {
if (!dashboard.git?.hasRepo) return;
this.setGitBusy(dashboard.id, true);
try { await gitService.pull(dashboard.slug || dashboard.id, this.selectedEnv || null); addToast(t.git?.pull_success || "Pulled", "success"); await this.refreshDashboardGitState(dashboard.id, true); }
catch (err: any) { addToast(err?.message || "Git pull failed", "error"); }
finally { this.setGitBusy(dashboard.id, false); }
}
async handleGitPush(dashboard: DashboardRow): Promise<void> {
if (!dashboard.git?.hasRepo) return;
this.setGitBusy(dashboard.id, true);
try { await gitService.push(dashboard.slug || dashboard.id, this.selectedEnv || null); addToast(t.git?.push_success || "Pushed", "success"); await this.refreshDashboardGitState(dashboard.id, true); }
catch (err: any) { addToast(err?.message || "Git push failed", "error"); }
finally { this.setGitBusy(dashboard.id, false); }
}
// ═══ Validation popover ════════════════════════════════════════
toggleValidationPopover(dashboardId: string, event: Event): void {
event.stopPropagation();
if (this.openValidationPopover === dashboardId) { this.openValidationPopover = null; return; }
const trigger = event.currentTarget as HTMLElement;
if (trigger?.getBoundingClientRect) {
const rect = trigger.getBoundingClientRect();
const vw = typeof window !== "undefined" ? window.innerWidth : 1920;
this.validationPopoverPosition = { left: Math.max(8, Math.min(rect.left, vw - 320 - 8)), top: rect.bottom + 8 };
}
this.openValidationPopover = dashboardId;
}
// ═══ Actions ═══════════════════════════════════════════════════
toggleActionDropdown(id: string | null, event: Event): void {
event.stopPropagation();
this.openActionDropdown = this.openActionDropdown === id ? null : id;
}
closeActionDropdown(): void { this.openActionDropdown = null; }
async handleAction(dashboard: DashboardRow, action: string): Promise<void> {
log('DashboardHub', 'REASON', `${action} on dashboard`, { title: dashboard.title });
this.closeActionDropdown();
try {
if (action === "migrate") {
this.replaceSelectedIds(new Set([dashboard.id]));
this.showMigrateModal = true;
this.isEditingMappings = false;
this.bulkMigrateModel.sourceEnvId = this.selectedEnv ?? "";
this.bulkMigrateModel.selectedDashboardIds = Array.from(this.selectedIds);
this.bulkMigrateModel.targetEnvId = "";
this.bulkMigrateModel.dryRunResult = null;
this.bulkMigrateModel.error = "";
this.bulkMigrateModel.sourceDatabases = [];
this.bulkMigrateModel.targetDatabases = [];
this.bulkMigrateModel.mappings = [];
this.bulkMigrateModel.suggestions = [];
this.bulkMigrateModel.currentStep = 1;
} else if (action === "backup") {
this.replaceSelectedIds(new Set([dashboard.id]));
this.showBackupModal = true;
this.backupSchedule = "";
}
} catch (err: any) { log('DashboardHub', 'EXPLORE', 'Action Error', {}, err?.message || 'unknown'); }
}
async handleBulkBackup(): Promise<void> {
if (this.isSubmittingBackup || this.selectedIds.size === 0) return;
this.isSubmittingBackup = true;
try {
const response = await api.postApi("/dashboards/backup", { env_id: this.selectedEnv, dashboard_ids: Array.from(this.selectedIds), schedule: this.backupSchedule || undefined });
log('DashboardHub', 'REFLECT', 'Bulk backup task created', { task_id: response.task_id });
this.showBackupModal = false;
this.clearSelectedIds();
if (response.task_id) {
const taskId = typeof response.task_id === "object" ? response.task_id.id || response.task_id.task_id : response.task_id;
openDrawerForTaskIfPreferred(taskId);
}
} catch (err: any) {
log('DashboardHub', 'EXPLORE', 'Bulk backup error', {}, err?.message || 'unknown');
alert(t.dashboard?.backup_task_failed);
} finally { this.isSubmittingBackup = false; }
}
handleTaskStatusClick(dashboard: DashboardRow): void {
if (dashboard.lastTask?.id) {
log('DashboardHub', 'REASON', 'Open task drawer for task', { taskId: dashboard.lastTask.id });
openDrawerForTask(dashboard.lastTask.id);
}
}
navigateToDashboardDetail(dashboard: DashboardRow): void {
const ref = dashboard?.slug || dashboard?.id;
if (!ref || !this.selectedEnv) return;
goto(`/dashboards/${encodeURIComponent(String(ref))}?env_id=${encodeURIComponent(this.selectedEnv)}`);
}
}
// #endregion DashboardHubModel

View File

@@ -0,0 +1,45 @@
<!-- #region EmptyState [C:2] [TYPE Component] [SEMANTICS empty-state, zero-state, placeholder, ui, atom] -->
<!-- @BRIEF Standardized empty/zero-state placeholder with icon, title, description, and optional CTA button. -->
<!-- @LAYER UI -->
<!-- @UX_STATE Empty -> Centered icon + message + optional action button rendered. -->
<!-- @RESTRICTION Icon is optional; falls back to a generic document icon. -->
<script lang="ts">
import { cn } from "$lib/utils.js";
import { Button } from "$lib/ui";
let {
icon: iconSlot = null,
title = "",
description = "",
actionLabel = "",
actionVariant = "primary",
onAction,
class: className = "",
} = $props();
</script>
<div class={cn("flex flex-col items-center justify-center py-12 px-4 text-center", className)}>
{#if iconSlot}
<div class="text-text-subtle mb-4">
{@render iconSlot()}
</div>
{:else}
<svg class="w-16 h-16 text-text-subtle mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
{/if}
{#if title}
<h3 class="text-lg font-semibold text-text mb-1">{title}</h3>
{/if}
{#if description}
<p class="text-sm text-text-muted max-w-md">{description}</p>
{/if}
{#if actionLabel && onAction}
<div class="mt-6">
<Button variant={actionVariant} onclick={onAction}>
{actionLabel}
</Button>
</div>
{/if}
</div>
<!-- #endregion EmptyState -->

View File

@@ -0,0 +1,68 @@
<!-- #region ColumnFilterPopover [C:3] [TYPE Component] [SEMANTICS dashboard, filter, popover, column] -->
<!-- @BRIEF Reusable column filter dropdown for dashboard grid — search input, select-all, clear, and checkbox list. -->
<!-- @LAYER UI -->
<!-- @UX_STATE Closed -> Popover hidden. -->
<!-- @UX_STATE Open -> Popover visible at positioned coordinates with search + options. -->
<script lang="ts">
import { t } from "$lib/i18n/index.svelte.js";
let {
column,
filterValues = $bindable(new Set<string>()),
searchText = $bindable(""),
options = [] as string[],
position = { left: 0, top: 0 },
widthPx = 256,
showSearch = true,
staticOptions = [] as string[],
onToggleFilter,
onClear,
} = $props<{
column: string;
filterValues: Set<string>;
searchText: string;
options: string[];
position: { left: number; top: number };
widthPx?: number;
showSearch?: boolean;
staticOptions?: string[];
onToggleFilter?: (value: string, checked: boolean) => void;
onClear?: () => void;
}>();
</script>
<div
class="fixed z-50 rounded-lg border border-border bg-surface-card shadow-lg p-3"
style="left: {position.left}px; top: {position.top}px; width: {widthPx}px;"
>
{#if showSearch}
<input
type="text"
class="w-full rounded border border-border-strong px-2 py-1 text-xs"
placeholder={$t.dashboard?.search}
bind:value={searchText}
/>
<div class="mt-2 flex items-center justify-between text-xs">
<button class="text-primary hover:underline" onclick={() => {
// Select all currently visible options
for (const v of options) filterValues.add(v);
}}>{$t.dashboard?.select_all}</button>
<button class="text-text-muted hover:underline" onclick={() => onClear?.()}>
{$t.common?.clear || "Clear"}
</button>
</div>
{/if}
<div class="mt-2 max-h-44 overflow-auto space-y-1 text-xs">
{#each (showSearch ? options : staticOptions) as value}
<label class="flex items-center gap-2">
<input
type="checkbox"
checked={filterValues.has(value)}
onchange={(event) => onToggleFilter?.(value, (event.target as HTMLInputElement).checked)}
/>
<span class="truncate">{value}</span>
</label>
{/each}
</div>
</div>
<!-- #endregion ColumnFilterPopover -->

View File

@@ -0,0 +1,193 @@
<!-- #region DashboardRow [C:3] [TYPE Component] [SEMANTICS dashboard, row, grid, git, validation] -->
<!-- @BRIEF Single row in the DashboardHub grid — checkbox, title, git status, validation indicator, actions, changed date, owners. -->
<!-- @LAYER UI -->
<script lang="ts">
import { t } from "$lib/i18n/index.svelte.js";
import DashboardMaintenanceBadge from "$lib/components/DashboardMaintenanceBadge.svelte";
import { formatDate } from "./dashboard-helpers.js";
let {
dashboard,
isSelected,
validationStatus,
validationStatusLoading,
isValidationPopoverOpen,
validationPopoverPosition,
onCheckboxChange,
onNavigate,
onToggleValidationPopover,
onAction,
onTaskStatusClick,
onGitInit,
onGitSync,
onGitCommit,
onGitPull,
onGitPush,
isGitBusy = false,
} = $props<{
dashboard: any;
isSelected: boolean;
validationStatus: any;
validationStatusLoading: boolean;
isValidationPopoverOpen: boolean;
validationPopoverPosition: { left: number; top: number };
onCheckboxChange: (e: Event) => void;
onNavigate: () => void;
onToggleValidationPopover: (e: Event) => void;
onAction: (action: string) => void;
onTaskStatusClick?: () => void;
onGitInit?: () => void;
onGitSync?: () => void;
onGitCommit?: () => void;
onGitPull?: () => void;
onGitPush?: () => void;
isGitBusy?: boolean;
}>();
</script>
<div
class="grid items-center gap-4 px-6 py-3 border-b border-border last:border-b-0 hover:bg-surface-muted transition-colors text-[13px]"
style="width: max(100%, 1460px); grid-template-columns: 40px minmax(250px,2.3fr) minmax(118px,0.9fr) minmax(70px,0.5fr) minmax(110px,0.7fr) minmax(280px,2fr) minmax(150px,1fr) 124px;"
>
<!-- Checkbox -->
<div>
<input type="checkbox" checked={isSelected} onchange={onCheckboxChange} />
</div>
<!-- Title -->
<div class="font-medium text-sm text-text">
<button
class="text-left text-primary hover:text-primary-hover hover:underline transition-colors"
onclick={onNavigate}
title={$t.dashboard?.open_overview}
>
{dashboard.title}
</button>
</div>
<!-- Git Status -->
<div>
<div class="flex flex-col gap-1">
<span class="inline-flex w-fit items-center rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide {dashboard.git?.hasRepo === null ? 'bg-surface-muted text-text-muted' : dashboard.git?.hasRepo ? 'bg-primary-light text-primary' : 'bg-surface-muted text-text'}">
{dashboard.git?.hasRepo === null ? $t.common?.loading || "Loading" : dashboard.git?.hasRepo ? $t.dashboard?.status_repo || "Repo" : $t.dashboard?.status_no_repo || "No Repo"}
</span>
{#if dashboard.git?.hasRepo}
<span class="inline-flex w-fit items-center rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide {dashboard.git?.hasChangesForCommit ? 'bg-yellow-100 text-yellow-800' : 'bg-green-100 text-green-800'}">
{dashboard.git?.hasChangesForCommit ? $t.dashboard?.status_changes || "Diff" : $t.dashboard?.status_no_changes || "Synced"}
</span>
{/if}
</div>
</div>
<!-- Validation Status Dot -->
<div class="relative">
{#if validationStatus}
{@const vs = validationStatus}
{@const dotClass = vs.status === "PASS" ? "bg-emerald-400" : vs.status === "WARN" ? "bg-amber-400" : vs.status === "FAIL" ? "bg-rose-400" : "bg-slate-300"}
<button
class="inline-flex h-5 w-5 items-center justify-center rounded-full border border-border-strong transition-colors hover:ring-2 hover:ring-border-strong"
onclick={onToggleValidationPopover}
title={vs.status || "UNKNOWN"}
aria-label={$t.dashboard?.validation_status || "Validation status"}
>
<span class="block h-3 w-3 rounded-full {dotClass}"></span>
</button>
{:else if validationStatusLoading}
<span class="inline-flex h-5 w-5 items-center justify-center">
<span class="block h-3 w-3 animate-pulse rounded-full bg-surface-muted"></span>
</span>
{:else}
<span class="text-text-subtle text-xs">-</span>
{/if}
<!-- Validation Popover -->
{#if isValidationPopoverOpen}
<div
class="fixed z-50 w-80 rounded-lg border border-border bg-surface-card shadow-lg"
style="left: {validationPopoverPosition.left}px; top: {validationPopoverPosition.top}px;"
onclick={(e) => e.stopPropagation()}
>
<div class="border-b border-border px-3 py-2 text-xs font-semibold uppercase tracking-wide text-text-muted">
{$t.dashboard?.validation_history || "Validation History"}
</div>
{#if validationStatus?.history?.length > 0}
<div class="max-h-48 overflow-y-auto">
{#each validationStatus.history.slice(0, 3) as run}
<div class="flex items-center justify-between px-3 py-2 text-xs border-b border-border last:border-b-0">
<div class="flex items-center gap-2">
<span class="block h-2.5 w-2.5 rounded-full {run.status === 'PASS' ? 'bg-emerald-400' : run.status === 'WARN' ? 'bg-amber-400' : run.status === 'FAIL' ? 'bg-rose-400' : 'bg-slate-300'}"></span>
<span class="text-text">{run.status || "UNKNOWN"}</span>
</div>
<div class="flex items-center gap-2">
<span class="text-text-subtle">{run.task_name || "-"}</span>
<span class="text-text-subtle">{formatDate(run.last_run_at)}</span>
</div>
</div>
{/each}
</div>
{:else}
<div class="px-3 py-3 text-xs text-text-subtle text-center">
{$t.dashboard?.no_validation_runs || "No validation runs yet"}
</div>
{/if}
<div class="border-t border-border px-3 py-2">
<a href={`/validation-tasks?search=${dashboard.id}`} class="text-xs text-primary hover:text-primary-hover hover:underline">
{$t.dashboard?.view_all_runs || "View all runs →"}
</a>
</div>
</div>
{/if}
</div>
<!-- Maintenance Badge -->
<div>
<DashboardMaintenanceBadge dashboardId={dashboard.id} />
</div>
<!-- Actions -->
<div class="flex items-center">
<div class="flex items-center gap-1.5">
<button
class="inline-flex h-8 w-8 items-center justify-center rounded-md border border-border bg-surface-card text-primary hover:bg-primary-light hover:border-primary-ring transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
onclick={() => onAction("migrate")}
title={$t.dashboard?.action_migrate}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
</button>
<button
class="inline-flex h-8 w-8 items-center justify-center rounded-md border border-border bg-surface-card text-warning hover:bg-warning-light hover:border-warning transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
onclick={() => onAction("backup")}
title={$t.dashboard?.action_backup}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7,10 12,15 17,10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
</button>
</div>
</div>
<!-- Changed On -->
<div class="text-xs text-text-muted">
{dashboard.changedOnLabel}
</div>
<!-- Owners -->
<div class="text-xs">
{#if dashboard.owners?.length > 0}
<div class="flex flex-wrap items-center gap-1">
{#each dashboard.owners as owner (owner)}
<span class="rounded bg-surface-muted px-2 py-0.5 text-xs text-text truncate max-w-[180px]" title={owner}>
{owner}
</span>
{/each}
</div>
{:else}
<div class="text-text-subtle">-</div>
{/if}
</div>
</div>
<!-- #endregion DashboardRow -->