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:
586
frontend/src/lib/models/DashboardHubModel.svelte.ts
Normal file
586
frontend/src/lib/models/DashboardHubModel.svelte.ts
Normal 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
|
||||
45
frontend/src/lib/ui/EmptyState.svelte
Normal file
45
frontend/src/lib/ui/EmptyState.svelte
Normal 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 -->
|
||||
68
frontend/src/routes/dashboards/ColumnFilterPopover.svelte
Normal file
68
frontend/src/routes/dashboards/ColumnFilterPopover.svelte
Normal 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 -->
|
||||
193
frontend/src/routes/dashboards/DashboardRow.svelte
Normal file
193
frontend/src/routes/dashboards/DashboardRow.svelte
Normal 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 -->
|
||||
Reference in New Issue
Block a user