|
|
|
|
@@ -1,36 +1,29 @@
|
|
|
|
|
<!-- #region DashboardHub [C:4] [TYPE Page] [SEMANTICS sveltekit, dashboard, hub, git, bulk-action] -->
|
|
|
|
|
<!-- @ingroup Routes -->
|
|
|
|
|
<!-- @BRIEF Dashboard hub page for environment-scoped dashboard management with Git status, validation badges, selection, and bulk migrate/backup actions. -->
|
|
|
|
|
<!-- @BRIEF Dashboard hub page migrated to DashboardDataGrid with header snippet, render-based cells, and selection via bridge. -->
|
|
|
|
|
<!-- @LAYER Page -->
|
|
|
|
|
<!-- @RELATION DEPENDS_ON -> [MappingTable] -->
|
|
|
|
|
<!-- @RELATION DEPENDS_ON -> [StartupEnvironmentWizard] -->
|
|
|
|
|
<!-- @RELATION BINDS_TO -> [EXT:frontend:environmentContextStore] -->
|
|
|
|
|
<!-- @RELATION DEPENDS_ON -> [Dashboard.DataGrid] -->
|
|
|
|
|
<!-- @RELATION BINDS_TO -> [DashboardHubModel] -->
|
|
|
|
|
<!-- @RELATION CALLS -> [EXT:frontend:api_module] -->
|
|
|
|
|
<!-- @RELATION CALLS -> [EXT:frontend:gitService] -->
|
|
|
|
|
<!-- @UX_STATE Loading -> Skeleton loader shown. -->
|
|
|
|
|
<!-- @UX_STATE Loaded -> Dashboard grid with status badges rendered. -->
|
|
|
|
|
<!-- @UX_STATE Error -> 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. -->
|
|
|
|
|
<!-- @RATIONALE MIGRATED from inline CSS Grid to DashboardDataGrid. Svelte 5 limitation: only one snippet per component. Strategy: header snippet for sort+filter headers, Column.render() for cells, internal selectable for checkboxes. -->
|
|
|
|
|
<script lang="ts">
|
|
|
|
|
import { onMount } from "svelte";
|
|
|
|
|
import { tick } from "svelte";
|
|
|
|
|
import { goto } from "$app/navigation";
|
|
|
|
|
import { ROUTES } from "$lib/routes.js";
|
|
|
|
|
import { t } from "$lib/i18n/index.svelte.js";
|
|
|
|
|
import StartupEnvironmentWizard from "$lib/components/ui/StartupEnvironmentWizard.svelte";
|
|
|
|
|
import SearchableMultiSelect from "$lib/components/ui/SearchableMultiSelect.svelte";
|
|
|
|
|
import DashboardMaintenanceBadge from "$lib/components/DashboardMaintenanceBadge.svelte";
|
|
|
|
|
|
|
|
|
|
import type { Column } from "$lib/components/dashboard/DashboardDataGrid.svelte";
|
|
|
|
|
import { environmentContextStore, initializeEnvironmentContext, setSelectedEnvironment } from "$lib/stores/environmentContext.svelte.js";
|
|
|
|
|
import { Button, EmptyState, Icon, Pagination, Skeleton } from "$lib/ui";
|
|
|
|
|
import { Button, EmptyState } from "$lib/ui";
|
|
|
|
|
import ColumnFilterPopover from "./ColumnFilterPopover.svelte";
|
|
|
|
|
import { formatDate, getSortIndicator } from "./dashboard-helpers.js";
|
|
|
|
|
import { DashboardHubModel } from "$lib/models/DashboardHubModel.svelte.ts";
|
|
|
|
|
import MigrateDashboardModal from "./MigrateDashboardModal.svelte";
|
|
|
|
|
import BackupDashboardModal from "./BackupDashboardModal.svelte";
|
|
|
|
|
import DashboardDataGrid from "$lib/components/dashboard/DashboardDataGrid.svelte";
|
|
|
|
|
import StartupEnvironmentWizard from "$lib/components/ui/StartupEnvironmentWizard.svelte";
|
|
|
|
|
import { maintenanceStore } from "$lib/stores/maintenance.svelte.js";
|
|
|
|
|
|
|
|
|
|
const m = new DashboardHubModel();
|
|
|
|
|
|
|
|
|
|
@@ -45,680 +38,276 @@
|
|
|
|
|
}
|
|
|
|
|
if (typeof document !== 'undefined') document.addEventListener('click', handleDocumentClick);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function handleEnvironmentCreated(envId: string) {
|
|
|
|
|
if (!envId) return;
|
|
|
|
|
m.error = null; m.lastLoadedEnvId = null; m.selectedEnv = envId;
|
|
|
|
|
setSelectedEnvironment(envId);
|
|
|
|
|
}
|
|
|
|
|
async function handleEnvironmentCreated(envId: string) { if (!envId) return; m.error = null; m.lastLoadedEnvId = null; m.selectedEnv = envId; setSelectedEnvironment(envId); }
|
|
|
|
|
|
|
|
|
|
// ── Pagination bridge (0-indexed ↔ 1-indexed) ──
|
|
|
|
|
// ── Pagination bridge ──
|
|
|
|
|
let pageZero = $state(m.currentPage - 1);
|
|
|
|
|
$effect(() => { if (pageZero + 1 !== m.currentPage) m.setPage(pageZero + 1); });
|
|
|
|
|
$effect(() => { if (m.currentPage - 1 !== pageZero) pageZero = m.currentPage - 1; });
|
|
|
|
|
function handlePageSizeChange(size: number) { m.setPageSize({ target: { value: String(size) } } as any); }
|
|
|
|
|
|
|
|
|
|
// ── Validation version (local reactive mirror, triggers grid {#key}) ──
|
|
|
|
|
let localValidationVersion = $state(0);
|
|
|
|
|
$effect(() => { localValidationVersion = m.validationVersion; });
|
|
|
|
|
|
|
|
|
|
// ── Selection bridge (array ↔ model Set) ──
|
|
|
|
|
let selectedIdsArray = $state([] as number[]);
|
|
|
|
|
$effect(() => { selectedIdsArray = Array.from(m.selectedIds); });
|
|
|
|
|
$effect(() => {
|
|
|
|
|
if (pageZero + 1 !== m.currentPage) {
|
|
|
|
|
m.setPage(pageZero + 1);
|
|
|
|
|
}
|
|
|
|
|
const arrSet = new Set(selectedIdsArray);
|
|
|
|
|
const modelSet = m.selectedIds;
|
|
|
|
|
const same = arrSet.size === modelSet.size && selectedIdsArray.every((id: number) => modelSet.has(id));
|
|
|
|
|
if (!same) m.replaceSelectedIds(arrSet);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$effect(() => {
|
|
|
|
|
if (m.currentPage - 1 !== pageZero) {
|
|
|
|
|
pageZero = m.currentPage - 1;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function handlePageSizeChange(size: number) {
|
|
|
|
|
m.pageSize = size;
|
|
|
|
|
m.currentPage = 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Store-derived values (must stay in page — legacy store not reactive in Model)
|
|
|
|
|
let environments = $derived($environmentContextStore?.environments || []);
|
|
|
|
|
let shouldShowEnvironmentWizard = $derived(
|
|
|
|
|
Boolean($environmentContextStore?.isLoaded) && !$environmentContextStore?.isLoading &&
|
|
|
|
|
!$environmentContextStore?.error && environments.length === 0
|
|
|
|
|
);
|
|
|
|
|
let shouldShowEnvironmentWizard = $derived(Boolean($environmentContextStore?.isLoaded) && !$environmentContextStore?.isLoading && !$environmentContextStore?.error && environments.length === 0);
|
|
|
|
|
let shouldShowEnvironmentZeroState = $derived(shouldShowEnvironmentWizard && !m.isLoading && !m.error);
|
|
|
|
|
|
|
|
|
|
// Environment context sync
|
|
|
|
|
$effect(() => { const ctxEnvId = $environmentContextStore?.selectedEnvId; if (ctxEnvId && m.selectedEnv !== ctxEnvId) m.selectedEnv = ctxEnvId; });
|
|
|
|
|
$effect(() => {
|
|
|
|
|
const ctx = $environmentContextStore;
|
|
|
|
|
if (!ctx || ctx.isLoading) return;
|
|
|
|
|
if (ctx.error && !m.selectedEnv) { m.error = ctx.error; m.isLoading = false; return; }
|
|
|
|
|
if (ctx.isLoaded && ctx.environments.length === 0) {
|
|
|
|
|
m.isLoading = false; m.error = null;
|
|
|
|
|
m.allDashboards = []; m.dashboards = []; m.filteredDashboards = [];
|
|
|
|
|
m.total = 0; m.totalPages = 1; m.serverTotal = 0; m.serverTotalPages = 1; m.lastLoadedEnvId = null;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
$effect(() => { const ctx = $environmentContextStore; if (!ctx || ctx.isLoading) return; if (ctx.error && !m.selectedEnv) { m.error = ctx.error; m.isLoading = false; return; } if (ctx.isLoaded && ctx.environments.length === 0) { m.isLoading = false; m.error = null; m.allDashboards = []; m.dashboards = []; m.filteredDashboards = []; m.total = 0; m.totalPages = 1; m.serverTotal = 0; m.serverTotalPages = 1; m.lastLoadedEnvId = null; } });
|
|
|
|
|
$effect(() => { m.filterDashboardIds; queueMicrotask(() => { if (m.allDashboards.length > 0) m.applyGridTransforms(); }); });
|
|
|
|
|
$effect(() => {
|
|
|
|
|
const envId = m.selectedEnv;
|
|
|
|
|
if (!envId || envId === m.lastLoadedEnvId) return;
|
|
|
|
|
m.lastLoadedEnvId = envId; m.currentPage = 1;
|
|
|
|
|
m.profileFilterOverrideShowAll = false; m.effectiveProfileFilter = null;
|
|
|
|
|
m.clearSelectedIds(); m.filterDashboardIds = [];
|
|
|
|
|
$effect(() => { const envId = m.selectedEnv; if (!envId || envId === m.lastLoadedEnvId) return; m.lastLoadedEnvId = envId; m.currentPage = 1; m.profileFilterOverrideShowAll = false; m.effectiveProfileFilter = null; m.clearSelectedIds(); m.filterDashboardIds = [];
|
|
|
|
|
void m.loadDashboards(); void m.loadDashboardSearchOptions('');
|
|
|
|
|
void maintenanceStore.loadDashboardBanners().catch(() => {});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ── Maintenance badge mounting (Svelte components into raw HTML placeholders) ──
|
|
|
|
|
let maintenanceInstances: Array<{ $destroy?: () => void }> = [];
|
|
|
|
|
async function mountMaintenanceBadges() {
|
|
|
|
|
maintenanceInstances.forEach(c => c.$destroy?.());
|
|
|
|
|
maintenanceInstances = [];
|
|
|
|
|
await tick();
|
|
|
|
|
const spans = document.querySelectorAll('[data-maintenance-dashboard]');
|
|
|
|
|
spans.forEach(span => {
|
|
|
|
|
const dashId = span.getAttribute('data-maintenance-dashboard');
|
|
|
|
|
if (dashId && !span.hasChildNodes()) {
|
|
|
|
|
const instance = new DashboardMaintenanceBadge({
|
|
|
|
|
target: span as HTMLElement,
|
|
|
|
|
props: { dashboardId: Number(dashId) }
|
|
|
|
|
}) as { $destroy?: () => void };
|
|
|
|
|
maintenanceInstances.push(instance);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
$effect(() => {
|
|
|
|
|
m.dashboards;
|
|
|
|
|
mountMaintenanceBadges().catch(() => {}); // silently handle async errors
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ── Grid columns with render functions for cells ──
|
|
|
|
|
let gridColumns = $derived.by(() => {
|
|
|
|
|
// Force re-derive when validation statuses or dashboards change
|
|
|
|
|
void m.validationVersion; void m.dashboards;
|
|
|
|
|
return [
|
|
|
|
|
{ key: 'title', label: $t.dashboard?.title || 'Title', sortable: true, class: 'min-w-[250px]',
|
|
|
|
|
render: (d: any) => `<a class="text-primary hover:text-primary-hover hover:underline text-sm font-medium" href="${ROUTES.dashboards.detail(d.id, m.selectedEnv)}">${d.title ?? ''}</a>`, raw: true },
|
|
|
|
|
{ key: 'git_status', label: 'GIT', sortable: true, class: 'min-w-[118px]',
|
|
|
|
|
render: (d: any) => renderGitStatus(d), raw: true },
|
|
|
|
|
{ key: 'validation_status', label: $t.dashboard?.validation || 'Validation', sortable: true, class: 'min-w-[70px]',
|
|
|
|
|
render: (d: any) => renderValidationDot(d), raw: true },
|
|
|
|
|
{ key: 'maintenance', label: $t.maintenance?.maintenance_badge || 'Maint.', sortable: false, class: 'min-w-[110px]',
|
|
|
|
|
render: (d: any) => `<span data-maintenance-dashboard="${d.id}"></span>`, raw: true },
|
|
|
|
|
{ key: 'actions', label: $t.dashboard?.actions || 'Actions', sortable: false, class: 'min-w-[124px]',
|
|
|
|
|
render: (d: any) => renderActions(d), raw: true },
|
|
|
|
|
{ key: 'changed_on', label: $t.dashboard?.changed_on || 'Changed', sortable: true, class: 'min-w-[150px]',
|
|
|
|
|
render: (d: any) => `<span class="text-xs text-text-muted">${d.changedOnLabel ?? '-'}</span>`, raw: true },
|
|
|
|
|
{ key: 'actor', label: $t.dashboard?.owners || 'Owners', sortable: true, class: 'min-w-[150px]',
|
|
|
|
|
render: (d: any) => renderOwners(d), raw: true },
|
|
|
|
|
] as Column[];
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function renderGitStatus(d: any): string {
|
|
|
|
|
const loading = $t.common?.loading || "Loading";
|
|
|
|
|
const repo = $t.dashboard?.status_repo || "Repo";
|
|
|
|
|
const noRepo = $t.dashboard?.status_no_repo || "No Repo";
|
|
|
|
|
const diff = $t.dashboard?.status_changes || "Diff";
|
|
|
|
|
const synced = $t.dashboard?.status_no_changes || "Synced";
|
|
|
|
|
const hasRepo = d.git?.hasRepo;
|
|
|
|
|
const repoClass = hasRepo === null ? 'bg-surface-muted text-text-muted' : hasRepo ? 'bg-primary-light text-primary' : 'bg-surface-muted text-text';
|
|
|
|
|
const repoText = hasRepo === null ? loading : hasRepo ? repo : noRepo;
|
|
|
|
|
let html = `<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 ${repoClass}">${repoText}</span>`;
|
|
|
|
|
if (hasRepo) {
|
|
|
|
|
const changeClass = d.git?.hasChangesForCommit ? 'bg-warning-light text-warning' : 'bg-success-light text-success';
|
|
|
|
|
const changeText = d.git?.hasChangesForCommit ? diff : synced;
|
|
|
|
|
html += `<span class="inline-flex w-fit items-center rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide ${changeClass}">${changeText}</span>`;
|
|
|
|
|
}
|
|
|
|
|
html += '</div>';
|
|
|
|
|
return html;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderValidationDot(d: any): string {
|
|
|
|
|
const vs = m.validationStatuses[d.id];
|
|
|
|
|
if (!vs) return m.validationStatusLoading ? '<span class="inline-flex h-5 w-5 items-center justify-center"><span class="block h-3 w-3 animate-pulse rounded-full bg-surface-muted"></span></span>' : '<span class="text-text-subtle text-xs">-</span>';
|
|
|
|
|
const dotClass = vs.status === "PASS" ? "bg-success" : vs.status === "WARN" ? "bg-warning" : vs.status === "FAIL" ? "bg-destructive" : "bg-border";
|
|
|
|
|
return `<span class="inline-flex h-5 w-5 items-center justify-center rounded-full border border-border-strong cursor-pointer hover:ring-2 hover:ring-border-strong" data-validation-popover="${d.id}" title="${vs.status || 'UNKNOWN'}"><span class="block h-3 w-3 rounded-full ${dotClass}"></span></span>`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderActions(d: any): string {
|
|
|
|
|
return `<div class="flex items-center gap-1.5">
|
|
|
|
|
<button class="inline-flex h-8 w-8 items-center justify-center rounded-md border border-border bg-surface-card text-primary hover:bg-primary-light hover:border-primary-ring transition-colors" data-action="migrate" data-dashboard-id="${d.id}" title="${$t.dashboard?.action_migrate || 'Migrate'}"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg></button>
|
|
|
|
|
<button class="inline-flex h-8 w-8 items-center justify-center rounded-md border border-border bg-surface-card text-warning hover:bg-warning-light hover:border-warning transition-colors" data-action="backup" data-dashboard-id="${d.id}" title="${$t.dashboard?.action_backup || 'Backup'}"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></button>
|
|
|
|
|
</div>`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderOwners(d: any): string {
|
|
|
|
|
if (!d.owners?.length) return '<span class="text-text-subtle">-</span>';
|
|
|
|
|
return `<div class="flex flex-wrap items-center gap-1">${d.owners.map((o: string) => `<span class="rounded bg-surface-muted px-2 py-0.5 text-xs text-text truncate max-w-[180px]" title="${o}">${o}</span>`).join('')}</div>`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Delegate click handlers for rendered HTML buttons (validation popover + actions)
|
|
|
|
|
function handleGridClick(event: MouseEvent) {
|
|
|
|
|
const target = event.target as HTMLElement;
|
|
|
|
|
const btn = target.closest('[data-action]') as HTMLElement | null;
|
|
|
|
|
if (btn) {
|
|
|
|
|
const action = btn.getAttribute('data-action');
|
|
|
|
|
const dashId = Number(btn.getAttribute('data-dashboard-id'));
|
|
|
|
|
const dashboard = m.dashboards.find((d: any) => d.id === dashId);
|
|
|
|
|
if (dashboard) m.handleAction(dashboard, action!);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const dot = target.closest('[data-validation-popover]') as HTMLElement | null;
|
|
|
|
|
if (dot) {
|
|
|
|
|
const dashId = Number(dot.getAttribute('data-validation-popover'));
|
|
|
|
|
m.toggleValidationPopover(dashId, event);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (typeof document !== 'undefined') document.addEventListener('click', handleGridClick);
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<div class="mx-auto w-full px-4 lg:px-8 space-y-6">
|
|
|
|
|
<!-- Header -->
|
|
|
|
|
<div class="flex items-center justify-between">
|
|
|
|
|
<h1 class="text-2xl font-bold text-text">
|
|
|
|
|
{$t.nav?.dashboards}
|
|
|
|
|
</h1>
|
|
|
|
|
<h1 class="text-2xl font-bold text-text">{$t.nav?.dashboards}</h1>
|
|
|
|
|
<div class="flex items-center space-x-4">
|
|
|
|
|
<button
|
|
|
|
|
class="inline-flex items-center justify-center rounded-lg border border-primary-ring bg-primary-light px-4 py-2 text-sm font-medium text-primary transition-colors hover:bg-primary-light"
|
|
|
|
|
onclick={() => {
|
|
|
|
|
const ids = Array.from(m.selectedIds);
|
|
|
|
|
goto(ROUTES.validationTasks.new(ids.length > 0 ? ids.map(Number) : undefined));
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
+ {$t.dashboard?.create_validation_task || "Create Validation Task"}
|
|
|
|
|
</button>
|
|
|
|
|
<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"
|
|
|
|
|
onclick={() => m.loadDashboards()}
|
|
|
|
|
>
|
|
|
|
|
{$t.common?.refresh}
|
|
|
|
|
</button>
|
|
|
|
|
<button class="inline-flex items-center justify-center rounded-lg border border-primary-ring bg-primary-light px-4 py-2 text-sm font-medium text-primary transition-colors hover:bg-primary-light" onclick={() => { const ids = Array.from(m.selectedIds); goto(ROUTES.validationTasks.new(ids.length > 0 ? ids.map(Number) : undefined)); }}>
|
|
|
|
|
+ {$t.dashboard?.create_validation_task || "Create Validation Task"}</button>
|
|
|
|
|
<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" onclick={() => m.loadDashboards()}>{$t.common?.refresh}</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{#if m.effectiveProfileFilter?.applied}
|
|
|
|
|
<div class="rounded-lg border border-primary-ring bg-primary-light px-4 py-2 text-sm text-primary">
|
|
|
|
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
|
|
|
|
<span class="font-medium">
|
|
|
|
|
{$t.profile?.filter_badge_active || "Profile filters active"}
|
|
|
|
|
</span>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
class="rounded border border-primary bg-surface-card px-2.5 py-1 text-xs font-medium text-primary hover:bg-primary-light"
|
|
|
|
|
onclick={() => m.handleTemporaryShowAll()}
|
|
|
|
|
>
|
|
|
|
|
{$t.profile?.filter_show_all_temporarily || "Show all dashboards temporarily"}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
<span class="font-medium">{$t.profile?.filter_badge_active || "Profile filters active"}</span>
|
|
|
|
|
<button type="button" class="ml-3 rounded border border-primary bg-surface-card px-2.5 py-1 text-xs font-medium text-primary hover:bg-primary-light" onclick={() => m.handleTemporaryShowAll()}>{$t.profile?.filter_show_all_temporarily || "Show all"}</button>
|
|
|
|
|
</div>
|
|
|
|
|
{:else if m.effectiveProfileFilter?.override_show_all}
|
|
|
|
|
<div class="rounded-lg border border-warning bg-warning-light px-4 py-2 text-sm text-warning">
|
|
|
|
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
|
|
|
|
<span class="font-medium">
|
|
|
|
|
{$t.profile?.filter_badge_override || "Showing all dashboards temporarily"}
|
|
|
|
|
</span>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
class="rounded border border-warning bg-surface-card px-2.5 py-1 text-xs font-medium text-warning hover:bg-warning-light"
|
|
|
|
|
onclick={() => m.handleRestoreProfileFilter()}
|
|
|
|
|
>
|
|
|
|
|
{$t.profile?.filter_restore_default || "Restore default filter"}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
<span class="font-medium">{$t.profile?.filter_badge_override || "Showing all"}</span>
|
|
|
|
|
<button type="button" class="ml-3 rounded border border-warning bg-surface-card px-2.5 py-1 text-xs font-medium text-warning hover:bg-warning-light" onclick={() => m.handleRestoreProfileFilter()}>{$t.profile?.filter_restore_default || "Restore filter"}</button>
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
|
|
|
<!-- Error Banner -->
|
|
|
|
|
{#if m.error}
|
|
|
|
|
<div
|
|
|
|
|
class="bg-destructive-light border border-destructive-ring text-destructive px-4 py-3 rounded mb-4 flex items-center justify-between"
|
|
|
|
|
>
|
|
|
|
|
<span>{m.error}</span>
|
|
|
|
|
<Button variant="destructive" onclick={() => m.loadDashboards()}>{$t.common?.retry}</Button>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="bg-destructive-light border border-destructive-ring text-destructive px-4 py-3 rounded flex items-center justify-between"><span>{m.error}</span><Button variant="destructive" onclick={() => m.loadDashboards()}>{$t.common?.retry}</Button></div>
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
|
|
|
<!-- Loading State -->
|
|
|
|
|
{#if m.isLoading}
|
|
|
|
|
<div
|
|
|
|
|
class="bg-surface-card border border-border rounded-xl shadow-sm overflow-hidden"
|
|
|
|
|
>
|
|
|
|
|
<div
|
|
|
|
|
class="grid gap-4 px-6 py-3 bg-surface-muted border-b border-border font-semibold text-[11px] uppercase tracking-wide text-text-muted"
|
|
|
|
|
style="width: max(100%, 1460px); grid-template-columns: 40px minmax(250px,2.3fr) minmax(118px,0.9fr) minmax(70px,0.5fr) minmax(110px,0.7fr) minmax(280px,2fr) minmax(150px,1fr) 124px;"
|
|
|
|
|
>
|
|
|
|
|
<Skeleton variant="line" />
|
|
|
|
|
<Skeleton variant="line" />
|
|
|
|
|
<Skeleton variant="line" />
|
|
|
|
|
<Skeleton variant="line" />
|
|
|
|
|
<Skeleton variant="line" />
|
|
|
|
|
<Skeleton variant="line" />
|
|
|
|
|
<Skeleton variant="line" />
|
|
|
|
|
<Skeleton variant="line" />
|
|
|
|
|
</div>
|
|
|
|
|
{#each Array(5) as _}
|
|
|
|
|
<div
|
|
|
|
|
class="grid gap-4 px-6 py-4 border-b border-border last:border-b-0 hover:bg-surface-muted transition-colors"
|
|
|
|
|
style="width: max(100%, 1460px); grid-template-columns: 40px minmax(250px,2.3fr) minmax(118px,0.9fr) minmax(70px,0.5fr) minmax(110px,0.7fr) minmax(280px,2fr) minmax(150px,1fr) 124px;"
|
|
|
|
|
>
|
|
|
|
|
<Skeleton variant="line" />
|
|
|
|
|
<Skeleton variant="line" />
|
|
|
|
|
<Skeleton variant="line" />
|
|
|
|
|
<Skeleton variant="line" />
|
|
|
|
|
<Skeleton variant="line" />
|
|
|
|
|
<Skeleton variant="line" />
|
|
|
|
|
<Skeleton variant="line" />
|
|
|
|
|
<Skeleton variant="line" />
|
|
|
|
|
</div>
|
|
|
|
|
{/each}
|
|
|
|
|
</div>
|
|
|
|
|
{:else if shouldShowEnvironmentZeroState}
|
|
|
|
|
<div class="relative overflow-hidden rounded-3xl border border-border bg-surface-page p-8 shadow-sm">
|
|
|
|
|
<div class="max-w-2xl">
|
|
|
|
|
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-primary">
|
|
|
|
|
{$t.dashboard?.setup_badge || "Initial setup"}
|
|
|
|
|
</p>
|
|
|
|
|
<h2 class="mt-3 text-2xl font-semibold text-text">
|
|
|
|
|
{$t.dashboard?.setup_empty_title || "No environments configured yet"}
|
|
|
|
|
</h2>
|
|
|
|
|
<p class="mt-3 text-sm leading-6 text-text-muted">
|
|
|
|
|
{$t.dashboard?.setup_empty_body ||
|
|
|
|
|
"Add the first Superset environment to unlock dashboards, datasets, backups, and migrations."}
|
|
|
|
|
</p>
|
|
|
|
|
<div class="mt-6 flex flex-wrap gap-3">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
class="rounded-xl bg-primary px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-primary-hover"
|
|
|
|
|
>
|
|
|
|
|
{$t.dashboard?.setup_start || "Start setup"}
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
class="rounded-xl border border-border-strong px-4 py-2.5 text-sm font-medium text-text transition hover:border-border-strong hover:text-text"
|
|
|
|
|
onclick={() => goto(ROUTES.settings.environmentsTab())}
|
|
|
|
|
>
|
|
|
|
|
{$t.dashboard?.setup_open_advanced || "Advanced settings"}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{:else if m.dashboards.length === 0}
|
|
|
|
|
{#if shouldShowEnvironmentZeroState}
|
|
|
|
|
<div class="relative overflow-hidden rounded-3xl border border-border bg-surface-page p-8 shadow-sm"><div class="max-w-2xl"><p class="text-xs font-semibold uppercase tracking-[0.24em] text-primary">{$t.dashboard?.setup_badge || "Initial setup"}</p><h2 class="mt-3 text-2xl font-semibold text-text">{$t.dashboard?.setup_empty_title || "No environments"}</h2><p class="mt-3 text-sm leading-6 text-text-muted">{$t.dashboard?.setup_empty_body || "Add environment to begin."}</p><div class="mt-6 flex flex-wrap gap-3"><button type="button" class="rounded-xl bg-primary px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-primary-hover">{$t.dashboard?.setup_start || "Start setup"}</button><button type="button" class="rounded-xl border border-border-strong px-4 py-2.5 text-sm font-medium text-text transition hover:border-border-strong hover:text-text" onclick={() => goto(ROUTES.settings.environmentsTab())}>{$t.dashboard?.setup_open_advanced || "Advanced settings"}</button></div></div></div>
|
|
|
|
|
{:else if !m.isLoading && m.dashboards.length === 0}
|
|
|
|
|
{#if m.effectiveProfileFilter?.applied}
|
|
|
|
|
<EmptyState
|
|
|
|
|
title={$t.profile?.filter_empty_state || "No dashboards found for active profile filters. Try adjusting your filter settings."}
|
|
|
|
|
actionLabel={$t.profile?.filter_show_all_temporarily || "Show all dashboards temporarily"}
|
|
|
|
|
onAction={() => m.handleTemporaryShowAll()}
|
|
|
|
|
/>
|
|
|
|
|
<EmptyState title={$t.profile?.filter_empty_state || "No dashboards found"} actionLabel={$t.profile?.filter_show_all_temporarily || "Show all"} onAction={() => m.handleTemporaryShowAll()} />
|
|
|
|
|
{:else}
|
|
|
|
|
<EmptyState title={$t.dashboard?.empty} />
|
|
|
|
|
{/if}
|
|
|
|
|
{:else}
|
|
|
|
|
<!-- Toolbar -->
|
|
|
|
|
<div class="flex items-center justify-between mb-4 gap-4">
|
|
|
|
|
<div class="flex items-center gap-2">
|
|
|
|
|
<Button variant="secondary" size="sm" onclick={() => m.handleSelectAll()} disabled={m.total === 0}>
|
|
|
|
|
{m.isAllSelected ? $t.dashboard?.deselect_all : $t.dashboard?.select_all}
|
|
|
|
|
</Button>
|
|
|
|
|
<Button variant="secondary" size="sm" onclick={() => m.handleSelectVisible()} disabled={m.dashboards.length === 0}>
|
|
|
|
|
{m.isAllVisibleSelected ? $t.dashboard?.deselect_visible : $t.dashboard?.select_visible}
|
|
|
|
|
</Button>
|
|
|
|
|
{#if m.selectedIds.size > 0}
|
|
|
|
|
<span class="text-sm text-text-muted">
|
|
|
|
|
{($t.dashboard?.selected_count ?? '{count} selected').replace(
|
|
|
|
|
"{count}",
|
|
|
|
|
String(m.selectedIds.size),
|
|
|
|
|
)}
|
|
|
|
|
</span>
|
|
|
|
|
{/if}
|
|
|
|
|
<Button variant="secondary" size="sm" onclick={() => m.handleSelectAll()} disabled={m.total === 0}>{m.isAllSelected ? $t.dashboard?.deselect_all : $t.dashboard?.select_all}</Button>
|
|
|
|
|
<Button variant="secondary" size="sm" onclick={() => m.handleSelectVisible()} disabled={m.dashboards.length === 0}>{m.isAllVisibleSelected ? $t.dashboard?.deselect_visible : $t.dashboard?.select_visible}</Button>
|
|
|
|
|
{#if m.selectedIds.size > 0}<span class="text-sm text-text-muted">{($t.dashboard?.selected_count ?? '{count} selected').replace("{count}", String(m.selectedIds.size))}</span>{/if}
|
|
|
|
|
</div>
|
|
|
|
|
<div class="w-80">
|
|
|
|
|
<SearchableMultiSelect
|
|
|
|
|
options={m.searchableDashboardOptions}
|
|
|
|
|
bind:selected={m.filterDashboardIds}
|
|
|
|
|
placeholder={$t.dashboard?.search}
|
|
|
|
|
selectAllLabel={$t.dashboard?.select_all}
|
|
|
|
|
clearLabel={$t.common?.clear || "Clear"}
|
|
|
|
|
emptyMessage="No dashboards match"
|
|
|
|
|
loading={m.searchableDashboardLoading}
|
|
|
|
|
onSearch={m.loadDashboardSearchOptions}
|
|
|
|
|
debounceMs={300}
|
|
|
|
|
/>
|
|
|
|
|
<div class="flex items-center gap-3">
|
|
|
|
|
<select class="rounded border border-border-strong bg-surface-card px-2 py-1.5 text-xs text-text" value={m.pageSize} onchange={(e) => handlePageSizeChange(Number((e.target as HTMLSelectElement).value))}>
|
|
|
|
|
{#each [5, 10, 25, 50, 100] as size}<option value={size}>{size}</option>{/each}
|
|
|
|
|
</select>
|
|
|
|
|
<div class="w-80"><SearchableMultiSelect options={m.searchableDashboardOptions} bind:selected={m.filterDashboardIds} placeholder={$t.dashboard?.search} selectAllLabel={$t.dashboard?.select_all} clearLabel={$t.common?.clear || "Clear"} emptyMessage="No dashboards match" loading={m.searchableDashboardLoading} onSearch={m.loadDashboardSearchOptions} debounceMs={300} /></div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Dashboard Grid -->
|
|
|
|
|
<div class="bg-surface-card border border-border rounded-xl shadow-sm">
|
|
|
|
|
<div class="overflow-x-auto overflow-y-visible">
|
|
|
|
|
<!-- Grid Header -->
|
|
|
|
|
<div
|
|
|
|
|
class="grid gap-4 px-6 py-3 bg-surface-muted border-b border-border font-semibold text-[11px] uppercase tracking-wide text-text-muted"
|
|
|
|
|
style="width: max(100%, 1460px); grid-template-columns: 40px minmax(250px,2.3fr) minmax(118px,0.9fr) minmax(70px,0.5fr) minmax(110px,0.7fr) minmax(280px,2fr) minmax(150px,1fr) 124px;"
|
|
|
|
|
>
|
|
|
|
|
<div></div>
|
|
|
|
|
{#key localValidationVersion}
|
|
|
|
|
<DashboardDataGrid
|
|
|
|
|
data={m.dashboards}
|
|
|
|
|
keyField="id"
|
|
|
|
|
columns={gridColumns}
|
|
|
|
|
selectable={true}
|
|
|
|
|
bind:selectedIds={selectedIdsArray}
|
|
|
|
|
hideFilter={true}
|
|
|
|
|
paginated={true}
|
|
|
|
|
pageSize={m.pageSize}
|
|
|
|
|
bind:page={pageZero}
|
|
|
|
|
serverTotal={m.total}
|
|
|
|
|
serverTotalPages={m.totalPages}
|
|
|
|
|
loading={m.isLoading}
|
|
|
|
|
loadingRows={Math.min(m.pageSize, 10)}
|
|
|
|
|
emptyText={$t.dashboard?.no_dashboards || "No dashboards found"}
|
|
|
|
|
>
|
|
|
|
|
{#snippet header(col)}
|
|
|
|
|
<div class="flex items-center gap-2">
|
|
|
|
|
<button
|
|
|
|
|
class="hover:text-text transition-colors"
|
|
|
|
|
onclick={() => m.handleSort("title")}
|
|
|
|
|
>
|
|
|
|
|
{$t.dashboard?.title}
|
|
|
|
|
{getSortIndicator(m.sortColumn, "title", m.sortDirection)}
|
|
|
|
|
</button>
|
|
|
|
|
<div class="relative column-filter">
|
|
|
|
|
<button
|
|
|
|
|
class="rounded border px-1.5 py-0.5 text-xs transition-colors {m.hasColumnFilter(
|
|
|
|
|
'title',
|
|
|
|
|
)
|
|
|
|
|
? 'border-primary-ring text-primary bg-primary-light'
|
|
|
|
|
: 'border-border-strong text-text-muted hover:text-text'}"
|
|
|
|
|
onclick={(event) => m.toggleFilterDropdown("title", event, 256)}
|
|
|
|
|
title={$t.dashboard?.column_filter}
|
|
|
|
|
>
|
|
|
|
|
▾
|
|
|
|
|
{#if col.sortable}
|
|
|
|
|
<button class="hover:text-text transition-colors" onclick={() => m.handleSort(col.key)}>
|
|
|
|
|
{col.label} {getSortIndicator(m.sortColumn, col.key, m.sortDirection)}
|
|
|
|
|
</button>
|
|
|
|
|
{#if m.openFilterColumn === "title"}
|
|
|
|
|
<ColumnFilterPopover
|
|
|
|
|
column="title"
|
|
|
|
|
bind:filterValues={m.columnFilters.title}
|
|
|
|
|
bind:searchText={m.columnFilterSearch.title}
|
|
|
|
|
options={m.getVisibleFilterOptions("title")}
|
|
|
|
|
position={m.filterDropdownPosition}
|
|
|
|
|
onToggleFilter={(v: string, c: boolean) => m.toggleFilterValue("title", v, c)}
|
|
|
|
|
onClear={() => m.clearColumnFilter("title")}
|
|
|
|
|
/>
|
|
|
|
|
{#if col.key === 'title'}
|
|
|
|
|
<div class="relative column-filter"><button class="rounded border px-1.5 py-0.5 text-xs transition-colors {m.hasColumnFilter('title') ? 'border-primary-ring text-primary bg-primary-light' : 'border-border-strong text-text-muted hover:text-text'}" onclick={(event) => m.toggleFilterDropdown("title", event, 256)} title={$t.dashboard?.column_filter}>▾</button>
|
|
|
|
|
{#if m.openFilterColumn === "title"}<ColumnFilterPopover column="title" bind:filterValues={m.columnFilters.title} bind:searchText={m.columnFilterSearch.title} options={m.getVisibleFilterOptions("title")} position={m.filterDropdownPosition} widthPx={256} onToggleFilter={(v: string, c: boolean) => m.toggleFilterValue("title", v, c)} onClear={() => m.clearColumnFilter("title")} />{/if}</div>
|
|
|
|
|
{:else if col.key === 'git_status'}
|
|
|
|
|
<div class="relative column-filter"><button class="rounded border px-1.5 py-0.5 text-xs transition-colors {m.hasColumnFilter('git_status') ? 'border-primary-ring text-primary bg-primary-light' : 'border-border-strong text-text-muted hover:text-text'}" onclick={(event) => m.toggleFilterDropdown("git_status", event, 256)} title={$t.dashboard?.column_filter}>▾</button>
|
|
|
|
|
{#if m.openFilterColumn === "git_status"}<ColumnFilterPopover column="git_status" bind:filterValues={m.columnFilters.git_status} bind:searchText={m.columnFilterSearch.git_status} options={m.getVisibleFilterOptions("git_status")} position={m.filterDropdownPosition} widthPx={256} getLabel={(v: string) => m.getFilterOptionLabel("git_status", v)} onToggleFilter={(v: string, c: boolean) => m.toggleFilterValue("git_status", v, c)} onClear={() => m.clearColumnFilter("git_status")} />{/if}</div>
|
|
|
|
|
{:else if col.key === 'validation_status'}
|
|
|
|
|
<div class="relative column-filter"><button class="rounded border px-1.5 py-0.5 text-xs transition-colors {m.hasColumnFilter('validation_status') ? 'border-primary-ring text-primary bg-primary-light' : 'border-border-strong text-text-muted hover:text-text'}" onclick={(event) => m.toggleFilterDropdown("validation_status", event, 200)} title={$t.dashboard?.column_filter}>▾</button>
|
|
|
|
|
{#if m.openFilterColumn === "validation_status"}<ColumnFilterPopover column="validation_status" bind:filterValues={m.columnFilters.validation_status} bind:searchText={m.columnFilterSearch.validation_status} options={[]} staticOptions={["pass", "warn", "fail", "unknown"]} showSearch={false} position={m.filterDropdownPosition} widthPx={200} onToggleFilter={(v: string, c: boolean) => m.toggleFilterValue("validation_status", v, c)} onClear={() => m.clearColumnFilter("validation_status")} />{/if}</div>
|
|
|
|
|
{:else if col.key === 'changed_on'}
|
|
|
|
|
<div class="relative column-filter"><button class="rounded border px-1.5 py-0.5 text-xs transition-colors {m.hasColumnFilter('changed_on') ? 'border-primary-ring text-primary bg-primary-light' : 'border-border-strong text-text-muted hover:text-text'}" onclick={(event) => m.toggleFilterDropdown("changed_on", event, 256)} title={$t.dashboard?.column_filter}>▾</button>
|
|
|
|
|
{#if m.openFilterColumn === "changed_on"}<ColumnFilterPopover column="changed_on" bind:filterValues={m.columnFilters.changed_on} bind:searchText={m.columnFilterSearch.changed_on} options={m.getVisibleFilterOptions("changed_on")} position={m.filterDropdownPosition} widthPx={256} onToggleFilter={(v: string, c: boolean) => m.toggleFilterValue("changed_on", v, c)} onClear={() => m.clearColumnFilter("changed_on")} />{/if}</div>
|
|
|
|
|
{:else if col.key === 'actor'}
|
|
|
|
|
<div class="relative column-filter"><button class="rounded border px-1.5 py-0.5 text-xs transition-colors {m.hasColumnFilter('actor') ? 'border-primary-ring text-primary bg-primary-light' : 'border-border-strong text-text-muted hover:text-text'}" onclick={(event) => m.toggleFilterDropdown("actor", event, 288)} title={$t.dashboard?.column_filter}>▾</button>
|
|
|
|
|
{#if m.openFilterColumn === "actor"}<ColumnFilterPopover column="actor" bind:filterValues={m.columnFilters.actor} bind:searchText={m.columnFilterSearch.actor} options={m.getVisibleFilterOptions("actor")} position={m.filterDropdownPosition} widthPx={288} onToggleFilter={(v: string, c: boolean) => m.toggleFilterValue("actor", v, c)} onClear={() => m.clearColumnFilter("actor")} />{/if}</div>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
{:else}
|
|
|
|
|
{col.label}
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
<div class="flex items-center gap-2">
|
|
|
|
|
<button
|
|
|
|
|
class="hover:text-text transition-colors"
|
|
|
|
|
onclick={() => m.handleSort("git_status")}
|
|
|
|
|
>
|
|
|
|
|
GIT {getSortIndicator(m.sortColumn, "git_status", m.sortDirection)}
|
|
|
|
|
</button>
|
|
|
|
|
<div class="relative column-filter">
|
|
|
|
|
<button
|
|
|
|
|
class="rounded border px-1.5 py-0.5 text-xs transition-colors {m.hasColumnFilter(
|
|
|
|
|
'git_status',
|
|
|
|
|
)
|
|
|
|
|
? 'border-primary-ring text-primary bg-primary-light'
|
|
|
|
|
: 'border-border-strong text-text-muted hover:text-text'}"
|
|
|
|
|
onclick={(event) =>
|
|
|
|
|
m.toggleFilterDropdown("git_status", event, 256)}
|
|
|
|
|
title={$t.dashboard?.column_filter}
|
|
|
|
|
>
|
|
|
|
|
▾
|
|
|
|
|
</button>
|
|
|
|
|
{#if m.openFilterColumn === "git_status"}
|
|
|
|
|
<ColumnFilterPopover
|
|
|
|
|
column="git_status"
|
|
|
|
|
bind:filterValues={m.columnFilters.git_status}
|
|
|
|
|
bind:searchText={m.columnFilterSearch.git_status}
|
|
|
|
|
options={m.getVisibleFilterOptions("git_status")}
|
|
|
|
|
position={m.filterDropdownPosition}
|
|
|
|
|
onToggleFilter={(v: string, c: boolean) => m.toggleFilterValue("git_status", v, c)}
|
|
|
|
|
onClear={() => m.clearColumnFilter("git_status")}
|
|
|
|
|
/>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="flex items-center gap-2">
|
|
|
|
|
<button
|
|
|
|
|
class="hover:text-text transition-colors"
|
|
|
|
|
onclick={() => m.handleSort("validation_status")}
|
|
|
|
|
>
|
|
|
|
|
{$t.dashboard?.validation || "Validation"}
|
|
|
|
|
{getSortIndicator(m.sortColumn, "validation_status", m.sortDirection)}
|
|
|
|
|
</button>
|
|
|
|
|
<div class="relative column-filter">
|
|
|
|
|
<button
|
|
|
|
|
class="rounded border px-1.5 py-0.5 text-xs transition-colors {m.hasColumnFilter(
|
|
|
|
|
'validation_status',
|
|
|
|
|
)
|
|
|
|
|
? 'border-primary-ring text-primary bg-primary-light'
|
|
|
|
|
: 'border-border-strong text-text-muted hover:text-text'}"
|
|
|
|
|
onclick={(event) =>
|
|
|
|
|
m.toggleFilterDropdown("validation_status", event, 200)}
|
|
|
|
|
title={$t.dashboard?.column_filter}
|
|
|
|
|
>
|
|
|
|
|
▾
|
|
|
|
|
</button>
|
|
|
|
|
{#if m.openFilterColumn === "validation_status"}
|
|
|
|
|
<ColumnFilterPopover
|
|
|
|
|
column="validation_status"
|
|
|
|
|
bind:filterValues={m.columnFilters.validation_status}
|
|
|
|
|
bind:searchText={m.columnFilterSearch.validation_status}
|
|
|
|
|
options={[]}
|
|
|
|
|
staticOptions={["pass", "warn", "fail", "unknown"]}
|
|
|
|
|
position={m.filterDropdownPosition}
|
|
|
|
|
widthPx={200}
|
|
|
|
|
showSearch={false}
|
|
|
|
|
onToggleFilter={(v: string, c: boolean) => m.toggleFilterValue("validation_status", v, c)}
|
|
|
|
|
/>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="flex items-center text-xs text-text-muted uppercase">
|
|
|
|
|
{$t.maintenance?.maintenance_badge || "Maint."}
|
|
|
|
|
</div>
|
|
|
|
|
<div class="flex items-center gap-2">
|
|
|
|
|
{$t.dashboard?.actions}
|
|
|
|
|
</div>
|
|
|
|
|
<div class="flex items-center gap-2">
|
|
|
|
|
<button
|
|
|
|
|
class="hover:text-text transition-colors"
|
|
|
|
|
onclick={() => m.handleSort("changed_on")}
|
|
|
|
|
>
|
|
|
|
|
{$t.dashboard?.changed_on}
|
|
|
|
|
{getSortIndicator(m.sortColumn, "changed_on", m.sortDirection)}
|
|
|
|
|
</button>
|
|
|
|
|
<div class="relative column-filter">
|
|
|
|
|
<button
|
|
|
|
|
class="rounded border px-1.5 py-0.5 text-xs transition-colors {m.hasColumnFilter(
|
|
|
|
|
'changed_on',
|
|
|
|
|
)
|
|
|
|
|
? 'border-primary-ring text-primary bg-primary-light'
|
|
|
|
|
: 'border-border-strong text-text-muted hover:text-text'}"
|
|
|
|
|
onclick={(event) =>
|
|
|
|
|
m.toggleFilterDropdown("changed_on", event, 256)}
|
|
|
|
|
title={$t.dashboard?.column_filter}
|
|
|
|
|
>
|
|
|
|
|
▾
|
|
|
|
|
</button>
|
|
|
|
|
{#if m.openFilterColumn === "changed_on"}
|
|
|
|
|
<ColumnFilterPopover
|
|
|
|
|
column="changed_on"
|
|
|
|
|
bind:filterValues={m.columnFilters.changed_on}
|
|
|
|
|
bind:searchText={m.columnFilterSearch.changed_on}
|
|
|
|
|
options={m.getVisibleFilterOptions("changed_on")}
|
|
|
|
|
position={m.filterDropdownPosition}
|
|
|
|
|
onToggleFilter={(v: string, c: boolean) => m.toggleFilterValue("changed_on", v, c)}
|
|
|
|
|
onClear={() => m.clearColumnFilter("changed_on")}
|
|
|
|
|
/>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="flex items-center gap-2">
|
|
|
|
|
<button
|
|
|
|
|
class="hover:text-text transition-colors"
|
|
|
|
|
onclick={() => m.handleSort("actor")}
|
|
|
|
|
>
|
|
|
|
|
{$t.dashboard?.owners || "Owners"}
|
|
|
|
|
{getSortIndicator(m.sortColumn, "actor", m.sortDirection)}
|
|
|
|
|
</button>
|
|
|
|
|
<div class="relative column-filter">
|
|
|
|
|
<button
|
|
|
|
|
class="rounded border px-1.5 py-0.5 text-xs transition-colors {m.hasColumnFilter(
|
|
|
|
|
'actor',
|
|
|
|
|
)
|
|
|
|
|
? 'border-primary-ring text-primary bg-primary-light'
|
|
|
|
|
: 'border-border-strong text-text-muted hover:text-text'}"
|
|
|
|
|
onclick={(event) => m.toggleFilterDropdown("actor", event, 288)}
|
|
|
|
|
title={$t.dashboard?.column_filter}
|
|
|
|
|
>
|
|
|
|
|
▾
|
|
|
|
|
</button>
|
|
|
|
|
{#if m.openFilterColumn === "actor"}
|
|
|
|
|
<ColumnFilterPopover
|
|
|
|
|
column="actor"
|
|
|
|
|
bind:filterValues={m.columnFilters.actor}
|
|
|
|
|
bind:searchText={m.columnFilterSearch.actor}
|
|
|
|
|
options={m.getVisibleFilterOptions("actor")}
|
|
|
|
|
position={m.filterDropdownPosition}
|
|
|
|
|
widthPx={288}
|
|
|
|
|
onToggleFilter={(v: string, c: boolean) => m.toggleFilterValue("actor", v, c)}
|
|
|
|
|
onClear={() => m.clearColumnFilter("actor")}
|
|
|
|
|
/>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Grid Rows -->
|
|
|
|
|
{#each m.dashboards as dashboard}
|
|
|
|
|
<div
|
|
|
|
|
class="grid items-center gap-4 px-6 py-3 border-b border-border last:border-b-0 hover:bg-surface-muted transition-colors text-[13px]"
|
|
|
|
|
style="width: max(100%, 1460px); grid-template-columns: 40px minmax(250px,2.3fr) minmax(118px,0.9fr) minmax(70px,0.5fr) minmax(110px,0.7fr) minmax(280px,2fr) minmax(150px,1fr) 124px;"
|
|
|
|
|
>
|
|
|
|
|
<!-- Checkbox -->
|
|
|
|
|
<div>
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={m.selectedIds.has(dashboard.id)}
|
|
|
|
|
onchange={(e) => m.handleCheckboxChange(dashboard, e)}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Title -->
|
|
|
|
|
<div class="font-medium text-sm text-text">
|
|
|
|
|
<button
|
|
|
|
|
class="text-left text-primary hover:text-primary-hover hover:underline transition-colors"
|
|
|
|
|
onclick={() => m.navigateToDashboardDetail(dashboard)}
|
|
|
|
|
title={$t.dashboard?.open_overview}
|
|
|
|
|
>
|
|
|
|
|
{dashboard.title}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Git Status -->
|
|
|
|
|
<div>
|
|
|
|
|
<div class="flex flex-col gap-1">
|
|
|
|
|
<span
|
|
|
|
|
class="inline-flex w-fit items-center rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide {dashboard.git?.hasRepo === null
|
|
|
|
|
? 'bg-surface-muted text-text-muted'
|
|
|
|
|
: dashboard.git?.hasRepo
|
|
|
|
|
? 'bg-primary-light text-primary'
|
|
|
|
|
: 'bg-surface-muted text-text'}"
|
|
|
|
|
>
|
|
|
|
|
{dashboard.git?.hasRepo === null
|
|
|
|
|
? $t.common?.loading || "Loading"
|
|
|
|
|
: dashboard.git?.hasRepo
|
|
|
|
|
? $t.dashboard?.status_repo || "Repo"
|
|
|
|
|
: $t.dashboard?.status_no_repo || "No Repo"}
|
|
|
|
|
</span>
|
|
|
|
|
{#if dashboard.git?.hasRepo}
|
|
|
|
|
<span
|
|
|
|
|
class="inline-flex w-fit items-center rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide {dashboard
|
|
|
|
|
.git?.hasChangesForCommit
|
|
|
|
|
? 'bg-warning-light text-warning'
|
|
|
|
|
: 'bg-success-light text-success'}"
|
|
|
|
|
>
|
|
|
|
|
{dashboard.git?.hasChangesForCommit
|
|
|
|
|
? $t.dashboard?.status_changes || "Diff"
|
|
|
|
|
: $t.dashboard?.status_no_changes || "Synced"}
|
|
|
|
|
</span>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Validation Status Indicator -->
|
|
|
|
|
<div class="relative">
|
|
|
|
|
{#if m.validationStatuses[dashboard.id]}
|
|
|
|
|
{@const vs = m.validationStatuses[dashboard.id]}
|
|
|
|
|
{@const dotClass = vs.status === "PASS" ? "bg-success" : vs.status === "WARN" ? "bg-warning" : vs.status === "FAIL" ? "bg-destructive" : "bg-border"}
|
|
|
|
|
<button
|
|
|
|
|
class="inline-flex h-5 w-5 items-center justify-center rounded-full border border-border-strong transition-colors hover:ring-2 hover:ring-border-strong"
|
|
|
|
|
onclick={(e) => m.toggleValidationPopover(dashboard.id, e)}
|
|
|
|
|
title={vs.status || "UNKNOWN"}
|
|
|
|
|
aria-label={$t.dashboard?.validation_status || "Validation status"}
|
|
|
|
|
>
|
|
|
|
|
<span class="block h-3 w-3 rounded-full {dotClass}"></span>
|
|
|
|
|
</button>
|
|
|
|
|
{:else if m.validationStatusLoading}
|
|
|
|
|
<span class="inline-flex h-5 w-5 items-center justify-center">
|
|
|
|
|
<span class="block h-3 w-3 animate-pulse rounded-full bg-surface-muted"></span>
|
|
|
|
|
</span>
|
|
|
|
|
{:else}
|
|
|
|
|
<span class="text-text-subtle text-xs">-</span>
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
|
|
|
<!-- Validation Popover -->
|
|
|
|
|
{#if m.openValidationPopover === dashboard.id}
|
|
|
|
|
<div
|
|
|
|
|
class="fixed z-50 w-80 rounded-lg border border-border bg-surface-card shadow-lg"
|
|
|
|
|
style="left: {m.validationPopoverPosition.left}px; top: {m.validationPopoverPosition.top}px;"
|
|
|
|
|
onclick={(e) => e.stopPropagation()}
|
|
|
|
|
>
|
|
|
|
|
<div class="border-b border-border px-3 py-2 text-xs font-semibold uppercase tracking-wide text-text-muted">
|
|
|
|
|
{$t.dashboard?.validation_history || "Validation History"}
|
|
|
|
|
</div>
|
|
|
|
|
{#if m.validationStatuses[dashboard.id]?.history?.length > 0}
|
|
|
|
|
<div class="max-h-48 overflow-y-auto">
|
|
|
|
|
{#each m.validationStatuses[dashboard.id].history.slice(0, 3) as run}
|
|
|
|
|
<div class="flex items-center justify-between px-3 py-2 text-xs border-b border-border last:border-b-0">
|
|
|
|
|
<div class="flex items-center gap-2">
|
|
|
|
|
<span class="block h-2.5 w-2.5 rounded-full {run.status === 'PASS' ? 'bg-success' : run.status === 'WARN' ? 'bg-warning' : run.status === 'FAIL' ? 'bg-destructive' : 'bg-border'}"></span>
|
|
|
|
|
<span class="text-text">{run.status || "UNKNOWN"}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="flex items-center gap-2">
|
|
|
|
|
<span class="text-text-subtle">{run.task_name || "-"}</span>
|
|
|
|
|
<span class="text-text-subtle">{formatDate(run.last_run_at)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{/each}
|
|
|
|
|
</div>
|
|
|
|
|
{:else}
|
|
|
|
|
<div class="px-3 py-3 text-xs text-text-subtle text-center">
|
|
|
|
|
{$t.dashboard?.no_validation_runs || "No validation runs yet"}
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
<div class="border-t border-border px-3 py-2">
|
|
|
|
|
<a
|
|
|
|
|
href={ROUTES.validationTasks.list(String(dashboard.id))}
|
|
|
|
|
class="text-xs text-primary hover:text-primary-hover hover:underline"
|
|
|
|
|
>
|
|
|
|
|
{$t.dashboard?.view_all_runs || "View all runs →"}
|
|
|
|
|
</a>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Maintenance Badge -->
|
|
|
|
|
<div>
|
|
|
|
|
<DashboardMaintenanceBadge dashboardId={dashboard.id} />
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Actions -->
|
|
|
|
|
<div class="flex items-center">
|
|
|
|
|
<div class="flex items-center gap-1.5">
|
|
|
|
|
<button
|
|
|
|
|
class="inline-flex h-8 w-8 items-center justify-center rounded-md border border-border bg-surface-card text-primary hover:bg-primary-light hover:border-primary-ring transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
|
|
|
onclick={() => m.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="inline-flex h-8 w-8 items-center justify-center rounded-md border border-border bg-surface-card text-warning hover:bg-warning-light hover:border-warning transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
|
|
|
onclick={() => m.handleAction(dashboard, "backup")}
|
|
|
|
|
title={$t.dashboard?.action_backup}
|
|
|
|
|
>
|
|
|
|
|
<Icon name="download" size={14} strokeWidth={2} />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Changed On -->
|
|
|
|
|
<div class="text-xs text-text-muted">
|
|
|
|
|
{dashboard.changedOnLabel}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Owners -->
|
|
|
|
|
<div class="text-xs">
|
|
|
|
|
{#if dashboard.owners?.length > 0}
|
|
|
|
|
<div class="flex flex-wrap items-center gap-1">
|
|
|
|
|
{#each dashboard.owners as owner (owner)}
|
|
|
|
|
<span
|
|
|
|
|
class="rounded bg-surface-muted px-2 py-0.5 text-xs text-text truncate max-w-[180px]"
|
|
|
|
|
title={owner}
|
|
|
|
|
>
|
|
|
|
|
{owner}
|
|
|
|
|
</span>
|
|
|
|
|
{/each}
|
|
|
|
|
</div>
|
|
|
|
|
{:else}
|
|
|
|
|
<div class="text-text-subtle">-</div>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{/each}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Pagination -->
|
|
|
|
|
{#if m.totalPages > 1}
|
|
|
|
|
<Pagination
|
|
|
|
|
bind:page={pageZero}
|
|
|
|
|
pageSize={m.pageSize}
|
|
|
|
|
total={m.total}
|
|
|
|
|
pageSizeOptions={[5, 10, 25, 50, 100]}
|
|
|
|
|
onPageSizeChange={handlePageSizeChange}
|
|
|
|
|
class="px-4 py-3 bg-surface-muted border-t border-border"
|
|
|
|
|
/>
|
|
|
|
|
{/if}
|
|
|
|
|
{/snippet}
|
|
|
|
|
</DashboardDataGrid>
|
|
|
|
|
{/key}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Floating Bulk Action Panel -->
|
|
|
|
|
{#if m.selectedIds.size > 0}
|
|
|
|
|
<div
|
|
|
|
|
class="fixed bottom-0 left-0 right-0 bg-surface-card border-t border-border shadow-lg p-4 transition-transform transform translate-y-0"
|
|
|
|
|
>
|
|
|
|
|
<div
|
|
|
|
|
class="flex items-center justify-between w-full px-4 lg:px-8 mx-auto"
|
|
|
|
|
>
|
|
|
|
|
<div class="flex items-center gap-4">
|
|
|
|
|
<span class="font-medium">
|
|
|
|
|
✓ {($t.dashboard?.selected_count ?? '{count} selected').replace(
|
|
|
|
|
"{count}",
|
|
|
|
|
String(m.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"
|
|
|
|
|
onclick={() => {
|
|
|
|
|
m.showMigrateModal = true;
|
|
|
|
|
m.isEditingMappings = false;
|
|
|
|
|
m.bulkMigrateModel.sourceEnvId = m.selectedEnv;
|
|
|
|
|
m.bulkMigrateModel.selectedDashboardIds = Array.from(m.selectedIds);
|
|
|
|
|
m.bulkMigrateModel.targetEnvId = "";
|
|
|
|
|
m.bulkMigrateModel.dryRunResult = null;
|
|
|
|
|
m.bulkMigrateModel.error = "";
|
|
|
|
|
m.bulkMigrateModel.sourceDatabases = [];
|
|
|
|
|
m.bulkMigrateModel.targetDatabases = [];
|
|
|
|
|
m.bulkMigrateModel.mappings = [];
|
|
|
|
|
m.bulkMigrateModel.suggestions = [];
|
|
|
|
|
m.bulkMigrateModel.currentStep = 1;
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{$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"
|
|
|
|
|
onclick={() => (m.showBackupModal = true)}
|
|
|
|
|
>
|
|
|
|
|
{$t.dashboard?.bulk_backup}
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
class="px-3 py-1 text-sm border border-border-strong rounded hover:bg-surface-muted transition-colors"
|
|
|
|
|
onclick={() => m.clearSelectedIds()}
|
|
|
|
|
>
|
|
|
|
|
{$t.common?.cancel}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="fixed bottom-0 left-0 right-0 bg-surface-card border-t border-border shadow-lg p-4"><div class="flex items-center justify-between w-full px-4 lg:px-8 mx-auto"><span class="font-medium">✓ {($t.dashboard?.selected_count ?? '{count} selected').replace("{count}", String(m.selectedIds.size))}</span><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" onclick={() => { m.showMigrateModal = true; m.isEditingMappings = false; m.bulkMigrateModel.sourceEnvId = m.selectedEnv; m.bulkMigrateModel.selectedDashboardIds = Array.from(m.selectedIds); m.bulkMigrateModel.targetEnvId = ""; m.bulkMigrateModel.dryRunResult = null; m.bulkMigrateModel.error = ""; m.bulkMigrateModel.sourceDatabases = []; m.bulkMigrateModel.targetDatabases = []; m.bulkMigrateModel.mappings = []; m.bulkMigrateModel.suggestions = []; m.bulkMigrateModel.currentStep = 1; }}>{$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" onclick={() => (m.showBackupModal = true)}>{$t.dashboard?.bulk_backup}</button>
|
|
|
|
|
<button class="px-3 py-1 text-sm border border-border-strong rounded hover:bg-surface-muted transition-colors" onclick={() => m.clearSelectedIds()}>{$t.common?.cancel}</button>
|
|
|
|
|
</div></div></div>
|
|
|
|
|
{/if}
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
|
|
|
<MigrateDashboardModal model={m} {environments} />
|
|
|
|
|
|
|
|
|
|
<BackupDashboardModal model={m} {environments} />
|
|
|
|
|
<StartupEnvironmentWizard open={shouldShowEnvironmentWizard} onCreated={handleEnvironmentCreated} />
|
|
|
|
|
|
|
|
|
|
<StartupEnvironmentWizard
|
|
|
|
|
open={shouldShowEnvironmentWizard}
|
|
|
|
|
onCreated={handleEnvironmentCreated}
|
|
|
|
|
/>
|
|
|
|
|
<!-- Validation Popover -->
|
|
|
|
|
{#if m.openValidationPopover}
|
|
|
|
|
<div class="fixed z-50 w-80 rounded-lg border border-border bg-surface-card shadow-lg" style="left: {m.validationPopoverPosition.left}px; top: {m.validationPopoverPosition.top}px;" onclick={(e) => e.stopPropagation()}>
|
|
|
|
|
<div class="border-b border-border px-3 py-2 text-xs font-semibold uppercase tracking-wide text-text-muted">{$t.dashboard?.validation_history || "Validation History"}</div>
|
|
|
|
|
{#if m.validationStatuses[m.openValidationPopover]?.history?.length > 0}
|
|
|
|
|
<div class="max-h-48 overflow-y-auto">
|
|
|
|
|
{#each m.validationStatuses[m.openValidationPopover].history.slice(0, 3) as run}
|
|
|
|
|
<div class="flex items-center justify-between px-3 py-2 text-xs border-b border-border last:border-b-0">
|
|
|
|
|
<span class="block h-2.5 w-2.5 rounded-full {run.status === 'PASS' ? 'bg-success' : run.status === 'WARN' ? 'bg-warning' : run.status === 'FAIL' ? 'bg-destructive' : 'bg-border'}"></span>
|
|
|
|
|
<span class="text-text">{run.status || "UNKNOWN"}</span>
|
|
|
|
|
<span class="text-text-subtle">{run.task_name || "-"}</span>
|
|
|
|
|
<span class="text-text-subtle">{formatDate(run.last_run_at)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
{/each}
|
|
|
|
|
</div>
|
|
|
|
|
{:else}
|
|
|
|
|
<div class="px-3 py-3 text-xs text-text-subtle text-center">{$t.dashboard?.no_validation_runs || "No validation runs yet"}</div>
|
|
|
|
|
{/if}
|
|
|
|
|
<div class="border-t border-border px-3 py-2"><a href={ROUTES.validationTasks.list(String(m.openValidationPopover))} class="text-xs text-primary hover:text-primary-hover hover:underline">{$t.dashboard?.view_all_runs || "View all runs →"}</a></div>
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- #endregion DashboardHub -->
|
|
|
|
|
|