Files
ss-tools/frontend/src/routes/dashboards/+page.svelte

1673 lines
59 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 environment selector and dashboard grid
*
* @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: Clicking task status opens Task Drawer
* @UX_FEEDBACK: Floating panel slides up from bottom when items selected
* @UX_RECOVERY: Refresh button reloads dashboard list
*/
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,
setSelectedEnvironment,
} from "$lib/stores/environmentContext.js";
// State
let selectedEnv = null;
let dashboards = [];
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 = "";
// 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;
loadDashboards();
}, 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();
}
}
// Add document click listener
if (typeof document !== "undefined") {
document.addEventListener("click", handleDocumentClick);
}
// Load dashboards from API
async function loadDashboards() {
if (!selectedEnv) return;
isLoading = true;
error = null;
try {
const response = await api.getDashboards(selectedEnv, {
search: searchQuery || undefined,
page: currentPage,
page_size: pageSize,
});
// Preserve selected IDs across pagination
const newSelectedIds = new Set();
response.dashboards.forEach((d) => {
if (selectedIds.has(d.id)) {
newSelectedIds.add(d.id);
}
});
selectedIds = newSelectedIds;
dashboards = response.dashboards.map((d) => ({
id: d.id,
title: d.title,
slug: d.slug,
git: {
status: d.git_status?.sync_status?.toLowerCase() || "no_repo",
branch: d.git_status?.branch || null,
hasRepo: d.git_status?.has_repo === true,
hasChangesForCommit: d.git_status?.has_changes_for_commit === true,
},
lastTask: d.last_task
? {
status: d.last_task.status?.toLowerCase() || null,
id: d.last_task.task_id,
}
: null,
actions: ["migrate", "backup"], // All dashboards have migrate and backup options
}));
// Update pagination state
total = response.total;
totalPages = response.total_pages;
// Update selection state
updateSelectionState();
} catch (err) {
error = err.message || $t.dashboard?.load_failed ;
console.error("[DashboardHub][Coherence:Failed]", err);
} finally {
isLoading = false;
}
}
// Handle environment change
function handleEnvChange(event) {
selectedEnv = event.target.value;
setSelectedEnvironment(selectedEnv);
currentPage = 1;
selectedIds.clear();
loadDashboards();
}
// Handle search input
function handleSearch(event) {
debouncedSearch(event.target.value);
}
// Handle page change
function handlePageChange(page) {
currentPage = page;
loadDashboards();
}
// Handle page size change
function handlePageSizeChange(event) {
pageSize = parseInt(event.target.value);
currentPage = 1;
loadDashboards();
}
// Update selection state based on current selection
function updateSelectionState() {
const visibleCount = dashboards.length;
const totalCount = total;
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 {
dashboards.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();
}
// 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 "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 "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 "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">
<select
class="px-4 py-2 border border-gray-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-blue-500"
bind:value={selectedEnv}
on:change={handleEnvChange}
>
{#each environments as env}
<option value={env.id}>{env.name}</option>
{/each}
</select>
<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-gray-200 rounded-lg overflow-hidden">
<div
class="grid grid-cols-12 gap-4 px-6 py-3 bg-gray-50 border-b border-gray-200 font-semibold text-sm text-gray-700"
>
<div class="col-span-1 h-4 rounded bg-gray-200 animate-pulse"></div>
<div class="col-span-3 h-4 rounded bg-gray-200 animate-pulse"></div>
<div class="col-span-2 h-4 rounded bg-gray-200 animate-pulse"></div>
<div class="col-span-3 h-4 rounded bg-gray-200 animate-pulse"></div>
<div class="col-span-3 h-4 rounded bg-gray-200 animate-pulse"></div>
</div>
{#each Array(5) as _}
<div
class="grid grid-cols-12 gap-4 px-6 py-4 border-b border-gray-200 last:border-b-0 hover:bg-gray-50 transition-colors"
>
<div class="col-span-1 h-4 rounded bg-gray-200 animate-pulse"></div>
<div class="col-span-3 h-4 rounded bg-gray-200 animate-pulse"></div>
<div class="col-span-2 h-4 rounded bg-gray-200 animate-pulse"></div>
<div class="col-span-3 h-4 rounded bg-gray-200 animate-pulse"></div>
<div class="col-span-3 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-gray-200 rounded-lg overflow-hidden">
<!-- Grid Header -->
<div
class="grid grid-cols-12 gap-4 px-6 py-3 bg-gray-50 border-b border-gray-200 font-semibold text-sm text-gray-700"
>
<div class="col-span-1"></div>
<div class="col-span-3 font-medium text-gray-900">
{$t.dashboard?.title }
</div>
<div class="col-span-2">
{$t.dashboard?.git_status }
</div>
<div class="col-span-3">{$t.dashboard?.last_task }</div>
<div class="col-span-3 flex items-center">
{$t.dashboard?.actions }
</div>
</div>
<!-- Grid Rows -->
{#each dashboards as dashboard}
<div
class="grid grid-cols-12 gap-4 px-6 py-4 border-b border-gray-200 last:border-b-0 hover:bg-gray-50 transition-colors"
>
<!-- Checkbox -->
<div class="col-span-1">
<input
type="checkbox"
checked={selectedIds.has(dashboard.id)}
on:change={(e) => handleCheckboxChange(dashboard, e)}
/>
</div>
<!-- Title -->
<div class="col-span-3 font-medium text-gray-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 class="col-span-2">
<div class="flex flex-col gap-1">
<span
class="status-badge {dashboard.git?.hasRepo ? 'bg-blue-100 text-blue-800' : 'bg-gray-100 text-gray-700'}"
>
{dashboard.git?.hasRepo
? ($t.dashboard?.status_repo || "Repo")
: ($t.dashboard?.status_no_repo || "No Repo")}
</span>
{#if dashboard.git?.hasRepo}
<span
class="status-badge {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>
<!-- Last Task -->
<div class="col-span-3">
{#if dashboard.lastTask}
<div
class="task-status-badge {dashboard.lastTask.status.toLowerCase()}"
on:click={() => handleTaskStatusClick(dashboard)}
on:keydown={(e) =>
(e.key === "Enter" || e.key === " ") &&
handleTaskStatusClick(dashboard)}
role="button"
tabindex="0"
aria-label={$t.dashboard?.view_task }
>
{@html getTaskStatusIcon(dashboard.lastTask.status)}
<span class="ml-1">
{#if dashboard.lastTask.status.toLowerCase() === "running"}
{$t.dashboard?.task_running }
{:else if dashboard.lastTask.status.toLowerCase() === "success"}
{$t.dashboard?.task_done }
{:else if dashboard.lastTask.status.toLowerCase() === "error"}
{$t.dashboard?.task_failed }
{:else if dashboard.lastTask.status.toLowerCase() === "waiting_input"}
{$t.dashboard?.task_waiting }
{/if}
</span>
</div>
{:else}
<span class="text-gray-400">-</span>
{/if}
</div>
<!-- Actions -->
<div class="col-span-3 flex items-center">
<div class="flex items-center gap-1">
{#if !dashboard.git?.hasRepo}
<button
class="p-2 rounded border border-gray-200 hover:bg-gray-50 hover:border-gray-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-gray-200 hover:bg-gray-50 hover:border-gray-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-gray-200 hover:bg-gray-50 hover:border-gray-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-gray-200 hover:bg-gray-50 hover:border-gray-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-gray-200 hover:bg-gray-50 hover:border-gray-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-gray-200 hover:bg-gray-50 hover:border-gray-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-gray-200 hover:bg-gray-50 hover:border-gray-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-gray-200 hover:bg-gray-50 hover:border-gray-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}
<!-- 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 dashboards 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 dashboards 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] -->