Files
ss-tools/frontend/src/routes/dashboards/+page.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] -->