2356 lines
87 KiB
Svelte
2356 lines
87 KiB
Svelte
<!-- [DEF:DashboardHub:Page] -->
|
|
<script>
|
|
/**
|
|
* @TIER: CRITICAL
|
|
* @PURPOSE: Dashboard Hub - Central hub for managing dashboards with Git status and task actions
|
|
* @LAYER: UI
|
|
* @RELATION: BINDS_TO -> sidebarStore, taskDrawerStore
|
|
* @INVARIANT: Always shows dashboards for the active environment from context store
|
|
*
|
|
* @UX_STATE: Loading -> Shows skeleton loader
|
|
* @UX_STATE: Loaded -> Shows dashboard grid with status badges
|
|
* @UX_STATE: Error -> Shows error banner with retry button
|
|
* @UX_STATE: Selecting -> Checkboxes checked, floating action panel appears
|
|
* @UX_STATE: BulkAction-Modal -> Migration or Backup modal open
|
|
* @UX_FEEDBACK: Floating panel slides up from bottom when items selected
|
|
* @UX_RECOVERY: Refresh button reloads dashboard list
|
|
*
|
|
* @TEST_CONTRACT Page_DashboardHub ->
|
|
* {
|
|
* required_props: {},
|
|
* optional_props: {},
|
|
* invariants: [
|
|
* "Loads specific dashboards for currently selected environment store context",
|
|
* "Reflects Git sync status explicitly via row badges",
|
|
* "Submits migration action carrying db_mappings safely"
|
|
* ]
|
|
* }
|
|
* @TEST_FIXTURE init_state -> {}
|
|
* @TEST_EDGE server_error -> shows error block and retry button
|
|
* @TEST_EDGE bulk_git_actions -> verifies modal functionality when triggered
|
|
* @TEST_INVARIANT rendering_and_selection -> verifies: [init_state]
|
|
*/
|
|
|
|
import { onMount } from "svelte";
|
|
import { goto } from "$app/navigation";
|
|
import { t } from "$lib/i18n";
|
|
import { openDrawerForTask } from "$lib/stores/taskDrawer.js";
|
|
import { api } from "$lib/api.js";
|
|
import { debounce } from "$lib/utils/debounce.js";
|
|
import { addToast } from "$lib/toasts.js";
|
|
import { gitService } from "../../services/gitService.js";
|
|
import MappingTable from "$components/MappingTable.svelte";
|
|
import {
|
|
environmentContextStore,
|
|
initializeEnvironmentContext,
|
|
} from "$lib/stores/environmentContext.js";
|
|
|
|
// State
|
|
let selectedEnv = null;
|
|
let allDashboards = [];
|
|
let dashboards = [];
|
|
let filteredDashboards = [];
|
|
let isLoading = true;
|
|
let error = null;
|
|
|
|
// Pagination state
|
|
let currentPage = 1;
|
|
let pageSize = 10;
|
|
let totalPages = 1;
|
|
let total = 0;
|
|
|
|
// Selection state
|
|
let selectedIds = new Set();
|
|
let isAllSelected = false;
|
|
let isAllVisibleSelected = false;
|
|
|
|
// 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;
|
|
let showBackupModal = false;
|
|
let targetEnvId = null;
|
|
let backupSchedule = "";
|
|
let isSubmittingMigrate = false;
|
|
let isSubmittingBackup = false;
|
|
let dbMappings = {};
|
|
let availableDbMappings = [];
|
|
let sourceDatabases = [];
|
|
let targetDatabases = [];
|
|
let isEditingMappings = false;
|
|
let useDbMappings = true;
|
|
let fixCrossFilters = true;
|
|
|
|
// Individual action dropdown state
|
|
let openActionDropdown = null; // stores dashboard ID
|
|
|
|
// Validation state
|
|
let validatingIds = new Set();
|
|
let gitBusyIds = new Set();
|
|
let cachedGitConfigs = [];
|
|
|
|
// Environment options - will be loaded from API
|
|
let environments = [];
|
|
|
|
// Debounced search function
|
|
const debouncedSearch = debounce((query) => {
|
|
searchQuery = query;
|
|
currentPage = 1;
|
|
applyGridTransforms();
|
|
}, 300);
|
|
|
|
// Load environments and dashboards on mount
|
|
onMount(async () => {
|
|
await initializeEnvironmentContext();
|
|
if (!selectedEnv && $environmentContextStore?.selectedEnvId) {
|
|
selectedEnv = $environmentContextStore.selectedEnvId;
|
|
}
|
|
await loadDashboards();
|
|
});
|
|
|
|
// Handle document click to close dropdowns
|
|
function handleDocumentClick(event) {
|
|
if (!event.target.closest(".action-dropdown")) {
|
|
closeActionDropdown();
|
|
}
|
|
if (!event.target.closest(".column-filter")) {
|
|
openFilterColumn = null;
|
|
}
|
|
}
|
|
|
|
// Add document click listener
|
|
if (typeof document !== "undefined") {
|
|
document.addEventListener("click", handleDocumentClick);
|
|
}
|
|
|
|
// 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 firstResponse = await api.getDashboards(selectedEnv, {
|
|
page: 1,
|
|
page_size: 100,
|
|
});
|
|
|
|
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 = new Set(
|
|
Array.from(selectedIds).filter((id) =>
|
|
allDashboards.some((dashboard) => dashboard.id === id),
|
|
),
|
|
);
|
|
applyGridTransforms();
|
|
updateSelectionState();
|
|
} catch (err) {
|
|
error = err.message || $t.dashboard?.load_failed;
|
|
console.error("[DashboardHub][Coherence:Failed]", err);
|
|
} finally {
|
|
isLoading = false;
|
|
}
|
|
}
|
|
|
|
// Handle search input
|
|
function handleSearch(event) {
|
|
debouncedSearch(event.target.value);
|
|
}
|
|
|
|
// Handle page change
|
|
function handlePageChange(page) {
|
|
currentPage = page;
|
|
applyGridTransforms();
|
|
}
|
|
|
|
// Handle page size change
|
|
function handlePageSizeChange(event) {
|
|
pageSize = parseInt(event.target.value);
|
|
currentPage = 1;
|
|
applyGridTransforms();
|
|
}
|
|
|
|
// Update selection state based on current selection
|
|
function updateSelectionState() {
|
|
const visibleCount = dashboards.length;
|
|
const totalCount = filteredDashboards.length;
|
|
|
|
isAllSelected = selectedIds.size === totalCount && totalCount > 0;
|
|
isAllVisibleSelected =
|
|
selectedIds.size === visibleCount && visibleCount > 0;
|
|
}
|
|
|
|
// Handle checkbox change for individual dashboard
|
|
function handleCheckboxChange(dashboard, event) {
|
|
if (event.target.checked) {
|
|
selectedIds.add(dashboard.id);
|
|
} else {
|
|
selectedIds.delete(dashboard.id);
|
|
}
|
|
selectedIds = selectedIds; // Trigger reactivity
|
|
updateSelectionState();
|
|
}
|
|
|
|
// Handle select all
|
|
function handleSelectAll() {
|
|
if (isAllSelected) {
|
|
selectedIds.clear();
|
|
} else {
|
|
filteredDashboards.forEach((d) => selectedIds.add(d.id));
|
|
}
|
|
selectedIds = selectedIds; // Trigger reactivity
|
|
updateSelectionState();
|
|
}
|
|
|
|
// Handle select visible
|
|
function handleSelectVisible() {
|
|
if (isAllVisibleSelected) {
|
|
dashboards.forEach((d) => selectedIds.delete(d.id));
|
|
} else {
|
|
dashboards.forEach((d) => selectedIds.add(d.id));
|
|
}
|
|
selectedIds = selectedIds; // Trigger reactivity
|
|
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();
|
|
openActionDropdown =
|
|
openActionDropdown === dashboardId ? null : dashboardId;
|
|
}
|
|
|
|
// Close action dropdown when clicking outside
|
|
function closeActionDropdown() {
|
|
openActionDropdown = null;
|
|
}
|
|
|
|
// Handle action click
|
|
async function handleAction(dashboard, action) {
|
|
console.log(
|
|
`[DashboardHub][Action] ${action} on dashboard ${dashboard.title}`,
|
|
);
|
|
closeActionDropdown();
|
|
|
|
try {
|
|
if (action === "migrate") {
|
|
// Select only this dashboard for migration
|
|
selectedIds.clear();
|
|
selectedIds.add(dashboard.id);
|
|
showMigrateModal = true;
|
|
targetEnvId = null;
|
|
dbMappings = {};
|
|
availableDbMappings = []; // Reset mappings until target env is selected
|
|
sourceDatabases = [];
|
|
targetDatabases = [];
|
|
isEditingMappings = false;
|
|
} else if (action === "backup") {
|
|
// Select only this dashboard for backup
|
|
selectedIds.clear();
|
|
selectedIds.add(dashboard.id);
|
|
showBackupModal = true;
|
|
backupSchedule = "";
|
|
// Force reactivity by updating selectedIds
|
|
selectedIds = new Set(selectedIds);
|
|
} else if (action === "commit") {
|
|
// Open commit modal or trigger commit task
|
|
// For now, we'll trigger a commit via git API
|
|
const response = await api.postApi(
|
|
`/git/repositories/${dashboard.id}/commit`,
|
|
{
|
|
message: `Update dashboard ${dashboard.title}`,
|
|
},
|
|
);
|
|
console.log("[DashboardHub][Action] Commit created:", response);
|
|
}
|
|
} catch (err) {
|
|
console.error("[DashboardHub][Coherence:Failed]", err);
|
|
// Error toast is already shown by api.postApi
|
|
}
|
|
}
|
|
|
|
// Handle validate - LLM dashboard validation
|
|
async function handleValidate(dashboard) {
|
|
if (validatingIds.has(dashboard.id)) return;
|
|
|
|
validatingIds.add(dashboard.id);
|
|
validatingIds = new Set(validatingIds); // Trigger reactivity
|
|
|
|
closeActionDropdown();
|
|
|
|
try {
|
|
const response = await api.postApi("/tasks", {
|
|
plugin_id: "llm_dashboard_validation",
|
|
params: {
|
|
dashboard_id: dashboard.id.toString(),
|
|
environment_id: selectedEnv,
|
|
},
|
|
});
|
|
|
|
console.log("[DashboardHub][Action] Validation task started:", response);
|
|
|
|
// Open task drawer if task was created
|
|
if (response.task_id || response.id) {
|
|
const taskId = response.task_id || response.id;
|
|
openDrawerForTask(taskId);
|
|
}
|
|
} catch (err) {
|
|
console.error("[DashboardHub][Coherence:Failed] Validation failed:", err);
|
|
alert(
|
|
$t.dashboard?.validation_start_failed +
|
|
": " +
|
|
(err.message || $t.dashboard?.unknown_error),
|
|
);
|
|
} finally {
|
|
validatingIds.delete(dashboard.id);
|
|
validatingIds = new Set(validatingIds);
|
|
}
|
|
}
|
|
|
|
// Handle target environment change
|
|
async function handleTargetEnvChange(event) {
|
|
targetEnvId = event.target.value;
|
|
if (targetEnvId) {
|
|
await Promise.all([loadDbMappings(), loadDatabases()]);
|
|
} else {
|
|
availableDbMappings = [];
|
|
dbMappings = {};
|
|
sourceDatabases = [];
|
|
targetDatabases = [];
|
|
}
|
|
}
|
|
|
|
// Load databases for both environments
|
|
async function loadDatabases() {
|
|
if (!selectedEnv || !targetEnvId) return;
|
|
try {
|
|
const [sourceRes, targetRes] = await Promise.all([
|
|
api.getEnvironmentDatabases(selectedEnv),
|
|
api.getEnvironmentDatabases(targetEnvId),
|
|
]);
|
|
sourceDatabases = sourceRes || [];
|
|
targetDatabases = targetRes || [];
|
|
} catch (err) {
|
|
console.error(
|
|
"[DashboardHub][Coherence:Failed] Failed to load databases:",
|
|
err,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Handle mapping update from MappingTable
|
|
function handleMappingUpdate(event) {
|
|
const { sourceUuid, targetUuid } = event.detail;
|
|
dbMappings[sourceUuid] = targetUuid;
|
|
dbMappings = dbMappings; // Trigger reactivity
|
|
}
|
|
|
|
// Load database mappings for migration modal
|
|
async function loadDbMappings() {
|
|
if (!selectedEnv || !targetEnvId) return;
|
|
|
|
try {
|
|
console.log(
|
|
`[DashboardHub][Action] Loading DB mappings from ${selectedEnv} to ${targetEnvId}`,
|
|
);
|
|
const response = await api.getDatabaseMappings(selectedEnv, targetEnvId);
|
|
availableDbMappings = response.mappings || [];
|
|
console.log(
|
|
`[DashboardHub][Action] Loaded mappings:`,
|
|
availableDbMappings,
|
|
);
|
|
|
|
// Auto-select mappings with high confidence
|
|
dbMappings = {};
|
|
availableDbMappings.forEach((m) => {
|
|
if (m.confidence > 0.8) {
|
|
dbMappings[m.source_db_uuid] = m.target_db_uuid;
|
|
}
|
|
});
|
|
} catch (err) {
|
|
console.error(
|
|
"[DashboardHub][Coherence:Failed] Failed to load DB mappings:",
|
|
err,
|
|
);
|
|
availableDbMappings = [];
|
|
}
|
|
}
|
|
|
|
// Handle bulk migrate
|
|
async function handleBulkMigrate() {
|
|
if (isSubmittingMigrate) return;
|
|
if (selectedIds.size === 0) return;
|
|
if (!targetEnvId) {
|
|
alert($t.dashboard?.target_env_required);
|
|
return;
|
|
}
|
|
|
|
isSubmittingMigrate = true;
|
|
try {
|
|
const response = await api.postApi("/dashboards/migrate", {
|
|
source_env_id: selectedEnv,
|
|
target_env_id: targetEnvId,
|
|
dashboard_ids: Array.from(selectedIds),
|
|
db_mappings: useDbMappings ? dbMappings : {},
|
|
replace_db_config: useDbMappings && Object.keys(dbMappings).length > 0,
|
|
fix_cross_filters: fixCrossFilters,
|
|
});
|
|
console.log(
|
|
"[DashboardHub][Action] Bulk migration task created:",
|
|
response.task_id,
|
|
);
|
|
|
|
// Close modal and open task drawer
|
|
showMigrateModal = false;
|
|
selectedIds.clear();
|
|
updateSelectionState();
|
|
|
|
if (response.task_id) {
|
|
const taskId =
|
|
typeof response.task_id === "object"
|
|
? response.task_id.id || response.task_id.task_id
|
|
: response.task_id;
|
|
openDrawerForTask(taskId);
|
|
}
|
|
} catch (err) {
|
|
console.error("[DashboardHub][Coherence:Failed]", err);
|
|
alert($t.dashboard?.migration_task_failed);
|
|
} finally {
|
|
isSubmittingMigrate = false;
|
|
}
|
|
}
|
|
|
|
// Handle bulk backup
|
|
async function handleBulkBackup() {
|
|
if (isSubmittingBackup) return;
|
|
if (selectedIds.size === 0) return;
|
|
|
|
isSubmittingBackup = true;
|
|
try {
|
|
const response = await api.postApi("/dashboards/backup", {
|
|
env_id: selectedEnv,
|
|
dashboard_ids: Array.from(selectedIds),
|
|
schedule: backupSchedule || undefined,
|
|
});
|
|
console.log(
|
|
"[DashboardHub][Action] Bulk backup task created:",
|
|
response.task_id,
|
|
);
|
|
|
|
// Close modal and open task drawer
|
|
showBackupModal = false;
|
|
selectedIds.clear();
|
|
updateSelectionState();
|
|
|
|
if (response.task_id) {
|
|
const taskId =
|
|
typeof response.task_id === "object"
|
|
? response.task_id.id || response.task_id.task_id
|
|
: response.task_id;
|
|
openDrawerForTask(taskId);
|
|
}
|
|
} catch (err) {
|
|
console.error("[DashboardHub][Coherence:Failed]", err);
|
|
alert($t.dashboard?.backup_task_failed);
|
|
} finally {
|
|
isSubmittingBackup = false;
|
|
}
|
|
}
|
|
|
|
// Handle task status click - open Task Drawer
|
|
function handleTaskStatusClick(dashboard) {
|
|
if (dashboard.lastTask?.id) {
|
|
console.log(
|
|
`[DashboardHub][Action] Open task drawer for task ${dashboard.lastTask.id}`,
|
|
);
|
|
openDrawerForTask(dashboard.lastTask.id);
|
|
}
|
|
}
|
|
|
|
function navigateToDashboardDetail(dashboardId) {
|
|
goto(
|
|
`/dashboards/${dashboardId}?env_id=${encodeURIComponent(selectedEnv)}`,
|
|
);
|
|
}
|
|
|
|
// Get status badge class
|
|
function getStatusBadgeClass(status) {
|
|
if (!status) return "bg-gray-100 text-gray-800";
|
|
switch (status.toLowerCase()) {
|
|
case "ok":
|
|
return "bg-green-100 text-green-800";
|
|
case "diff":
|
|
return "bg-yellow-100 text-yellow-800";
|
|
default:
|
|
return "bg-gray-100 text-gray-800";
|
|
}
|
|
}
|
|
|
|
function isGitBusy(dashboardId) {
|
|
return gitBusyIds.has(dashboardId);
|
|
}
|
|
|
|
function setGitBusy(dashboardId, busy) {
|
|
if (busy) {
|
|
gitBusyIds.add(dashboardId);
|
|
} else {
|
|
gitBusyIds.delete(dashboardId);
|
|
}
|
|
gitBusyIds = new Set(gitBusyIds);
|
|
}
|
|
|
|
async function ensureGitConfigs() {
|
|
if (cachedGitConfigs.length > 0) return cachedGitConfigs;
|
|
cachedGitConfigs = await gitService.getConfigs();
|
|
return cachedGitConfigs;
|
|
}
|
|
|
|
function updateDashboardGitState(dashboardId, nextGit) {
|
|
dashboards = dashboards.map((dashboard) =>
|
|
dashboard.id === dashboardId
|
|
? { ...dashboard, git: { ...dashboard.git, ...nextGit } }
|
|
: dashboard,
|
|
);
|
|
}
|
|
|
|
async function refreshDashboardGitState(dashboardId) {
|
|
try {
|
|
const status = await gitService.getStatus(dashboardId);
|
|
updateDashboardGitState(dashboardId, {
|
|
status: status?.is_dirty ? "diff" : "ok",
|
|
branch: status?.current_branch || null,
|
|
hasRepo: true,
|
|
hasChangesForCommit: Boolean(status?.is_dirty),
|
|
});
|
|
} catch (_err) {
|
|
updateDashboardGitState(dashboardId, {
|
|
status: "no_repo",
|
|
branch: null,
|
|
hasRepo: false,
|
|
hasChangesForCommit: false,
|
|
});
|
|
}
|
|
}
|
|
|
|
async function handleGitInit(dashboard) {
|
|
setGitBusy(dashboard.id, true);
|
|
try {
|
|
const configs = await 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 refreshDashboardGitState(dashboard.id);
|
|
} catch (err) {
|
|
addToast(err?.message || "Git init failed", "error");
|
|
} finally {
|
|
setGitBusy(dashboard.id, false);
|
|
}
|
|
}
|
|
|
|
async function handleGitSync(dashboard) {
|
|
setGitBusy(dashboard.id, true);
|
|
try {
|
|
await gitService.sync(dashboard.id, selectedEnv || null);
|
|
addToast($t.git?.sync_success || "Synced", "success");
|
|
await refreshDashboardGitState(dashboard.id);
|
|
} catch (err) {
|
|
addToast(err?.message || "Git sync failed", "error");
|
|
} finally {
|
|
setGitBusy(dashboard.id, false);
|
|
}
|
|
}
|
|
|
|
async function handleGitCommit(dashboard) {
|
|
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;
|
|
|
|
setGitBusy(dashboard.id, true);
|
|
try {
|
|
await gitService.commit(dashboard.id, message.trim());
|
|
addToast($t.git?.commit_success || "Committed", "success");
|
|
await refreshDashboardGitState(dashboard.id);
|
|
} catch (err) {
|
|
addToast(err?.message || "Git commit failed", "error");
|
|
} finally {
|
|
setGitBusy(dashboard.id, false);
|
|
}
|
|
}
|
|
|
|
async function handleGitPull(dashboard) {
|
|
if (!dashboard.git?.hasRepo) return;
|
|
setGitBusy(dashboard.id, true);
|
|
try {
|
|
await gitService.pull(dashboard.id);
|
|
addToast($t.git?.pull_success || "Pulled", "success");
|
|
await refreshDashboardGitState(dashboard.id);
|
|
} catch (err) {
|
|
addToast(err?.message || "Git pull failed", "error");
|
|
} finally {
|
|
setGitBusy(dashboard.id, false);
|
|
}
|
|
}
|
|
|
|
async function handleGitPush(dashboard) {
|
|
if (!dashboard.git?.hasRepo) return;
|
|
setGitBusy(dashboard.id, true);
|
|
try {
|
|
await gitService.push(dashboard.id);
|
|
addToast($t.git?.push_success || "Pushed", "success");
|
|
await refreshDashboardGitState(dashboard.id);
|
|
} catch (err) {
|
|
addToast(err?.message || "Git push failed", "error");
|
|
} finally {
|
|
setGitBusy(dashboard.id, false);
|
|
}
|
|
}
|
|
|
|
// Get task status icon
|
|
function getTaskStatusIcon(status) {
|
|
if (!status) return "";
|
|
switch (status.toLowerCase()) {
|
|
case "running":
|
|
return '<svg class="animate-spin" width="16" height="16" viewBox="0 0 24 24"><path fill="currentColor" d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2zm0 18a8 8 0 1 1 8-8 8 0 0 1-8 8z"/></svg>';
|
|
case "pending":
|
|
return '<svg class="animate-spin" width="16" height="16" viewBox="0 0 24 24"><path fill="currentColor" d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2zm0 18a8 8 0 1 1 8-8 8 0 0 1-8 8z"/></svg>';
|
|
case "success":
|
|
return '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/></svg>';
|
|
case "failed":
|
|
case "error":
|
|
return '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/></svg>';
|
|
case "awaiting_input":
|
|
case "waiting_input":
|
|
return '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/></svg>';
|
|
default:
|
|
return "";
|
|
}
|
|
}
|
|
|
|
// Generate pagination range with ellipsis
|
|
function getPaginationRange(current, total, maxVisible = 5) {
|
|
if (total <= maxVisible) {
|
|
return Array.from({ length: total }, (_, i) => i + 1);
|
|
}
|
|
|
|
const range = [];
|
|
const halfVisible = Math.floor(maxVisible / 2);
|
|
|
|
let start = Math.max(1, current - halfVisible);
|
|
let end = Math.min(total, current + halfVisible);
|
|
|
|
if (current <= halfVisible + 1) {
|
|
end = maxVisible;
|
|
} else if (current >= total - halfVisible) {
|
|
start = total - maxVisible + 1;
|
|
}
|
|
|
|
// Add first page and ellipsis if needed
|
|
if (start > 1) {
|
|
range.push(1);
|
|
if (start > 2) {
|
|
range.push("...");
|
|
}
|
|
}
|
|
|
|
// Add visible pages
|
|
for (let i = start; i <= end; i++) {
|
|
range.push(i);
|
|
}
|
|
|
|
// Add ellipsis and last page if needed
|
|
if (end < total) {
|
|
if (end < total - 1) {
|
|
range.push("...");
|
|
}
|
|
range.push(total);
|
|
}
|
|
|
|
return range;
|
|
}
|
|
|
|
$: environments = $environmentContextStore?.environments || [];
|
|
$: if (
|
|
$environmentContextStore?.selectedEnvId &&
|
|
selectedEnv !== $environmentContextStore.selectedEnvId
|
|
) {
|
|
selectedEnv = $environmentContextStore.selectedEnvId;
|
|
currentPage = 1;
|
|
selectedIds.clear();
|
|
loadDashboards();
|
|
}
|
|
</script>
|
|
|
|
<div class="mx-auto w-full max-w-7xl space-y-6">
|
|
<!-- Header -->
|
|
<div class="flex items-center justify-between">
|
|
<h1 class="text-2xl font-bold text-gray-900">
|
|
{$t.nav?.dashboards}
|
|
</h1>
|
|
<div class="flex items-center space-x-4">
|
|
<button
|
|
class="inline-flex items-center justify-center rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-hover"
|
|
on:click={loadDashboards}
|
|
>
|
|
{$t.common?.refresh}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Error Banner -->
|
|
{#if error}
|
|
<div
|
|
class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4 flex items-center justify-between"
|
|
>
|
|
<span>{error}</span>
|
|
<button
|
|
class="px-4 py-2 bg-destructive text-white rounded hover:bg-destructive-hover transition-colors"
|
|
on:click={loadDashboards}
|
|
>
|
|
{$t.common?.retry}
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Loading State -->
|
|
{#if isLoading}
|
|
<div class="bg-white border border-slate-200 rounded-xl shadow-sm overflow-hidden">
|
|
<div
|
|
class="grid min-w-[1520px] gap-4 px-6 py-3 bg-slate-50 border-b border-slate-200 font-semibold text-[11px] uppercase tracking-wide text-slate-600"
|
|
style="grid-template-columns: 40px minmax(250px,2.3fr) minmax(118px,0.9fr) minmax(150px,1fr) minmax(150px,1fr) minmax(300px,2.1fr) minmax(280px,2fr);"
|
|
>
|
|
<div class="h-4 rounded bg-gray-200 animate-pulse"></div>
|
|
<div class="h-4 rounded bg-gray-200 animate-pulse"></div>
|
|
<div class="h-4 rounded bg-gray-200 animate-pulse"></div>
|
|
<div class="h-4 rounded bg-gray-200 animate-pulse"></div>
|
|
<div class="h-4 rounded bg-gray-200 animate-pulse"></div>
|
|
<div class="h-4 rounded bg-gray-200 animate-pulse"></div>
|
|
<div class="h-4 rounded bg-gray-200 animate-pulse"></div>
|
|
</div>
|
|
{#each Array(5) as _}
|
|
<div
|
|
class="grid min-w-[1520px] gap-4 px-6 py-4 border-b border-slate-200 last:border-b-0 hover:bg-slate-50 transition-colors"
|
|
style="grid-template-columns: 40px minmax(250px,2.3fr) minmax(118px,0.9fr) minmax(150px,1fr) minmax(150px,1fr) minmax(300px,2.1fr) minmax(280px,2fr);"
|
|
>
|
|
<div class="h-4 rounded bg-gray-200 animate-pulse"></div>
|
|
<div class="h-4 rounded bg-gray-200 animate-pulse"></div>
|
|
<div class="h-4 rounded bg-gray-200 animate-pulse"></div>
|
|
<div class="h-4 rounded bg-gray-200 animate-pulse"></div>
|
|
<div class="h-4 rounded bg-gray-200 animate-pulse"></div>
|
|
<div class="h-4 rounded bg-gray-200 animate-pulse"></div>
|
|
<div class="h-4 rounded bg-gray-200 animate-pulse"></div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{:else if dashboards.length === 0}
|
|
<!-- Empty State -->
|
|
<div class="py-12 text-center text-gray-500">
|
|
<svg
|
|
class="w-16 h-16 mx-auto mb-4 text-gray-400"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
>
|
|
<path d="M3 3h18v18H3V3zm16 16V5H5v14h14z" />
|
|
</svg>
|
|
<p>{$t.dashboard?.empty}</p>
|
|
</div>
|
|
{:else}
|
|
<!-- Toolbar -->
|
|
<div class="flex items-center justify-between mb-4 gap-4">
|
|
<div class="flex items-center gap-2">
|
|
<button
|
|
class="px-3 py-1 text-sm border border-gray-300 rounded hover:bg-gray-100 transition-colors"
|
|
on:click={handleSelectAll}
|
|
disabled={total === 0}
|
|
>
|
|
{isAllSelected
|
|
? $t.dashboard?.deselect_all
|
|
: $t.dashboard?.select_all}
|
|
</button>
|
|
<button
|
|
class="px-3 py-1 text-sm border border-gray-300 rounded hover:bg-gray-100 transition-colors"
|
|
on:click={handleSelectVisible}
|
|
disabled={dashboards.length === 0}
|
|
>
|
|
{isAllVisibleSelected
|
|
? $t.dashboard?.deselect_visible
|
|
: $t.dashboard?.select_visible}
|
|
</button>
|
|
{#if selectedIds.size > 0}
|
|
<span class="text-sm text-gray-600">
|
|
{($t.dashboard?.selected_count).replace(
|
|
"{count}",
|
|
String(selectedIds.size),
|
|
)}
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
<div>
|
|
<input
|
|
type="text"
|
|
class="px-4 py-2 border border-gray-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
placeholder={$t.dashboard?.search}
|
|
on:input={handleSearch}
|
|
value={searchQuery}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Dashboard Grid -->
|
|
<div class="bg-white border border-slate-200 rounded-xl shadow-sm">
|
|
<div class="overflow-x-auto">
|
|
<!-- Grid Header -->
|
|
<div
|
|
class="grid min-w-[1520px] gap-4 px-6 py-3 bg-slate-50 border-b border-slate-200 font-semibold text-[11px] uppercase tracking-wide text-slate-600"
|
|
style="grid-template-columns: 40px minmax(250px,2.3fr) minmax(118px,0.9fr) minmax(150px,1fr) minmax(150px,1fr) minmax(300px,2.1fr) minmax(280px,2fr);"
|
|
>
|
|
<div></div>
|
|
<div class="flex items-center gap-2">
|
|
<button class="hover:text-gray-900 transition-colors" on:click={() => handleSort("title")}>
|
|
{$t.dashboard?.title} {getSortIndicator("title")}
|
|
</button>
|
|
<div class="relative column-filter">
|
|
<button
|
|
class="rounded border px-1.5 py-0.5 text-xs transition-colors {hasColumnFilter('title') ? 'border-blue-400 text-blue-700 bg-blue-50' : 'border-gray-300 text-gray-500 hover:text-gray-700'}"
|
|
on:click={() => toggleFilterDropdown("title")}
|
|
title={$t.dashboard?.column_filter}
|
|
>
|
|
▾
|
|
</button>
|
|
{#if openFilterColumn === "title"}
|
|
<div class="absolute z-30 mt-2 w-64 rounded-lg border border-gray-200 bg-white shadow-lg p-3">
|
|
<input
|
|
type="text"
|
|
class="w-full rounded border border-gray-300 px-2 py-1 text-xs"
|
|
placeholder={$t.dashboard?.search}
|
|
value={columnFilterSearch.title}
|
|
on:input={(event) => updateColumnFilterSearch("title", event.target.value)}
|
|
/>
|
|
<div class="mt-2 flex items-center justify-between text-xs">
|
|
<button class="text-blue-600 hover:underline" on:click={() => selectAllColumnFilterValues("title")}>{$t.dashboard?.select_all}</button>
|
|
<button class="text-gray-500 hover:underline" on:click={() => clearColumnFilter("title")}>{$t.common?.clear || "Clear"}</button>
|
|
</div>
|
|
<div class="mt-2 max-h-44 overflow-auto space-y-1 text-xs">
|
|
{#each getVisibleFilterOptions("title") as value}
|
|
<label class="flex items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
checked={columnFilters.title.has(value)}
|
|
on:change={(event) => toggleFilterValue("title", value, event.target.checked)}
|
|
/>
|
|
<span class="truncate">{value}</span>
|
|
</label>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<button class="hover:text-gray-900 transition-colors" on:click={() => handleSort("git_status")}>
|
|
GIT {getSortIndicator("git_status")}
|
|
</button>
|
|
<div class="relative column-filter">
|
|
<button
|
|
class="rounded border px-1.5 py-0.5 text-xs transition-colors {hasColumnFilter('git_status') ? 'border-blue-400 text-blue-700 bg-blue-50' : 'border-gray-300 text-gray-500 hover:text-gray-700'}"
|
|
on:click={() => toggleFilterDropdown("git_status")}
|
|
title={$t.dashboard?.column_filter}
|
|
>
|
|
▾
|
|
</button>
|
|
{#if openFilterColumn === "git_status"}
|
|
<div class="absolute z-30 mt-2 w-64 rounded-lg border border-gray-200 bg-white shadow-lg p-3">
|
|
<input
|
|
type="text"
|
|
class="w-full rounded border border-gray-300 px-2 py-1 text-xs"
|
|
placeholder={$t.dashboard?.search}
|
|
value={columnFilterSearch.git_status}
|
|
on:input={(event) => updateColumnFilterSearch("git_status", event.target.value)}
|
|
/>
|
|
<div class="mt-2 flex items-center justify-between text-xs">
|
|
<button class="text-blue-600 hover:underline" on:click={() => selectAllColumnFilterValues("git_status")}>{$t.dashboard?.select_all}</button>
|
|
<button class="text-gray-500 hover:underline" on:click={() => clearColumnFilter("git_status")}>{$t.common?.clear || "Clear"}</button>
|
|
</div>
|
|
<div class="mt-2 max-h-44 overflow-auto space-y-1 text-xs">
|
|
{#each getVisibleFilterOptions("git_status") as value}
|
|
<label class="flex items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
checked={columnFilters.git_status.has(value)}
|
|
on:change={(event) => toggleFilterValue("git_status", value, event.target.checked)}
|
|
/>
|
|
<span class="truncate">{value}</span>
|
|
</label>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<button class="hover:text-gray-900 transition-colors" on:click={() => handleSort("llm_status")}>
|
|
{$t.dashboard?.llm_status} {getSortIndicator("llm_status")}
|
|
</button>
|
|
<div class="relative column-filter">
|
|
<button
|
|
class="rounded border px-1.5 py-0.5 text-xs transition-colors {hasColumnFilter('llm_status') ? 'border-blue-400 text-blue-700 bg-blue-50' : 'border-gray-300 text-gray-500 hover:text-gray-700'}"
|
|
on:click={() => toggleFilterDropdown("llm_status")}
|
|
title={$t.dashboard?.column_filter}
|
|
>
|
|
▾
|
|
</button>
|
|
{#if openFilterColumn === "llm_status"}
|
|
<div class="absolute z-30 mt-2 w-64 rounded-lg border border-gray-200 bg-white shadow-lg p-3">
|
|
<input
|
|
type="text"
|
|
class="w-full rounded border border-gray-300 px-2 py-1 text-xs"
|
|
placeholder={$t.dashboard?.search}
|
|
value={columnFilterSearch.llm_status}
|
|
on:input={(event) => updateColumnFilterSearch("llm_status", event.target.value)}
|
|
/>
|
|
<div class="mt-2 flex items-center justify-between text-xs">
|
|
<button class="text-blue-600 hover:underline" on:click={() => selectAllColumnFilterValues("llm_status")}>{$t.dashboard?.select_all}</button>
|
|
<button class="text-gray-500 hover:underline" on:click={() => clearColumnFilter("llm_status")}>{$t.common?.clear || "Clear"}</button>
|
|
</div>
|
|
<div class="mt-2 max-h-44 overflow-auto space-y-1 text-xs">
|
|
{#each getVisibleFilterOptions("llm_status") as value}
|
|
<label class="flex items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
checked={columnFilters.llm_status.has(value)}
|
|
on:change={(event) => toggleFilterValue("llm_status", value, event.target.checked)}
|
|
/>
|
|
<span class="truncate">{value}</span>
|
|
</label>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<button class="hover:text-gray-900 transition-colors" on:click={() => handleSort("changed_on")}>
|
|
{$t.dashboard?.changed_on} {getSortIndicator("changed_on")}
|
|
</button>
|
|
<div class="relative column-filter">
|
|
<button
|
|
class="rounded border px-1.5 py-0.5 text-xs transition-colors {hasColumnFilter('changed_on') ? 'border-blue-400 text-blue-700 bg-blue-50' : 'border-gray-300 text-gray-500 hover:text-gray-700'}"
|
|
on:click={() => toggleFilterDropdown("changed_on")}
|
|
title={$t.dashboard?.column_filter}
|
|
>
|
|
▾
|
|
</button>
|
|
{#if openFilterColumn === "changed_on"}
|
|
<div class="absolute z-30 mt-2 w-64 rounded-lg border border-gray-200 bg-white shadow-lg p-3">
|
|
<input
|
|
type="text"
|
|
class="w-full rounded border border-gray-300 px-2 py-1 text-xs"
|
|
placeholder={$t.dashboard?.search}
|
|
value={columnFilterSearch.changed_on}
|
|
on:input={(event) => updateColumnFilterSearch("changed_on", event.target.value)}
|
|
/>
|
|
<div class="mt-2 flex items-center justify-between text-xs">
|
|
<button class="text-blue-600 hover:underline" on:click={() => selectAllColumnFilterValues("changed_on")}>{$t.dashboard?.select_all}</button>
|
|
<button class="text-gray-500 hover:underline" on:click={() => clearColumnFilter("changed_on")}>{$t.common?.clear || "Clear"}</button>
|
|
</div>
|
|
<div class="mt-2 max-h-44 overflow-auto space-y-1 text-xs">
|
|
{#each getVisibleFilterOptions("changed_on") as value}
|
|
<label class="flex items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
checked={columnFilters.changed_on.has(value)}
|
|
on:change={(event) => toggleFilterValue("changed_on", value, event.target.checked)}
|
|
/>
|
|
<span class="truncate">{value}</span>
|
|
</label>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<button class="hover:text-gray-900 transition-colors" on:click={() => handleSort("actor")}>
|
|
{$t.dashboard?.owners || "Owners"} {getSortIndicator("actor")}
|
|
</button>
|
|
<div class="relative column-filter">
|
|
<button
|
|
class="rounded border px-1.5 py-0.5 text-xs transition-colors {hasColumnFilter('actor') ? 'border-blue-400 text-blue-700 bg-blue-50' : 'border-gray-300 text-gray-500 hover:text-gray-700'}"
|
|
on:click={() => toggleFilterDropdown("actor")}
|
|
title={$t.dashboard?.column_filter}
|
|
>
|
|
▾
|
|
</button>
|
|
{#if openFilterColumn === "actor"}
|
|
<div class="absolute z-30 mt-2 w-72 rounded-lg border border-gray-200 bg-white shadow-lg p-3">
|
|
<input
|
|
type="text"
|
|
class="w-full rounded border border-gray-300 px-2 py-1 text-xs"
|
|
placeholder={$t.dashboard?.search}
|
|
value={columnFilterSearch.actor}
|
|
on:input={(event) => updateColumnFilterSearch("actor", event.target.value)}
|
|
/>
|
|
<div class="mt-2 flex items-center justify-between text-xs">
|
|
<button class="text-blue-600 hover:underline" on:click={() => selectAllColumnFilterValues("actor")}>{$t.dashboard?.select_all}</button>
|
|
<button class="text-gray-500 hover:underline" on:click={() => clearColumnFilter("actor")}>{$t.common?.clear || "Clear"}</button>
|
|
</div>
|
|
<div class="mt-2 max-h-44 overflow-auto space-y-1 text-xs">
|
|
{#each getVisibleFilterOptions("actor") as value}
|
|
<label class="flex items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
checked={columnFilters.actor.has(value)}
|
|
on:change={(event) => toggleFilterValue("actor", value, event.target.checked)}
|
|
/>
|
|
<span class="truncate">{value}</span>
|
|
</label>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center">
|
|
{$t.dashboard?.actions}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Grid Rows -->
|
|
{#each dashboards as dashboard}
|
|
<div
|
|
class="grid min-w-[1520px] gap-4 px-6 py-4 border-b border-slate-200 last:border-b-0 hover:bg-slate-50 transition-colors text-[13px]"
|
|
style="grid-template-columns: 40px minmax(250px,2.3fr) minmax(118px,0.9fr) minmax(150px,1fr) minmax(150px,1fr) minmax(300px,2.1fr) minmax(280px,2fr);"
|
|
>
|
|
<!-- Checkbox -->
|
|
<div>
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedIds.has(dashboard.id)}
|
|
on:change={(e) => handleCheckboxChange(dashboard, e)}
|
|
/>
|
|
</div>
|
|
|
|
<!-- Title -->
|
|
<div class="font-medium text-sm text-slate-900">
|
|
<button
|
|
class="text-left text-blue-700 hover:text-blue-900 hover:underline transition-colors"
|
|
on:click={() => navigateToDashboardDetail(dashboard.id)}
|
|
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
|
|
? 'bg-blue-100 text-blue-800'
|
|
: 'bg-slate-100 text-slate-700'}"
|
|
>
|
|
{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>
|
|
|
|
<!-- LLM Validation Status -->
|
|
<div>
|
|
{#if dashboard.lastTask}
|
|
<div
|
|
class="inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs font-semibold uppercase tracking-wide {getValidationBadgeClass(dashboard.lastTask.validationStatus)}"
|
|
on:click={() => 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)}
|
|
</div>
|
|
{:else}
|
|
<span class="text-gray-400">-</span>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Changed On -->
|
|
<div class="text-xs text-slate-600">
|
|
{dashboard.changedOnLabel}
|
|
</div>
|
|
|
|
<!-- Owners -->
|
|
<div class="text-xs">
|
|
<div class="text-slate-500 text-[10px] uppercase tracking-wide">{$t.dashboard?.owners || "Owners"}</div>
|
|
{#if dashboard.owners?.length > 0}
|
|
<div class="mt-0.5 flex flex-wrap items-center gap-1">
|
|
{#each dashboard.owners as owner (owner)}
|
|
<span class="rounded bg-slate-100 px-2 py-0.5 text-xs text-slate-700 truncate max-w-[180px]" title={owner}>
|
|
{owner}
|
|
</span>
|
|
{/each}
|
|
</div>
|
|
{:else}
|
|
<div class="mt-0.5 text-gray-400">-</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Actions -->
|
|
<div class="flex items-center">
|
|
<div class="flex items-center gap-1">
|
|
{#if !dashboard.git?.hasRepo}
|
|
<button
|
|
class="p-2 rounded border border-emerald-200 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 hover:border-emerald-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
on:click={() => handleGitInit(dashboard)}
|
|
disabled={isGitBusy(dashboard.id)}
|
|
title={$t.git?.init_repo || "Init Git repository"}
|
|
>
|
|
<svg
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
>
|
|
<path d="M12 5v14M5 12h14" />
|
|
</svg>
|
|
</button>
|
|
{:else}
|
|
<button
|
|
class="p-2 rounded border border-sky-200 bg-sky-50 text-sky-700 hover:bg-sky-100 hover:border-sky-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
on:click={() => handleGitSync(dashboard)}
|
|
disabled={isGitBusy(dashboard.id)}
|
|
title={$t.git?.sync || "Sync from Superset"}
|
|
>
|
|
<svg
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
>
|
|
<path d="M21 12a9 9 0 1 1-3-6.7" />
|
|
<polyline points="21 3 21 9 15 9" />
|
|
</svg>
|
|
</button>
|
|
<button
|
|
class="p-2 rounded border border-orange-200 bg-orange-50 text-orange-700 hover:bg-orange-100 hover:border-orange-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
on:click={() => handleGitCommit(dashboard)}
|
|
disabled={isGitBusy(dashboard.id) ||
|
|
!dashboard.git?.hasChangesForCommit}
|
|
title={$t.git?.commit || "Commit"}
|
|
>
|
|
<svg
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
>
|
|
<path d="M12 3v12" />
|
|
<path d="M7 10l5 5 5-5" />
|
|
<path d="M5 21h14" />
|
|
</svg>
|
|
</button>
|
|
<button
|
|
class="p-2 rounded border border-cyan-200 bg-cyan-50 text-cyan-700 hover:bg-cyan-100 hover:border-cyan-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
on:click={() => handleGitPull(dashboard)}
|
|
disabled={isGitBusy(dashboard.id)}
|
|
title={$t.git?.pull || "Pull"}
|
|
>
|
|
<svg
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
>
|
|
<path d="M12 3v12" />
|
|
<path d="M17 10l-5 5-5-5" />
|
|
<path d="M5 21h14" />
|
|
</svg>
|
|
</button>
|
|
<button
|
|
class="p-2 rounded border border-indigo-200 bg-indigo-50 text-indigo-700 hover:bg-indigo-100 hover:border-indigo-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
on:click={() => handleGitPush(dashboard)}
|
|
disabled={isGitBusy(dashboard.id)}
|
|
title={$t.git?.push || "Push"}
|
|
>
|
|
<svg
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
>
|
|
<path d="M12 21V9" />
|
|
<path d="M7 14l5-5 5 5" />
|
|
<path d="M5 3h14" />
|
|
</svg>
|
|
</button>
|
|
{/if}
|
|
<button
|
|
class="p-2 rounded border border-blue-200 bg-blue-50 text-blue-700 hover:bg-blue-100 hover:border-blue-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
on:click={() => handleAction(dashboard, "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="p-2 rounded border border-emerald-200 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 hover:border-emerald-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
on:click={() => handleValidate(dashboard)}
|
|
disabled={validatingIds.has(dashboard.id)}
|
|
title={$t.dashboard?.action_validate}
|
|
>
|
|
{#if validatingIds.has(dashboard.id)}
|
|
<svg
|
|
class="animate-spin"
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
>
|
|
<circle
|
|
cx="12"
|
|
cy="12"
|
|
r="10"
|
|
stroke-dasharray="32"
|
|
stroke-dashoffset="12"
|
|
/>
|
|
</svg>
|
|
{:else}
|
|
<svg
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
>
|
|
<path d="M9 12l2 2 4-4" />
|
|
<circle cx="12" cy="12" r="10" />
|
|
</svg>
|
|
{/if}
|
|
</button>
|
|
<button
|
|
class="p-2 rounded border border-amber-200 bg-amber-50 text-amber-700 hover:bg-amber-100 hover:border-amber-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
on:click={() => handleAction(dashboard, "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>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
|
|
<!-- Pagination -->
|
|
{#if totalPages > 1}
|
|
<div
|
|
class="flex items-center justify-between px-4 py-3 bg-gray-50 border-t border-gray-200"
|
|
>
|
|
<div class="text-sm text-gray-600">
|
|
{($t.dashboard?.showing)
|
|
.replace("{start}", String((currentPage - 1) * pageSize + 1))
|
|
.replace("{end}", String(Math.min(currentPage * pageSize, total)))
|
|
.replace("{total}", String(total))}
|
|
</div>
|
|
<div class="flex items-center gap-1">
|
|
<button
|
|
class="px-3 py-1 border border-gray-300 rounded hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed min-w-[32px]"
|
|
on:click={() => handlePageChange(1)}
|
|
disabled={currentPage === 1}
|
|
>
|
|
{$t.common?.first}
|
|
</button>
|
|
<button
|
|
class="px-3 py-1 border border-gray-300 rounded hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed min-w-[32px]"
|
|
on:click={() => handlePageChange(currentPage - 1)}
|
|
disabled={currentPage === 1}
|
|
>
|
|
{$t.dashboard?.previous}
|
|
</button>
|
|
{#each getPaginationRange(currentPage, totalPages) as pageNum}
|
|
{#if pageNum === "..."}
|
|
<span class="px-2 py-1 text-gray-500">...</span>
|
|
{:else}
|
|
<button
|
|
class="page-btn {pageNum === currentPage ? 'active' : ''}"
|
|
on:click={() => handlePageChange(pageNum)}
|
|
>
|
|
{pageNum}
|
|
</button>
|
|
{/if}
|
|
{/each}
|
|
<button
|
|
class="px-3 py-1 border border-gray-300 rounded hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed min-w-[32px]"
|
|
on:click={() => handlePageChange(currentPage + 1)}
|
|
disabled={currentPage === totalPages}
|
|
>
|
|
{$t.dashboard?.next}
|
|
</button>
|
|
<button
|
|
class="px-3 py-1 border border-gray-300 rounded hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed min-w-[32px]"
|
|
on:click={() => handlePageChange(totalPages)}
|
|
disabled={currentPage === totalPages}
|
|
>
|
|
{$t.common?.last}
|
|
</button>
|
|
</div>
|
|
<div>
|
|
<select
|
|
class="px-4 py-2 border border-gray-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
value={pageSize}
|
|
on:change={handlePageSizeChange}
|
|
>
|
|
<option value={5}>
|
|
{($t.dashboard?.per_page_option).replace("{count}", "5")}
|
|
</option>
|
|
<option value={10}>
|
|
{($t.dashboard?.per_page_option).replace("{count}", "10")}
|
|
</option>
|
|
<option value={25}>
|
|
{($t.dashboard?.per_page_option).replace("{count}", "25")}
|
|
</option>
|
|
<option value={50}>
|
|
{($t.dashboard?.per_page_option).replace("{count}", "50")}
|
|
</option>
|
|
<option value={100}>
|
|
{($t.dashboard?.per_page_option).replace("{count}", "100")}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Floating Bulk Action Panel -->
|
|
{#if selectedIds.size > 0}
|
|
<div
|
|
class="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 shadow-lg p-4 transition-transform transform translate-y-0"
|
|
>
|
|
<div class="flex items-center justify-between max-w-7xl mx-auto">
|
|
<div class="flex items-center gap-4">
|
|
<span class="font-medium">
|
|
✓ {($t.dashboard?.selected_count).replace(
|
|
"{count}",
|
|
String(selectedIds.size),
|
|
)}
|
|
</span>
|
|
</div>
|
|
<div class="flex gap-3">
|
|
<button
|
|
class="px-3 py-1 text-sm bg-primary text-white border-primary rounded hover:bg-primary-hover transition-colors"
|
|
on:click={() => {
|
|
showMigrateModal = true;
|
|
targetEnvId = null;
|
|
availableDbMappings = [];
|
|
dbMappings = {};
|
|
sourceDatabases = [];
|
|
targetDatabases = [];
|
|
isEditingMappings = false;
|
|
}}
|
|
>
|
|
{$t.dashboard?.bulk_migrate}
|
|
</button>
|
|
<button
|
|
class="px-3 py-1 text-sm bg-primary text-white border-primary rounded hover:bg-primary-hover transition-colors"
|
|
on:click={() => (showBackupModal = true)}
|
|
>
|
|
{$t.dashboard?.bulk_backup}
|
|
</button>
|
|
<button
|
|
class="px-3 py-1 text-sm border border-gray-300 rounded hover:bg-gray-100 transition-colors"
|
|
on:click={() => selectedIds.clear()}
|
|
>
|
|
{$t.common?.cancel}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
|
|
<!-- Migration Modal -->
|
|
{#if showMigrateModal}
|
|
<div
|
|
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
|
|
on:click={() => (showMigrateModal = false)}
|
|
on:keydown={(e) => e.key === "Escape" && (showMigrateModal = false)}
|
|
role="presentation"
|
|
>
|
|
<div
|
|
class="bg-white rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[80vh] overflow-y-auto"
|
|
on:click|stopPropagation
|
|
role="dialog"
|
|
aria-modal="true"
|
|
tabindex="-1"
|
|
aria-labelledby="migrate-modal-title"
|
|
on:keydown={(e) => e.stopPropagation()}
|
|
>
|
|
<div
|
|
class="px-6 py-4 border-b border-gray-200 flex items-center justify-between relative"
|
|
>
|
|
<h2 id="migrate-modal-title" class="text-xl font-bold">
|
|
{($t.dashboard?.migrate_modal_title).replace(
|
|
"{count}",
|
|
String(selectedIds.size),
|
|
)}
|
|
</h2>
|
|
<button
|
|
on:click={() => (showMigrateModal = false)}
|
|
class="absolute top-4 right-4 p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-full transition-all"
|
|
aria-label={$t.common?.close_modal}
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="20"
|
|
height="20"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
>
|
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div class="px-6 py-4">
|
|
<div class="space-y-6">
|
|
<!-- Source Env (Read-only) -->
|
|
<div>
|
|
<label
|
|
for="source-env"
|
|
class="block text-sm font-medium text-gray-500 mb-1"
|
|
>{$t.migration?.source_env}</label
|
|
>
|
|
<div
|
|
id="source-env"
|
|
class="px-4 py-2 bg-gray-50 border border-gray-200 rounded-lg text-gray-700 font-medium"
|
|
>
|
|
{environments.find((e) => e.id === selectedEnv)?.name ||
|
|
selectedEnv}
|
|
{$t.dashboard?.read_only}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Target Env -->
|
|
<div>
|
|
<label
|
|
for="target-env"
|
|
class="block text-sm font-medium text-gray-700 mb-2"
|
|
>{$t.migration?.target_env}</label
|
|
>
|
|
<select
|
|
id="target-env"
|
|
class="env-dropdown w-full"
|
|
bind:value={targetEnvId}
|
|
on:change={handleTargetEnvChange}
|
|
>
|
|
<option value="">
|
|
{$t.dashboard?.target_env_placeholder ||
|
|
"Select target environment..."}
|
|
</option>
|
|
{#each environments.filter((e) => e.id !== selectedEnv) as env}
|
|
<option value={env.id}>{env.name}</option>
|
|
{/each}
|
|
</select>
|
|
</div>
|
|
|
|
<!-- Database Mappings Toggle -->
|
|
<div>
|
|
<div class="flex items-center justify-between mb-2">
|
|
<label
|
|
for="use-db-mappings"
|
|
class="block text-sm font-medium text-gray-700"
|
|
>{$t.migration?.database_mappings}</label
|
|
>
|
|
<label class="relative inline-flex items-center cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
id="use-db-mappings"
|
|
bind:checked={useDbMappings}
|
|
class="sr-only peer"
|
|
/>
|
|
<div
|
|
class="w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-blue-600"
|
|
></div>
|
|
<span class="ml-2 text-xs text-gray-500"
|
|
>{useDbMappings ? $t.common?.on : $t.common?.off}</span
|
|
>
|
|
</label>
|
|
</div>
|
|
|
|
{#if useDbMappings}
|
|
<div class="flex items-center justify-between mb-2">
|
|
{#if availableDbMappings.length > 0}
|
|
<button
|
|
class="text-sm text-blue-600 hover:underline"
|
|
on:click={() => (isEditingMappings = !isEditingMappings)}
|
|
>
|
|
{isEditingMappings
|
|
? $t.dashboard?.view_summary
|
|
: $t.dashboard?.edit_mappings}
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if isEditingMappings}
|
|
<div
|
|
class="border border-gray-200 rounded-lg overflow-hidden"
|
|
>
|
|
<MappingTable
|
|
{sourceDatabases}
|
|
{targetDatabases}
|
|
mappings={Object.entries(dbMappings).map(([s, t]) => ({
|
|
source_db_uuid: s,
|
|
target_db_uuid: t,
|
|
}))}
|
|
suggestions={availableDbMappings}
|
|
on:update={handleMappingUpdate}
|
|
/>
|
|
</div>
|
|
{:else}
|
|
<div
|
|
class="border border-gray-200 rounded-lg overflow-hidden"
|
|
>
|
|
<table class="w-full text-sm text-left">
|
|
<thead class="bg-gray-50 border-b border-gray-200">
|
|
<tr>
|
|
<th class="px-4 py-2 font-semibold text-gray-700"
|
|
>{$t.dashboard?.source_database}</th
|
|
>
|
|
<th class="px-4 py-2 font-semibold text-gray-700"
|
|
>{$t.dashboard?.target_database}</th
|
|
>
|
|
<th class="px-4 py-2 font-semibold text-gray-700"
|
|
>{$t.dashboard?.match_percent}</th
|
|
>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{#if availableDbMappings.length > 0}
|
|
{#each availableDbMappings as mapping}
|
|
<tr
|
|
class="border-b border-gray-200 last:border-b-0"
|
|
>
|
|
<td class="px-4 py-2">{mapping.source_db}</td>
|
|
<td class="px-4 py-2">
|
|
{#if dbMappings[mapping.source_db_uuid]}
|
|
{targetDatabases.find(
|
|
(d) =>
|
|
d.uuid ===
|
|
dbMappings[mapping.source_db_uuid],
|
|
)?.database_name || mapping.target_db}
|
|
{:else}
|
|
<span class="text-red-500"
|
|
>{$t.dashboard?.not_mapped}</span
|
|
>
|
|
{/if}
|
|
</td>
|
|
<td class="px-4 py-2">
|
|
<span
|
|
class="px-2 py-0.5 rounded-full text-xs font-medium {mapping.confidence >
|
|
0.9
|
|
? 'bg-green-100 text-green-800'
|
|
: 'bg-yellow-100 text-yellow-800'}"
|
|
>
|
|
{Math.round(mapping.confidence * 100)}%
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
{/each}
|
|
{:else}
|
|
<tr>
|
|
<td
|
|
colspan="3"
|
|
class="px-4 py-4 text-center text-gray-500 italic"
|
|
>
|
|
{targetEnvId
|
|
? $t.dashboard?.no_databases_to_map ||
|
|
"No databases found to map"
|
|
: $t.dashboard?.select_target_for_mappings ||
|
|
"Select target environment to see mappings"}
|
|
</td>
|
|
</tr>
|
|
{/if}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{/if}
|
|
{:else}
|
|
<p class="text-xs text-gray-400 italic">
|
|
{$t.dashboard?.mapping_disabled_hint ||
|
|
"Database mapping is disabled. Dashboards will be imported with original database references."}
|
|
</p>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Fix Cross-Filters (Spec 022 FR-007) -->
|
|
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
|
<label class="flex items-start gap-3 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
bind:checked={fixCrossFilters}
|
|
class="mt-0.5 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
|
/>
|
|
<div>
|
|
<span class="text-sm font-medium text-gray-900"
|
|
>{$t.dashboard?.fix_cross_filters_title ||
|
|
"Fix cross-filter bindings"}</span
|
|
>
|
|
<p class="text-xs text-gray-500 mt-0.5">
|
|
{$t.dashboard?.fix_cross_filters_hint ||
|
|
"Automatically remap chart and dataset IDs in cross-filters to target environment IDs. Recommended when migrating dashboards with cross-filters."}
|
|
</p>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
|
|
<!-- Selected Dashboards List -->
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-2"
|
|
>{$t.dashboard?.selected_dashboards}</label
|
|
>
|
|
<div
|
|
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 allDashboards as d}
|
|
{#if d.id === id}
|
|
<div class="flex items-center text-sm py-1 px-2">
|
|
<span class="text-green-600 mr-2">☑</span>
|
|
<span class="text-gray-700">{d.title}</span>
|
|
</div>
|
|
{/if}
|
|
{/each}
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="px-6 py-4 border-t border-gray-200 flex justify-end gap-3">
|
|
<button
|
|
class="px-3 py-1 text-sm border border-gray-300 rounded hover:bg-gray-100 transition-colors"
|
|
on:click={() => (showMigrateModal = false)}
|
|
disabled={isSubmittingMigrate}>{$t.common?.cancel}</button
|
|
>
|
|
<button
|
|
class="px-3 py-1 text-sm bg-primary text-white border-primary rounded hover:bg-primary-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
on:click={handleBulkMigrate}
|
|
disabled={!targetEnvId ||
|
|
selectedIds.size === 0 ||
|
|
isSubmittingMigrate}
|
|
>
|
|
{isSubmittingMigrate ? $t.dashboard?.starting : $t.migration?.start}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Backup Modal -->
|
|
{#if showBackupModal}
|
|
<div
|
|
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
|
|
on:click={() => (showBackupModal = false)}
|
|
on:keydown={(e) => e.key === "Escape" && (showBackupModal = false)}
|
|
role="presentation"
|
|
>
|
|
<div
|
|
class="bg-white rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[80vh] overflow-y-auto"
|
|
on:click|stopPropagation
|
|
role="dialog"
|
|
aria-modal="true"
|
|
tabindex="-1"
|
|
aria-labelledby="backup-modal-title"
|
|
on:keydown={(e) => e.stopPropagation()}
|
|
>
|
|
<div
|
|
class="px-6 py-4 border-b border-gray-200 flex items-center justify-between relative"
|
|
>
|
|
<h2 id="backup-modal-title" class="text-xl font-bold">
|
|
{($t.dashboard?.backup_modal_title).replace(
|
|
"{count}",
|
|
String(selectedIds.size),
|
|
)}
|
|
</h2>
|
|
<button
|
|
on:click={() => (showBackupModal = false)}
|
|
class="absolute top-4 right-4 p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-full transition-all"
|
|
aria-label={$t.common?.close_modal}
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="20"
|
|
height="20"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
>
|
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div class="px-6 py-4">
|
|
<div class="space-y-6">
|
|
<!-- Env (Read-only) -->
|
|
<div>
|
|
<label
|
|
for="backup-env"
|
|
class="block text-sm font-medium text-gray-500 mb-1"
|
|
>{$t.dashboard?.environment}</label
|
|
>
|
|
<div
|
|
id="backup-env"
|
|
class="px-4 py-2 bg-gray-50 border border-gray-200 rounded-lg text-gray-700 font-medium"
|
|
>
|
|
{environments.find((e) => e.id === selectedEnv)?.name ||
|
|
selectedEnv}
|
|
{$t.dashboard?.read_only}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Selected Dashboards List -->
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-2"
|
|
>{$t.dashboard?.selected_dashboards}</label
|
|
>
|
|
<div
|
|
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 allDashboards as d}
|
|
{#if d.id === id}
|
|
<div class="flex items-center text-sm py-1 px-2">
|
|
<span class="text-green-600 mr-2">☑</span>
|
|
<span class="text-gray-700">{d.title}</span>
|
|
</div>
|
|
{/if}
|
|
{/each}
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Schedule -->
|
|
<div>
|
|
<label
|
|
id="backup-schedule-label"
|
|
class="block text-sm font-medium text-gray-700 mb-2"
|
|
>{$t.dashboard?.schedule}</label
|
|
>
|
|
<div class="space-y-3" aria-labelledby="backup-schedule-label">
|
|
<label class="flex items-center">
|
|
<input
|
|
type="radio"
|
|
name="schedule"
|
|
value=""
|
|
bind:group={backupSchedule}
|
|
class="mr-2"
|
|
/>
|
|
<span class="text-sm">{$t.dashboard?.one_time_backup}</span>
|
|
</label>
|
|
<label class="flex items-center">
|
|
<input
|
|
type="radio"
|
|
name="schedule"
|
|
value="0 0 * * *"
|
|
bind:group={backupSchedule}
|
|
class="mr-2"
|
|
/>
|
|
<span class="text-sm">{$t.dashboard?.schedule_backup}</span>
|
|
</label>
|
|
{#if backupSchedule !== ""}
|
|
<div class="ml-6">
|
|
<label
|
|
for="cron-expression"
|
|
class="block text-xs text-gray-500 mb-1"
|
|
>{$t.dashboard?.cron_expression}</label
|
|
>
|
|
<input
|
|
id="cron-expression"
|
|
type="text"
|
|
class="search-input w-full text-sm"
|
|
placeholder={$t.dashboard?.cron_placeholder ||
|
|
"0 2 * * * (daily at 2 AM)"}
|
|
bind:value={backupSchedule}
|
|
/>
|
|
<button class="text-xs text-blue-600 hover:underline mt-1"
|
|
>{$t.dashboard?.cron_help}</button
|
|
>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="px-6 py-4 border-t border-gray-200 flex justify-end gap-3">
|
|
<button
|
|
class="px-3 py-1 text-sm border border-gray-300 rounded hover:bg-gray-100 transition-colors"
|
|
on:click={() => (showBackupModal = false)}
|
|
disabled={isSubmittingBackup}>{$t.common?.cancel}</button
|
|
>
|
|
<button
|
|
class="px-3 py-1 text-sm bg-primary text-white border-primary rounded hover:bg-primary-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
on:click={handleBulkBackup}
|
|
disabled={selectedIds.size === 0 || isSubmittingBackup}
|
|
>
|
|
{isSubmittingBackup
|
|
? $t.dashboard?.starting
|
|
: $t.dashboard?.start_backup}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- [/DEF:DashboardHub:Page] -->
|