feat(migration): dashboard-first dry-run review, compact logs, second timestamps

Backend:
- dashboard_outcomes module: per-dashboard new/overwrite/identical outcome,
  composite-key drift prediction with desired-vs-live values, read-only
- dry-run: single cached live dataset-contract fetch (was O(dashboards)),
  literal_find/literal_replace reach every transform_zip call
- catalog read failures degrade to warnings (target_database/dataset_catalog_
  unavailable, risk_analysis_unavailable) instead of fabricated blockers
- MigrationDryRunResult + dashboards[]; MigrationRiskItem + dataset_uuid

Frontend:
- MigrationReviewPanel: sorted outcomes (blocked -> overwrite -> new ->
  identical), show-all toggle, risk assessment separated from technical
  details, full en/ru localization
- execute failure preserves dry-run and returns to step 3; submit guard
  hides Back while POST is in flight; confirm dialog before migration start
- task logs: dense one-line rows with expand chevron, compact toolbar
  (filters/debug/errors/autoscroll/count in one row)
- migration dashboard grid timestamps truncated to seconds via
  formatDateTimeSeconds

Tests: +6 backend regression (literal kwargs, outcomes, drift severity,
missing-db blocker, catalog degradation, single live fetch), +4 frontend
(sorting, legacy fallback, execute recovery). 116 pytest + 261 vitest passed.
This commit is contained in:
2026-09-10 16:34:02 +03:00
parent 1b79597536
commit befa03e34f
21 changed files with 1424 additions and 378 deletions

View File

@@ -0,0 +1,303 @@
<!-- #region Migration.ReviewPanel [C:3] [TYPE Component] [SEMANTICS migration,dry-run,review,dashboard,outcome] -->
<!-- @ingroup Migration -->
<!-- @BRIEF Dashboard-first dry-run review panel: migration plan header, per-dashboard import
outcomes, collapsed technical object details, and aggregate risk assessment. -->
<!-- @LAYER UI -->
<!-- @RELATION DEPENDS_ON -> [Migration.Model] -->
<!-- @RELATION DEPENDS_ON -> [Migration.DryRunDiffList] -->
<!-- @RELATION CALLED_BY -> [Migration.Page.MigrationPage] -->
<!-- @UX_STATE Populated -> Plan header + sorted outcome list (non-importable → overwrite → new → identical). -->
<!-- @UX_STATE Empty -> Prompt to run dry-run. -->
<!-- @UX_STATE Expanded -> Technical details (summary cards + UUID-level diff) visible via details element. -->
<script lang="ts">
import { t } from "$lib/i18n/index.svelte.js";
import { Icon } from "$lib/ui";
import { formatDateTimeSeconds } from "$lib/utils/dateFormat.js";
import DryRunDiffList from "./DryRunDiffList.svelte";
let { model } = $props();
let showAll = $state(false);
const reviewDashboards = $derived(model.reviewDashboards);
const visibleDashboards = $derived(
showAll ? reviewDashboards : reviewDashboards.slice(0, 10),
);
const hiddenCount = $derived(Math.max(0, reviewDashboards.length - visibleDashboards.length));
const visibleRiskItems = $derived((model.dryRunResult?.risk.items || []).slice(0, 5));
const hiddenRiskCount = $derived(
Math.max(0, (model.dryRunResult?.risk.items || []).length - visibleRiskItems.length),
);
const hasBlockingRisks = $derived((model.dryRunResult?.risk.blockers || 0) > 0);
function fmt(key: string, params: Record<string, string | number> = {}, fallback = ""): string {
let text = $t.migration?.[key] || fallback || key;
for (const [name, value] of Object.entries(params)) {
text = text.replace(`{${name}}`, String(value));
}
return text;
}
function outcomeLabel(dashboard: {
outcome: string;
target_title?: string | null;
}): string {
if (dashboard.outcome === "overwrite") {
return dashboard.target_title
? fmt("review_outcome_overwrite", { title: dashboard.target_title }, "Overwrites «{title}»")
: fmt("review_outcome_overwrite_unknown", {}, "Overwrites target dashboard");
}
if (dashboard.outcome === "new") return fmt("review_outcome_new", {}, "New on target");
return fmt("review_outcome_identical", {}, "No changes");
}
</script>
{#if model.dryRunResult}
<!-- Plan header: env pair + applied options -->
<div class="mb-6 rounded-xl border border-info-ring bg-info-light p-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-info">
{fmt("review_plan", {}, "Migration plan")}
</p>
<p class="mt-1 text-base font-semibold text-info-hover">
{model.environmentName(model.sourceEnvId)} <span aria-hidden="true">→</span> {model.environmentName(model.targetEnvId)}
</p>
</div>
<div class="text-right text-xs text-info-hover">
<p>{fmt("review_selected_count", { n: model.dryRunResult.summary.selected_dashboards }, "{n} dashboards selected")}</p>
<p class="mt-1">{fmt("review_analysis_at", { ts: formatDateTimeSeconds(model.dryRunResult.generated_at) }, "Analysis: {ts}")}</p>
</div>
</div>
<div class="mt-3 flex flex-wrap gap-2 text-xs">
{#if model.dryRunResult.selection.fix_cross_filters}<span class="rounded-full bg-surface-card px-2.5 py-1 text-info">{fmt("review_opt_cross_filters", {}, "Cross-filters remapped")}</span>{/if}
{#if model.dryRunResult.selection.replace_db_config}<span class="rounded-full bg-surface-card px-2.5 py-1 text-info">{fmt("review_opt_db_mappings", {}, "Database mappings applied")}</span>{/if}
{#if model.dryRunResult.selection.literal_find}<span class="rounded-full bg-surface-card px-2.5 py-1 text-info">{fmt("review_opt_dataset_replacement", {}, "Dataset replacement applied")}</span>{/if}
{#if model.dryRunResult.selection.sync_dataset_composite_keys}<span class="rounded-full bg-surface-card px-2.5 py-1 text-info">{fmt("review_opt_key_retry", {}, "Dataset key retry enabled")}</span>{/if}
</div>
</div>
<!-- Dashboard-first outcome list -->
<div class="mb-6 overflow-hidden rounded-xl border border-border bg-surface-card">
<div class="flex items-center justify-between border-b border-border px-4 py-3">
<div>
<h3 class="text-sm font-semibold text-text">{fmt("review_dashboard_plan", {}, "Dashboard import plan")}</h3>
<p class="mt-0.5 text-xs text-text-muted">{fmt("review_dashboard_plan_hint", {}, "The dashboard is the migration artifact. Technical objects explain import risk.")}</p>
</div>
<span class="text-xs text-text-muted">{fmt("review_total_count", { n: reviewDashboards.length }, "{n} total")}</span>
</div>
<div class="divide-y divide-border">
{#each visibleDashboards as dashboard (dashboard.id)}
<details class="group px-4 py-3">
<summary class="flex cursor-pointer list-none items-center gap-3">
<span class="min-w-0 flex-1 truncate text-sm font-medium text-text">{dashboard.title}</span>
<span class={dashboard.outcome === 'overwrite' ? 'rounded-full bg-warning-light px-2 py-1 text-xs text-warning' : dashboard.outcome === 'new' ? 'rounded-full bg-success-light px-2 py-1 text-xs text-success' : 'rounded-full bg-surface-muted px-2 py-1 text-xs text-text-muted'}>
{outcomeLabel(dashboard)}
</span>
<span class={dashboard.importable ? 'text-xs text-success' : 'text-xs font-semibold text-destructive'}>{dashboard.importable ? fmt("review_importable", {}, "Importable") : fmt("review_may_fail", {}, "May fail")}</span>
<Icon name="chevronDown" size={16} class="text-text-muted transition-transform group-open:rotate-180" strokeWidth={2} />
</summary>
<div class="mt-3 grid gap-2 pl-1 text-xs text-text-muted sm:grid-cols-3">
<span>{fmt("review_charts_datasets", { charts: dashboard.counts.charts, datasets: dashboard.counts.datasets }, "{charts} charts · {datasets} datasets")}</span>
<span>{fmt("review_issues_count", { n: dashboard.issues.length }, "{n} import issue(s)")}</span>
<span>{fmt(dashboard.outcome === 'overwrite' ? "review_detail_overwrite" : dashboard.outcome === 'new' ? "review_detail_new" : "review_detail_identical", {}, "Existing target artifact will be updated")}</span>
</div>
{#if dashboard.issues.length > 0}
<div class="mt-3 space-y-2">
{#each dashboard.issues as issue (`${issue.code}:${issue.object_uuid}:${issue.message}`)}
<div class={issue.category === 'blocker' ? 'rounded-lg bg-destructive-light px-3 py-2 text-xs text-destructive' : issue.category === 'confirmation' ? 'rounded-lg bg-info-light px-3 py-2 text-xs text-info' : 'rounded-lg bg-warning-light px-3 py-2 text-xs text-warning'}>
<span class="font-semibold">{issue.category}:</span> {issue.message}
</div>
{/each}
</div>
{/if}
</details>
{:else}
<p class="px-4 py-6 text-sm text-text-muted">{fmt("review_no_outcomes", {}, "No dashboard outcomes returned by dry-run.")}</p>
{/each}
</div>
{#if hiddenCount > 0}
<button
type="button"
class="w-full border-t border-border px-4 py-2 text-xs text-primary hover:bg-surface-muted hover:underline"
onclick={() => (showAll = true)}
>
{fmt("review_show_all", { n: reviewDashboards.length }, "Show all {n} dashboards")}
</button>
{:else if showAll && reviewDashboards.length > 10}
<button
type="button"
class="w-full border-t border-border px-4 py-2 text-xs text-primary hover:bg-surface-muted hover:underline"
onclick={() => (showAll = false)}
>
{fmt("review_show_less", {}, "Show less")}
</button>
{/if}
</div>
<!-- Aggregate risk assessment (separate from technical details) -->
<div class="mb-6 p-4 border border-border rounded-xl bg-surface-card">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01" />
</svg>
<h3 class="text-sm font-semibold text-text">{$t.migration?.risk_assessment || "Risk Assessment"}</h3>
</div>
<div class="flex items-center gap-3">
<div class="flex items-center gap-2">
<span class="text-xs text-text-muted">{$t.migration?.risk_score || "Score"}:</span>
<div class={model.dryRunResult.risk.score <= 20 ? 'px-2.5 py-1 rounded-full text-xs font-bold bg-success-light text-success' : model.dryRunResult.risk.score <= 50 ? 'px-2.5 py-1 rounded-full text-xs font-bold bg-warning-light text-warning' : 'px-2.5 py-1 rounded-full text-xs font-bold bg-destructive-light text-destructive'}>
{model.dryRunResult.risk.score}/100
</div>
</div>
<div class={model.dryRunResult.risk.level === 'low' ? 'px-2.5 py-1 rounded-full text-xs font-bold bg-success-light text-success' : model.dryRunResult.risk.level === 'medium' ? 'px-2.5 py-1 rounded-full text-xs font-bold bg-warning-light text-warning' : 'px-2.5 py-1 rounded-full text-xs font-bold bg-destructive-light text-destructive'}>
{model.dryRunResult.risk.level === "low"
? ($t.migration?.risk_level_low || "Low")
: model.dryRunResult.risk.level === "medium"
? ($t.migration?.risk_level_medium || "Medium")
: ($t.migration?.risk_level_high || "High")}
</div>
</div>
</div>
<div class="w-full h-2 bg-surface-muted rounded-full mb-4 overflow-hidden">
<div
class="h-full rounded-full transition-all duration-700"
class:bg-success={model.dryRunResult.risk.score <= 20}
class:bg-warning={model.dryRunResult.risk.score > 20 && model.dryRunResult.risk.score <= 50}
class:bg-destructive={model.dryRunResult.risk.score > 50}
style="width: {model.dryRunResult.risk.score}%"
></div>
</div>
<div class="mb-4 grid grid-cols-1 gap-2 text-xs sm:grid-cols-3">
<div class="rounded bg-destructive-light px-3 py-2 text-destructive">{($t.migration?.risk_blockers || "Blockers: {n}").replace("{n}", String(model.dryRunResult.risk.blockers))}</div>
<div class="rounded bg-warning-light px-3 py-2 text-warning">{($t.migration?.risk_warnings || "Warnings: {n}").replace("{n}", String(model.dryRunResult.risk.warnings))}</div>
<div class="rounded bg-info-light px-3 py-2 text-info">{($t.migration?.risk_confirmations || "Updates to confirm: {n}").replace("{n}", String(model.dryRunResult.risk.confirmations))}</div>
</div>
{#if hasBlockingRisks}
<p class="mb-4 text-sm text-destructive">{$t.migration?.risk_blocked_hint || "Resolve the blocking issues before starting migration."}</p>
{:else}
<p class="mb-4 text-sm text-text-muted">{$t.migration?.risk_ready_hint || "No blocking issues detected. Review updates, then start migration when ready."}</p>
{/if}
{#if visibleRiskItems.length > 0}
<div class="space-y-2">
{#each visibleRiskItems as item (`${item.object_type}:${item.object_uuid}:${item.message}`)}
<div class={item.category === 'blocker' ? 'flex items-start gap-3 rounded-lg bg-destructive-light p-2.5 text-sm' : item.severity === 'medium' ? 'flex items-start gap-3 rounded-lg bg-warning-light p-2.5 text-sm' : 'flex items-start gap-3 rounded-lg bg-info-light p-2.5 text-sm'}>
<div class={item.category === 'blocker' ? 'mt-1.5 h-2 w-2 flex-shrink-0 rounded-full bg-destructive' : item.severity === 'medium' ? 'mt-1.5 h-2 w-2 flex-shrink-0 rounded-full bg-warning' : 'mt-1.5 h-2 w-2 flex-shrink-0 rounded-full bg-info'}></div>
<div>
<p class="font-medium text-text">{item.message}</p>
<p class="text-xs text-text-muted mt-0.5">{item.object_type} · {model.riskObjectLabel(item)}</p>
</div>
</div>
{/each}
</div>
{#if hiddenRiskCount > 0}
<details class="mt-3 text-sm text-text-muted">
<summary class="cursor-pointer text-primary hover:text-primary-hover">{($t.migration?.risk_show_all || "Show {n} more issues").replace("{n}", String(hiddenRiskCount))}</summary>
<div class="mt-2 max-h-64 space-y-2 overflow-y-auto pr-1">
{#each (model.dryRunResult.risk.items || []).slice(5) as item (`more:${item.object_type}:${item.object_uuid}:${item.message}`)}
<p class="rounded bg-surface-muted px-3 py-2 text-xs text-text">{item.message}</p>
{/each}
</div>
</details>
{/if}
{:else}
<p class="text-sm text-text-muted">{$t.migration?.no_issues || "No issues detected."}</p>
{/if}
</div>
<!-- Collapsed technical details: summary cards + UUID-level diff -->
<details class="mb-6 rounded-xl border border-border bg-surface-card">
<summary class="cursor-pointer list-none px-4 py-3 text-sm font-semibold text-text">
{fmt("review_technical_details", {}, "Technical object details")}
<span class="ml-2 text-xs font-normal text-text-muted">{fmt("review_technical_details_hint", {}, "charts, datasets, UUID-level diff")}</span>
</summary>
<div class="border-t border-border p-4">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div class="p-4 border border-border rounded-xl bg-surface-card">
<div class="flex items-center gap-2 mb-3">
<div class="w-8 h-8 rounded-lg bg-info-light flex items-center justify-center">
<Icon name="dashboard" size={16} class="text-info" strokeWidth={2} />
</div>
<h4 class="text-sm font-semibold text-text">{$t.migration?.dashboards || "Dashboards"}</h4>
</div>
<div class="space-y-1.5 text-sm">
<div class="flex items-center justify-between">
<span class="text-success font-medium">{$t.migration?.create || "Create"}</span>
<span class="font-semibold text-success">{model.dryRunResult.summary.dashboards.create}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-warning font-medium">{$t.migration?.update || "Update"}</span>
<span class="font-semibold text-warning">{model.dryRunResult.summary.dashboards.update}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-destructive font-medium">{$t.migration?.delete || "Delete"}</span>
<span class="font-semibold text-destructive">{model.dryRunResult.summary.dashboards.delete}</span>
</div>
</div>
</div>
<div class="p-4 border border-border rounded-xl bg-surface-card">
<div class="flex items-center gap-2 mb-3">
<div class="w-8 h-8 rounded-lg bg-primary-light flex items-center justify-center">
<svg class="w-4 h-4 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</div>
<h4 class="text-sm font-semibold text-text">{$t.migration?.charts || "Charts"}</h4>
</div>
<div class="space-y-1.5 text-sm">
<div class="flex items-center justify-between">
<span class="text-success font-medium">{$t.migration?.create || "Create"}</span>
<span class="font-semibold text-success">{model.dryRunResult.summary.charts.create}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-warning font-medium">{$t.migration?.update || "Update"}</span>
<span class="font-semibold text-warning">{model.dryRunResult.summary.charts.update}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-destructive font-medium">{$t.migration?.delete || "Delete"}</span>
<span class="font-semibold text-destructive">{model.dryRunResult.summary.charts.delete}</span>
</div>
</div>
</div>
<div class="p-4 border border-border rounded-xl bg-surface-card">
<div class="flex items-center gap-2 mb-3">
<div class="w-8 h-8 rounded-lg bg-success-light flex items-center justify-center">
<Icon name="database" size={16} class="text-success" strokeWidth={2} />
</div>
<h4 class="text-sm font-semibold text-text">{$t.migration?.datasets || "Datasets"}</h4>
</div>
<div class="space-y-1.5 text-sm">
<div class="flex items-center justify-between">
<span class="text-success font-medium">{$t.migration?.create || "Create"}</span>
<span class="font-semibold text-success">{model.dryRunResult.summary.datasets.create}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-warning font-medium">{$t.migration?.update || "Update"}</span>
<span class="font-semibold text-warning">{model.dryRunResult.summary.datasets.update}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-destructive font-medium">{$t.migration?.delete || "Delete"}</span>
<span class="font-semibold text-destructive">{model.dryRunResult.summary.datasets.delete}</span>
</div>
</div>
</div>
</div>
<div class="mb-6">
<DryRunDiffList diff={model.dryRunResult.diff} />
</div>
</div>
</details>
{:else}
<div class="flex flex-col items-center justify-center py-8 text-text-subtle">
<svg class="w-12 h-12 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p class="text-sm">{$t.migration?.no_dry_run || "Run a dry-run to see the migration summary here."}</p>
</div>
{/if}
<!-- #endregion Migration.ReviewPanel -->

View File

@@ -3,8 +3,8 @@
<!-- @BRIEF Renders a single log entry; Molecular CoT JSON shows marker + intent instead of raw wall. -->
<!-- @LAYER UI -->
<!-- @RELATION DEPENDS_ON -> [Logs.ParseCot] -->
<!-- @UX_STATE Idle -> Color-coded level/source; CoT marker chip when parseable. -->
<!-- @UX_STATE Expanded -> Payload/error JSON visible for CoT rows. -->
<!-- @UX_STATE Idle -> Dense one-line summary with color-coded level/source and CoT marker. -->
<!-- @UX_STATE Expanded -> Full message, payload, error, trace, and progress details visible. -->
<script lang="ts">
let { log, showSource = true } = $props();
import { parseDateUTC } from "$lib/utils/dateFormat.js";
@@ -69,7 +69,11 @@
let hasProgress = $derived(log.payload?.progress !== undefined);
let progressPercent = $derived(log.payload?.progress || 0);
let hasDetails = $derived(
Boolean(cot && (cot.payload || cot.error || cot.trace_id || cot.src)),
Boolean(
(cot && (cot.payload || cot.error || cot.trace_id || cot.src)) ||
log.payload ||
log.error,
),
);
function sanitizeMessage(message) {
@@ -89,20 +93,20 @@
</script>
<div
class="py-2.5 px-4 border-b border-border transition-colors hover:bg-surface-muted"
class="px-3 py-1.5 border-b border-border transition-colors hover:bg-surface-muted"
class:border-l-2={exploreBorder}
class:border-l-destructive={exploreBorder}
>
<!-- Meta line: time + level + marker + source -->
<div class="flex items-center gap-2 mb-1 flex-wrap">
<span class="font-mono text-xs text-text-subtle shrink-0">{formattedTime}</span>
<!-- Dense single-line summary; details remain keyboard accessible. -->
<div class="flex min-w-0 items-center gap-1.5">
<span class="shrink-0 font-mono text-[0.6875rem] text-text-subtle">{formattedTime}</span>
<span
class="font-mono font-semibold uppercase text-[0.625rem] px-1.5 py-0.5 rounded tracking-wider shrink-0 {levelClass}"
class="shrink-0 rounded px-1 py-0.5 font-mono text-[0.5625rem] font-semibold uppercase tracking-wider {levelClass}"
>{log.level || "INFO"}</span
>
{#if cot}
<span
class="font-mono text-[0.625rem] px-1.5 py-0.5 rounded shrink-0 {markerClass}"
class="shrink-0 rounded px-1 py-0.5 font-mono text-[0.5625rem] {markerClass}"
title={cot.marker}
>
{markerIcons[cot.marker] || "·"}
@@ -110,58 +114,62 @@
</span>
{/if}
{#if showSource && log.src}
<span class="text-[0.625rem] px-1.5 py-0.5 rounded shrink-0 {sourceClass}"
<span class="max-w-[8rem] truncate rounded px-1 py-0.5 text-[0.5625rem] {sourceClass}"
>{log.src}</span
>
{/if}
{#if cot?.trace_id}
<span
class="font-mono text-[0.625rem] text-text-subtle truncate max-w-[8rem]"
class="max-w-[6rem] truncate font-mono text-[0.5625rem] text-text-subtle"
title={cot.trace_id}>trace:{cot.trace_id.slice(0, 8)}</span
>
{/if}
</div>
<!-- Intent / message -->
<div
class="font-mono text-sm leading-relaxed text-text break-words whitespace-pre-wrap"
>
{displayText}
</div>
{#if cot?.error}
<p class="mt-1 font-mono text-xs text-destructive break-words">{cot.error}</p>
{/if}
{#if hasDetails}
<button
type="button"
class="mt-1 text-[0.625rem] text-primary hover:underline"
class="min-w-0 flex-1 truncate text-left font-mono text-xs text-text hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-ring"
title={displayText}
aria-expanded={expanded}
aria-label={expanded ? "Hide log details" : `Show log details: ${displayText}`}
onclick={() => (expanded = !expanded)}
>
{expanded ? "Hide details" : "Show details"}
</button>
{#if expanded}
>{displayText}</button>
{#if hasDetails}
<svg
class="shrink-0 text-text-subtle transition-transform {expanded ? 'rotate-180' : ''}"
xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"
>
<path d="M6 9l6 6 6-6" />
</svg>
{/if}
</div>
{#if hasDetails && expanded}
<div class="mt-1 space-y-1">
{#if cot?.error}
<p class="font-mono text-xs text-destructive break-words">{sanitizeMessage(cot.error)}</p>
{/if}
<pre
class="mt-1 max-h-40 overflow-auto rounded bg-surface-muted p-2 text-[0.625rem] text-text-muted whitespace-pre-wrap break-words"
class="max-h-40 overflow-auto rounded bg-surface-muted p-2 text-[0.625rem] text-text-muted whitespace-pre-wrap break-words"
>{JSON.stringify(
{
src: cot.src,
trace_id: cot.trace_id,
span_id: cot.span_id,
payload: cot.payload,
error: cot.error,
message: displayText,
src: cot?.src || log.src,
trace_id: cot?.trace_id || log.trace_id,
span_id: cot?.span_id,
payload: cot?.payload || log.payload,
error: cot?.error || log.error,
progress: log.payload?.progress,
},
null,
2,
)}</pre
>
{/if}
</div>
{/if}
{#if hasProgress}
<div class="flex items-center gap-2 mt-1.5">
<div class="flex-1 h-1.5 bg-surface-muted rounded-full overflow-hidden">
<div class="flex items-center gap-2 mt-1">
<div class="flex-1 h-1 bg-surface-muted rounded-full overflow-hidden">
<div
class="h-full bg-primary rounded-full transition-[width] duration-300 ease-out"
style="width: {progressPercent}%"

View File

@@ -1,6 +1,6 @@
<!-- #region Tasks.LogFilterBar [C:3] [TYPE Component] [SEMANTICS log, filter, level, source, toolbar] -->
<!-- @ingroup Tasks -->
<!-- @BRIEF Component component: components/tasks/LogFilterBar.svelte -->
<!-- @BRIEF Compact toolbar for level/source/search filters and log display controls. -->
<!-- @LAYER UI -->
<!--
@RELATION DEPENDS_ON -> [EXT:frontend:App]
@@ -10,6 +10,7 @@
@LAYER UI
@UX_STATE: Idle -> Shows filter controls
@UX_STATE: Active -> Filters applied, clear button visible
@UX_STATE: Compact -> Debug, auto-scroll, and count controls share the filter row.
-->
<script lang="ts">
import { t } from "$lib/i18n/index.svelte.js";
@@ -19,7 +20,15 @@
selectedLevel = $bindable(""),
selectedSource = $bindable(""),
searchText = $bindable(""),
showDebug = $bindable(true),
autoScroll = $bindable(true),
filteredCount = 0,
totalCount = 0,
onfilterchange = () => {},
ontoggledebug = () => {},
ontoggleautoscroll = () => {},
ontoggleerror = () => {},
errorFilterActive = false,
} = $props();
const levelOptions = [
@@ -69,13 +78,13 @@
);
</script>
<div class="flex items-center gap-2 px-3 py-2 bg-surface-card border-b border-border">
<div class="grid flex-1 grid-cols-[minmax(4.5rem,auto)_minmax(7rem,auto)_minmax(0,1fr)] items-center gap-2 min-w-0">
<div class="flex flex-wrap items-center gap-1.5 px-2 py-1.5 bg-surface-card border-b border-border">
<div class="grid min-w-0 flex-1 grid-cols-[minmax(4.5rem,auto)_minmax(7rem,auto)_minmax(8rem,1fr)] items-center gap-1.5">
<!-- Level filter -->
<select
id="task-log-level-filter"
name="task-log-level-filter"
class="h-11 bg-surface-card text-text-muted border border-border-strong rounded-md px-2 py-2 text-xs cursor-pointer shrink-0 appearance-none pr-6 focus:outline-none focus:ring-2 focus:ring-primary-ring focus:border-primary-ring"
class="h-8 bg-surface-card text-text-muted border border-border-strong rounded-md px-2 text-xs cursor-pointer shrink-0 appearance-none pr-6 focus:outline-none focus:ring-2 focus:ring-primary-ring focus:border-primary-ring"
style="background-image: url(&quot;data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E&quot;); background-repeat: no-repeat; background-position: right 0.375rem center;"
value={selectedLevel}
onchange={handleLevelChange}
@@ -90,7 +99,7 @@
<select
id="task-log-source-filter"
name="task-log-source-filter"
class="h-11 bg-surface-card text-text-muted border border-border-strong rounded-md px-2 py-2 text-xs cursor-pointer shrink-0 appearance-none pr-6 focus:outline-none focus:ring-2 focus:ring-primary-ring focus:border-primary-ring"
class="h-8 bg-surface-card text-text-muted border border-border-strong rounded-md px-2 text-xs cursor-pointer shrink-0 appearance-none pr-6 focus:outline-none focus:ring-2 focus:ring-primary-ring focus:border-primary-ring"
style="background-image: url(&quot;data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E&quot;); background-repeat: no-repeat; background-position: right 0.375rem center;"
value={selectedSource}
onchange={handleSourceChange}
@@ -121,7 +130,7 @@
id="task-log-search"
name="task-log-search"
type="text"
class="h-11 w-full bg-surface-card text-text border border-border-strong rounded-md py-2 px-2 pl-7 text-xs placeholder:text-text-subtle focus:outline-none focus:ring-2 focus:ring-primary-ring focus:border-primary-ring"
class="h-8 w-full bg-surface-card text-text border border-border-strong rounded-md px-2 pl-7 text-xs placeholder:text-text-subtle focus:outline-none focus:ring-2 focus:ring-primary-ring focus:border-primary-ring"
placeholder={$t.tasks?.search_logs || "Search logs..."}
value={searchText}
oninput={handleSearchChange}
@@ -149,5 +158,36 @@
</svg>
</button>
{/if}
<button
type="button"
class="inline-flex h-8 shrink-0 items-center gap-1 rounded-md border px-2 text-[0.6875rem] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-ring {showDebug ? 'border-border-strong bg-surface-muted text-text' : 'border-border text-text-subtle hover:border-border-strong hover:text-text'}"
onclick={ontoggledebug}
aria-pressed={showDebug}
aria-label={$t.tasks?.show_debug || "Debug"}
>
<span aria-hidden="true">·</span>{$t.tasks?.show_debug || "Debug"}
</button>
<button
type="button"
class="inline-flex h-8 shrink-0 items-center gap-1 rounded-md border px-2 text-[0.6875rem] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-ring {errorFilterActive ? 'border-destructive-ring bg-destructive-light text-destructive' : 'border-border text-text-subtle hover:border-border-strong hover:text-text'}"
onclick={ontoggleerror}
aria-pressed={errorFilterActive}
>
{$t.tasks?.quick_filter_errors || "Errors"}
</button>
<button
type="button"
class="inline-flex h-8 shrink-0 items-center gap-1 rounded-md border px-2 text-[0.6875rem] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-ring {autoScroll ? 'border-primary-ring bg-primary-light text-primary' : 'border-border text-text-subtle hover:border-border-strong hover:text-text'}"
onclick={ontoggleautoscroll}
aria-pressed={autoScroll}
aria-label={$t.tasks?.toggle_autoscroll || "Toggle auto-scroll"}
>
{#if autoScroll}<span class="inline-block h-1.5 w-1.5 rounded-full bg-primary" aria-hidden="true"></span>{/if}
{$t.tasks?.auto_scroll || "Auto-scroll"}
</button>
<span class="whitespace-nowrap px-1 font-mono text-[0.6875rem] text-text-subtle" aria-live="polite">
{filteredCount}{filteredCount !== totalCount ? ` / ${totalCount}` : ""} {$t.tasks?.log_entries || "entries"}
</span>
</div>
<!-- #endregion Tasks.LogFilterBar -->

View File

@@ -12,6 +12,7 @@
@UX_STATE: Empty -> Displays "No logs" message
@UX_STATE: Populated -> Displays list of LogEntryRow components
@UX_STATE: AutoScroll -> Automatically scrolls to bottom on new logs
@UX_STATE: CompactToolbar -> Filtering, display controls, and count share one toolbar.
-->
<script lang="ts">
import { onMount, tick } from "svelte";
@@ -19,21 +20,26 @@
import LogFilterBar from "./LogFilterBar.svelte";
import LogEntryRow from "./LogEntryRow.svelte";
let {
logs = [],
autoScroll = $bindable(true),
onfilterchange = () => {},
hideDebugInitially = false,
requestedFilter = null,
} = $props();
let {
logs = [],
autoScroll = $bindable(true),
onfilterchange = () => {},
hideDebugInitially = false,
requestedFilter = null,
} = $props();
let scrollContainer;
let selectedSource = $state("all");
let selectedLevel = $state("all");
let searchText = $state("");
let appliedFilterRequest = "";
let debugOverride = $state<boolean | null>(null);
let showDebug = $derived(debugOverride ?? !hideDebugInitially);
$effect(() => {
let searchText = $state("");
let appliedFilterRequest = "";
let showDebug = $state(true);
const initialDebugVisibility = $derived(!hideDebugInitially);
let debugInitialized = false;
$effect(() => {
if (!debugInitialized) {
showDebug = initialDebugVisibility;
debugInitialized = true;
}
if (!requestedFilter) return;
const requestKey = JSON.stringify(requestedFilter);
if (requestKey === appliedFilterRequest) return;
@@ -44,19 +50,9 @@
onfilterchange({ source: selectedSource, level: selectedLevel, search: searchText });
tick().then(scrollToBottom);
});
const quickFilters = [
{ id: "all", labelKey: "quick_filter_all", fallback: "All", source: "all", level: "all" },
{ id: "error", labelKey: "quick_filter_errors", fallback: "Errors", source: "all", level: "ERROR" },
{ id: "warning", labelKey: "quick_filter_warn", fallback: "Warn", source: "all", level: "WARNING" },
{ id: "superset", labelKey: "quick_filter_superset", fallback: "Superset", source: "superset_api", level: "all" },
{ id: "llm", labelKey: "quick_filter_llm", fallback: "LLM", source: "llm", level: "all" },
{ id: "plugin", labelKey: "quick_filter_plugin", fallback: "Plugin", source: "plugin", level: "all" },
{ id: "system", labelKey: "quick_filter_system", fallback: "System", source: "system", level: "all" },
];
let filteredLogs = $derived(
let filteredLogs = $derived(
filterLogs(logs, selectedLevel, selectedSource, searchText, showDebug),
);
);
function filterLogs(allLogs, level, source, search, includeDebug) {
return allLogs.filter((log) => {
@@ -82,12 +78,6 @@
let availableSources = $derived([
...new Set(logs.map((l) => l.src).filter(Boolean)),
]);
let activeQuickFilter = $derived(
quickFilters.find(
(filter) => filter.source === selectedSource && filter.level === selectedLevel,
)?.id || "",
);
function handleFilterChange(event) {
const { source, level, search } = event;
selectedSource = source || "all";
@@ -96,14 +86,8 @@
onfilterchange({ source, level, search });
}
function applyQuickFilter(filter) {
selectedSource = filter.source;
selectedLevel = filter.level;
onfilterchange({ source: selectedSource, level: selectedLevel, search: searchText });
}
function toggleDebug() {
debugOverride = !showDebug;
showDebug = !showDebug;
onfilterchange({ source: selectedSource, level: selectedLevel, search: searchText });
}
@@ -118,6 +102,12 @@
if (autoScroll) scrollToBottom();
}
function applyErrorFilter() {
selectedSource = "all";
selectedLevel = selectedLevel === "ERROR" ? "all" : "ERROR";
onfilterchange({ source: selectedSource, level: selectedLevel, search: searchText });
}
// Use $effect instead of afterUpdate for runes mode
$effect(() => {
filteredLogs.length;
@@ -130,32 +120,22 @@
</script>
<div class="flex flex-col h-full bg-surface-card overflow-hidden rounded-lg border border-border shadow-sm">
<!-- Filter Bar -->
<!-- Compact filter and display toolbar -->
<LogFilterBar
{availableSources}
bind:selectedLevel
bind:selectedSource
bind:searchText
bind:showDebug
bind:autoScroll
filteredCount={filteredLogs.length}
totalCount={logs.length}
onfilterchange={handleFilterChange}
ontoggledebug={toggleDebug}
ontoggleautoscroll={toggleAutoScroll}
ontoggleerror={applyErrorFilter}
errorFilterActive={selectedLevel === "ERROR"}
/>
<div class="flex flex-wrap gap-2 border-b border-border bg-surface-card px-3 py-2" aria-label={$t.tasks?.log_quick_filters_aria || "Log quick filters"}>
{#each quickFilters as filter (filter.id)}
<button
class="min-h-11 shrink-0 rounded-full border px-3 text-xs font-semibold transition-colors {activeQuickFilter === filter.id ? 'border-primary-ring bg-primary-light text-primary' : 'border-border bg-surface-card text-text-muted hover:border-border-strong hover:text-text'}"
onclick={() => applyQuickFilter(filter)}
aria-pressed={activeQuickFilter === filter.id}
>
{$t.tasks?.[filter.labelKey] || filter.fallback}
</button>
{/each}
<button
class="min-h-11 shrink-0 rounded-full border px-3 text-xs font-semibold transition-colors {showDebug ? 'border-border-strong bg-surface-muted text-text' : 'border-border bg-surface-card text-text-subtle hover:border-border-strong hover:text-text'}"
onclick={toggleDebug}
aria-pressed={showDebug}
>
{$t.tasks?.show_debug || "Debug"}
</button>
</div>
<!-- Log List -->
<div
@@ -190,30 +170,5 @@
{/if}
</div>
<!-- Footer Stats -->
<div
class="flex items-center justify-between gap-3 px-4 py-2 border-t border-border bg-surface-muted"
>
<span class="font-mono text-xs text-text-subtle">
{filteredLogs.length}{filteredLogs.length !== logs.length
? ` / ${logs.length}`
: ""} {$t.tasks?.log_entries || "entries"}
</span>
<button
class="flex min-h-11 items-center gap-1.5 bg-transparent border-none text-xs cursor-pointer px-2 rounded transition-all hover:bg-surface-card hover:text-text
{autoScroll ? 'text-primary font-medium' : 'text-text-subtle'}"
onclick={toggleAutoScroll}
aria-label={$t.tasks?.toggle_autoscroll || "Toggle auto-scroll"}
>
{#if autoScroll}
<span
class="inline-block w-1.5 h-1.5 rounded-full bg-primary animate-pulse"
></span>
{/if}
{$t.tasks?.auto_scroll || "Auto-scroll"} {autoScroll
? ($t.tasks?.auto_scroll_on || "on")
: ($t.tasks?.auto_scroll_off || "off")}
</button>
</div>
</div>
<!-- #endregion Tasks.TaskLogPanel -->

View File

@@ -609,7 +609,7 @@ import { SvelteURLSearchParams, SvelteDate } from "svelte/reactivity";
</div>
</section>
{/if}
<div class="h-[500px]" data-testid="task-log-panel">
<div class="h-[min(60vh,480px)]" data-testid="task-log-panel">
<TaskLogPanel
taskId={$selectedTask.id}
logs={$taskLogs}

View File

@@ -132,5 +132,33 @@
"sed_rule_hint": "Replace a literal substring (e.g. a mart name) inside every dataset YAML of the export before import. Create, save and select a named rule.",
"rescan_ids_before_migration": "Rescan chart/dataset IDs before migration",
"rescan_ids_hint": "Refresh the target environment's chart/dataset/dashboard ID catalog so cross-filters resolve correctly.",
"rescan_ids_duration": "average ≈ {sec} sec"
"rescan_ids_duration": "average ≈ {sec} sec",
"review_plan": "Migration plan",
"review_analysis_at": "Analysis: {ts}",
"review_selected_count": "{n} dashboards selected",
"review_opt_cross_filters": "Cross-filters remapped",
"review_opt_db_mappings": "Database mappings applied",
"review_opt_dataset_replacement": "Dataset replacement applied",
"review_opt_key_retry": "Dataset key retry enabled",
"review_dashboard_plan": "Dashboard import plan",
"review_dashboard_plan_hint": "The dashboard is the migration artifact. Technical objects explain import risk.",
"review_total_count": "{n} total",
"review_outcome_overwrite": "Overwrites «{title}»",
"review_outcome_overwrite_unknown": "Overwrites target dashboard",
"review_outcome_new": "New on target",
"review_outcome_identical": "No changes",
"review_importable": "Importable",
"review_may_fail": "May fail",
"review_charts_datasets": "{charts} charts · {datasets} datasets",
"review_issues_count": "{n} import issue(s)",
"review_detail_overwrite": "Existing target artifact will be updated",
"review_detail_new": "Target artifact does not exist",
"review_detail_identical": "Target artifact matches",
"review_no_outcomes": "No dashboard outcomes returned by dry-run.",
"review_show_all": "Show all {n} dashboards",
"review_show_less": "Show less",
"review_technical_details": "Technical object details",
"review_technical_details_hint": "charts, datasets, UUID-level diff",
"confirm_start_title": "Start dashboard migration?",
"confirm_start_message": "{source} → {target} · {total} dashboards · {overwrite} existing target dashboards will be overwritten."
}

View File

@@ -132,5 +132,33 @@
"sed_rule_hint": "Замена литеральной подстроки (например, имени витрины) внутри YAML каждого датасета экспорта перед импортом. Создайте, сохраните и выберите именованное правило.",
"rescan_ids_before_migration": "Пересканировать ID чартов/датасетов перед миграцией",
"rescan_ids_hint": "Обновить каталог ID чартов/датасетов/дашбордов целевого окружения, чтобы кросс-фильтры разрешались корректно.",
"rescan_ids_duration": "среднее ≈ {sec} сек"
"rescan_ids_duration": "среднее ≈ {sec} сек",
"review_plan": "План миграции",
"review_analysis_at": "Анализ: {ts}",
"review_selected_count": "Выбрано дашбордов: {n}",
"review_opt_cross_filters": "Кросс-фильтры переназначены",
"review_opt_db_mappings": "Маппинги БД применены",
"review_opt_dataset_replacement": "Замена в датасетах применена",
"review_opt_key_retry": "Повтор с синхронизацией ключей включён",
"review_dashboard_plan": "План импорта дашбордов",
"review_dashboard_plan_hint": "Дашборд — артефакт миграции. Технические объекты объясняют риск импорта.",
"review_total_count": "Всего: {n}",
"review_outcome_overwrite": "Перезапишет «{title}»",
"review_outcome_overwrite_unknown": "Перезапишет дашборд в целевом окружении",
"review_outcome_new": "Новый в целевом окружении",
"review_outcome_identical": "Без изменений",
"review_importable": "Импортируется",
"review_may_fail": "Может упасть",
"review_charts_datasets": "чартов: {charts} · датасетов: {datasets}",
"review_issues_count": "проблем импорта: {n}",
"review_detail_overwrite": "Существующий артефакт в целевом окружении будет обновлён",
"review_detail_new": "Артефакт отсутствует в целевом окружении",
"review_detail_identical": "Артефакт совпадает с целевым",
"review_no_outcomes": "Dry-run не вернул результатов по дашбордам.",
"review_show_all": "Показать все {n} дашбордов",
"review_show_less": "Свернуть",
"review_technical_details": "Технические детали объектов",
"review_technical_details_hint": "чарты, датасеты, diff по UUID",
"confirm_start_title": "Запустить миграцию дашбордов?",
"confirm_start_message": "{source} → {target} · дашбордов: {total} · будет перезаписано в целевом окружении: {overwrite}."
}

View File

@@ -68,6 +68,19 @@ function isDryRunResult(payload: unknown): payload is DryRunResult {
typeof r.message === "string"
);
};
const isDashboardOutcome = (item: unknown): boolean => {
if (!item || typeof item !== "object") return false;
const outcome = item as Record<string, unknown>;
const counts = outcome.counts as Record<string, unknown> | undefined;
return (
(typeof outcome.id === "number" || typeof outcome.id === "string") &&
typeof outcome.title === "string" &&
["new", "overwrite", "identical"].includes(String(outcome.outcome)) &&
!!counts && typeof counts.charts === "number" && typeof counts.datasets === "number" &&
typeof outcome.importable === "boolean" &&
Array.isArray(outcome.issues) && outcome.issues.every(isRiskItem)
);
};
const summary = result.summary as Record<string, unknown> | undefined;
const diff = result.diff as Record<string, unknown> | undefined;
const risk = result.risk as Record<string, unknown> | undefined;
@@ -95,7 +108,9 @@ function isDryRunResult(payload: unknown): payload is DryRunResult {
typeof risk.warnings === "number" &&
typeof risk.confirmations === "number" &&
Array.isArray(risk.items) &&
risk.items.every(isRiskItem)
risk.items.every(isRiskItem) &&
(result.dashboards === undefined ||
(Array.isArray(result.dashboards) && result.dashboards.every(isDashboardOutcome)))
);
}
// #endregion Migration.ValidateDryRunResult
@@ -108,6 +123,8 @@ export class MigrationExecutor {
selectedDashboardIds: number[] = $state([]);
dryRunResult: DryRunResult | null = $state(null);
dryRunLoading: boolean = $state(false);
/** True while the execute POST is in flight — used to block navigation away from Step 4. */
submittingMigration: boolean = $state(false);
showPasswordPrompt: boolean = $state(false);
passwordPromptDatabases: string[] = $state([]);
passwordPromptErrorMessage: string = $state("");
@@ -152,9 +169,12 @@ export class MigrationExecutor {
async executeMigration(endpoint: string = "/migration/execute"): Promise<void> {
if (!this.parent._validatePreconditions()) return;
this.parent.error = "";
this.submittingMigration = true;
try {
this.dryRunResult = null;
const result = await api.postApi(endpoint, this.parent._buildSelection());
// Keep the review available until the task is accepted. A failed POST must return
// to an actionable review instead of leaving the user on an empty execution step.
this.dryRunResult = null;
await new Promise((r) => setTimeout(r, 500));
try {
const task = await api.getTask(result.task_id);
@@ -170,6 +190,9 @@ export class MigrationExecutor {
}
} catch (e: unknown) {
this.parent.error = e instanceof Error ? e.message : "Migration execution failed";
this.parent.wizard.currentStep = 3;
} finally {
this.submittingMigration = false;
}
}

View File

@@ -53,6 +53,10 @@ import { t } from "$lib/i18n/index.svelte.js";
import { WizardState } from "./Migration.WizardModel.svelte.ts";
import { MigrationExecutor } from "./Migration.ExecutorModel.svelte.ts";
import type { DryRunResult, DashboardSelection } from "./Migration.ExecutorModel.svelte.ts";
import type {
DashboardDryRunOutcome,
DryRunRiskItem,
} from "$types/dashboard";
// ── Types ─────────────────────────────────────────────────────
@@ -97,6 +101,8 @@ interface LogViewerTask {
[key: string]: unknown;
}
type ReviewDashboard = DashboardDryRunOutcome;
export class MigrationModel {
// ── Sub-models (instantiated after $state fields, before $derived) ─
@@ -168,6 +174,8 @@ export class MigrationModel {
get dryRunLoading(): boolean { return this.executor.dryRunLoading; }
set dryRunLoading(v: boolean) { this.executor.dryRunLoading = v; }
get submittingMigration(): boolean { return this.executor.submittingMigration; }
get isCalculating(): boolean { return this.executor.isCalculating; }
get preselectionState(): PreselectionState {
if (this.preselectionRequestedCount === 0) return "idle";
@@ -184,6 +192,63 @@ export class MigrationModel {
get passwordPromptErrorMessage(): string { return this.executor.passwordPromptErrorMessage; }
set passwordPromptErrorMessage(v: string) { this.executor.passwordPromptErrorMessage = v; }
/** Resolve an environment label for the dashboard-first review header. */
environmentName(envId: string): string {
return this.environments.find((env) => env.id === envId)?.name || envId;
}
/**
* Return dashboard-level dry-run outcomes, sorted for analyst actionability:
* non-importable (blockers) → overwrite → new → identical.
* Uses a legacy fallback for older API payloads without per-dashboard outcomes.
*/
get reviewDashboards(): ReviewDashboard[] {
const result = this.dryRunResult;
if (!result) return [];
let list: ReviewDashboard[];
if (result.dashboards?.length) {
list = result.dashboards;
} else {
const dashboardDiff = result.diff.dashboards;
list = result.selected_dashboard_titles.map((title, index) => {
const id = this.selectedDashboardIds[index] ?? index;
const created = dashboardDiff.create.find((item) => item.title === title);
const updated = dashboardDiff.update.find((item) => item.title === title);
const issues = result.risk.items.filter(
(item) => item.object_type === "dashboard" && item.object_uuid === String(id),
);
return {
id,
title,
outcome: created ? "new" : updated ? "overwrite" : "identical",
target_title: updated?.target_title || null,
counts: { charts: 0, datasets: 0 },
importable: !issues.some((item) => item.category === "blocker"),
issues,
};
});
}
const score = (d: ReviewDashboard) => {
if (!d.importable) return 0;
if (d.outcome === "overwrite") return 1;
if (d.outcome === "new") return 2;
return 3;
};
return [...list].sort((a, b) => score(a) - score(b) || a.title.localeCompare(b.title));
}
/** Resolve a risk item to the most useful human-readable object label. */
riskObjectLabel(item: DryRunRiskItem): string {
const bucket = this.dryRunResult?.diff[item.object_type as keyof DryRunResult["diff"]];
const allObjects = bucket
? [...bucket.create, ...bucket.update, ...bucket.delete]
: [];
const object = allObjects.find((candidate) => candidate.uuid === item.object_uuid);
return object?.title || item.object_uuid;
}
// ── Derived ─────────────────────────────────────────────────────
/** Whether each wizard step is ready to proceed. */

View File

@@ -397,6 +397,25 @@ describe("Migration.ExecutorModel — L1 invariants (no render)", () => {
await model.executeMigration();
expect(parent.error).toBe("Migration execution failed");
});
it("keeps dry-run result and returns to step 3 when execute POST fails", async () => {
model.dryRunResult = validDryRunResult();
parent.wizard.currentStep = 4;
api.postApi.mockRejectedValue(new Error("boom"));
await model.executeMigration();
expect(model.dryRunResult).not.toBeNull();
expect(parent.wizard.currentStep).toBe(3);
expect(model.submittingMigration).toBe(false);
});
it("clears dry-run result only after a successful submit", async () => {
model.dryRunResult = validDryRunResult();
api.postApi.mockResolvedValue({ task_id: "t1" });
api.getTask.mockResolvedValue({ id: "t1", status: "RUNNING" });
await model.executeMigration();
expect(model.dryRunResult).toBeNull();
expect(model.submittingMigration).toBe(false);
});
});
// #endregion Test.ExecutorModel.ExecuteMigration

View File

@@ -697,5 +697,60 @@ describe("MigrationModel — L1 invariants (no render)", () => {
);
});
});
// #region Test.MigrationModel.ReviewDashboards [C:2] [TYPE Function]
// @BRIEF reviewDashboards ordering and legacy fallback semantics.
describe("reviewDashboards", () => {
const outcome = (over: Record<string, unknown>) => ({
id: 1,
title: "D",
outcome: "new",
target_title: null,
counts: { charts: 0, datasets: 0 },
importable: true,
issues: [],
...over,
});
it("sorts non-importable first, then overwrite, new, identical", () => {
model.dryRunResult = {
dashboards: [
outcome({ id: 4, title: "Identical", outcome: "identical" }),
outcome({ id: 3, title: "New", outcome: "new" }),
outcome({ id: 2, title: "Overwrite", outcome: "overwrite" }),
outcome({ id: 1, title: "Blocked", importable: false }),
],
} as any;
expect(model.reviewDashboards.map((d) => d.title)).toEqual([
"Blocked",
"Overwrite",
"New",
"Identical",
]);
});
it("legacy fallback derives outcomes from diff buckets and counts zero", () => {
model.selectedDashboardIds = [10, 11];
model.dryRunResult = {
selected_dashboard_titles: ["Sales", "Costs"],
diff: {
dashboards: {
create: [{ uuid: "a", title: "Sales" }],
update: [{ uuid: "b", title: "Costs", target_title: "Costs Old" }],
delete: [],
},
charts: { create: [], update: [], delete: [] },
datasets: { create: [], update: [], delete: [] },
},
risk: { items: [] },
} as any;
const list = model.reviewDashboards;
expect(list.find((d) => d.id === 10)?.outcome).toBe("new");
expect(list.find((d) => d.id === 11)?.outcome).toBe("overwrite");
expect(list.find((d) => d.id === 11)?.target_title).toBe("Costs Old");
expect(list[0].counts).toEqual({ charts: 0, datasets: 0 });
});
});
// #endregion Test.MigrationModel.ReviewDashboards
});
// #endregion Models.MigrationModel.MigrationModelTests

View File

@@ -115,6 +115,26 @@ describe('formatDateTime', () => {
});
});
describe('formatDateTimeSeconds', () => {
it('returns empty string for null', async () => {
const { formatDateTimeSeconds } = await import('$lib/utils/dateFormat.js');
expect(formatDateTimeSeconds(null)).toBe('');
});
it('formats date+time with seconds in the app timezone', async () => {
const { formatDateTimeSeconds } = await import('$lib/utils/dateFormat.js');
const result = formatDateTimeSeconds('2026-07-08T12:00:05Z');
expect(result).toContain('2026');
expect(result).toMatch(/:00:05/);
});
it('returns empty string for invalid date', async () => {
const { formatDateTimeSeconds } = await import('$lib/utils/dateFormat.js');
expect(formatDateTimeSeconds('garbage')).toBe('');
});
});
describe('formatTime', () => {
it('returns empty string for null', async () => {
const { formatTime } = await import('$lib/utils/dateFormat.js');

View File

@@ -58,6 +58,25 @@ export function formatDateTime(value: string | Date | null | undefined): string
}
// #endregion Utils.DateFormat.FormatDateTimeFunction
// #region Utils.DateFormat.FormatDateTimeSecondsFunction [C:2] [TYPE Function] [SEMANTICS date,format,seconds,timezone]
// @ingroup Utils.DateFormat
// @BRIEF Format ISO date string to localized date+time with seconds in the app timezone.
export function formatDateTimeSeconds(value: string | Date | null | undefined): string {
if (!value) return "";
const d = parseDateUTC(value);
if (isNaN(d.getTime())) return "";
return d.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
timeZone: appTimezone.current,
});
}
// #endregion Utils.DateFormat.FormatDateTimeSecondsFunction
// #region Utils.DateFormat.FormatTimeFunction [TYPE Function]
// @BRIEF Format ISO date string to localized time in app timezone (e.g. "14:30:15").
export function formatTime(value: string | Date | null | undefined, showSeconds: boolean = false): string {

View File

@@ -37,12 +37,13 @@
import TaskHistory from "$lib/components/tasks/TaskHistory.svelte";
import TaskLogViewer from "$lib/components/tasks/TaskLogViewer.svelte";
import PasswordPrompt from "$lib/components/migration/PasswordPrompt.svelte";
import DryRunDiffList from "$lib/components/migration/DryRunDiffList.svelte";
import MigrationReviewPanel from "$lib/components/migration/MigrationReviewPanel.svelte";
import SedRuleEditor from "$lib/components/SedRuleEditor.svelte";
import { MigrationModel } from "../../lib/models/MigrationModel.svelte.ts";
import { t } from "$lib/i18n/index.svelte.js";
import { Button, Card, Icon, PageHeader } from "$lib/ui";
import { Button, Card, ConfirmDialog, Icon, PageHeader } from "$lib/ui";
import { formatDateTimeSeconds } from "$lib/utils/dateFormat.js";
// ── Model: single source of truth ───────────────────────────────
const model = new MigrationModel();
@@ -92,19 +93,10 @@
: "",
);
const visibleSelectedDashboardTitles = $derived(
(model.dryRunResult?.selected_dashboard_titles || []).slice(0, 5),
);
const hiddenSelectedDashboardCount = $derived(
Math.max(0, (model.dryRunResult?.selected_dashboard_titles?.length || 0) - visibleSelectedDashboardTitles.length),
);
const visibleRiskItems = $derived(
(model.dryRunResult?.risk.items || []).filter((item) => item.category !== "confirmation").slice(0, 5),
);
const hiddenRiskCount = $derived(
Math.max(0, (model.dryRunResult?.risk.items || []).filter((item) => item.category !== "confirmation").length - visibleRiskItems.length),
);
let showExecuteConfirm = $state(false);
const reviewDashboards = $derived(model.reviewDashboards);
const hasBlockingRisks = $derived((model.dryRunResult?.risk.blockers || 0) > 0);
const reviewOverwriteCount = $derived(reviewDashboards.filter((dashboard) => dashboard.outcome === "overwrite").length);
</script>
<div class="max-w-6xl mx-auto p-4 sm:p-6 lg:p-8">
@@ -297,7 +289,12 @@
data={model.dashboards}
columns={[
{ key: 'title', label: $t.dashboard?.title || 'Title', sortable: true },
{ key: 'last_modified', label: $t.dashboard?.last_modified || 'Last Modified', sortable: true },
{
key: 'last_modified',
label: $t.dashboard?.last_modified || 'Last Modified',
sortable: true,
render: (item: any) => formatDateTimeSeconds(item.last_modified),
},
{
key: 'status',
label: $t.dashboard?.status || 'Status',
@@ -526,208 +523,14 @@
<h3 class="text-lg font-semibold text-text">{$t.migration?.review_validate || "Review & Validate"}</h3>
</div>
{#if model.dryRunResult}
<!-- Selection Summary -->
<div class="mb-6 p-4 bg-info-light border border-info-ring rounded-xl">
<div class="flex items-center gap-2 mb-2">
<svg class="w-5 h-5 text-info" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<h3 class="text-sm font-semibold text-info-hover">{$t.migration?.selected_dashboards || "Selected Dashboards"}</h3>
</div>
<div class="mb-2 text-sm text-info-hover">
{($t.migration?.selected_dashboard_count || "{n} dashboard(s) selected").replace("{n}", String(model.dryRunResult.summary.selected_dashboards))}
</div>
<div class="flex flex-wrap gap-2">
{#each visibleSelectedDashboardTitles as title (title)}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-info-light text-info">
{title}
</span>
{/each}
{#if hiddenSelectedDashboardCount > 0}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-surface-card text-info">
{($t.migration?.and_more || "+{n} more").replace("{n}", String(hiddenSelectedDashboardCount))}
</span>
{/if}
</div>
</div>
<!-- Diff Summary Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<!-- Dashboards -->
<div class="p-4 border border-border rounded-xl bg-surface-card">
<div class="flex items-center gap-2 mb-3">
<div class="w-8 h-8 rounded-lg bg-info-light flex items-center justify-center">
<Icon name="dashboard" size={16} class="text-info" strokeWidth={2} />
</div>
<h4 class="text-sm font-semibold text-text">{$t.migration?.dashboards || "Dashboards"}</h4>
</div>
<div class="space-y-1.5 text-sm">
<div class="flex items-center justify-between">
<span class="text-success font-medium">{$t.migration?.create || "Create"}</span>
<span class="font-semibold text-success">{model.dryRunResult.summary.dashboards.create}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-warning font-medium">{$t.migration?.update || "Update"}</span>
<span class="font-semibold text-warning">{model.dryRunResult.summary.dashboards.update}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-destructive font-medium">{$t.migration?.delete || "Delete"}</span>
<span class="font-semibold text-destructive">{model.dryRunResult.summary.dashboards.delete}</span>
</div>
</div>
</div>
<!-- Charts -->
<div class="p-4 border border-border rounded-xl bg-surface-card">
<div class="flex items-center gap-2 mb-3">
<div class="w-8 h-8 rounded-lg bg-primary-light flex items-center justify-center">
<svg class="w-4 h-4 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</div>
<h4 class="text-sm font-semibold text-text">{$t.migration?.charts || "Charts"}</h4>
</div>
<div class="space-y-1.5 text-sm">
<div class="flex items-center justify-between">
<span class="text-success font-medium">{$t.migration?.create || "Create"}</span>
<span class="font-semibold text-success">{model.dryRunResult.summary.charts.create}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-warning font-medium">{$t.migration?.update || "Update"}</span>
<span class="font-semibold text-warning">{model.dryRunResult.summary.charts.update}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-destructive font-medium">{$t.migration?.delete || "Delete"}</span>
<span class="font-semibold text-destructive">{model.dryRunResult.summary.charts.delete}</span>
</div>
</div>
</div>
<!-- Datasets -->
<div class="p-4 border border-border rounded-xl bg-surface-card">
<div class="flex items-center gap-2 mb-3">
<div class="w-8 h-8 rounded-lg bg-success-light flex items-center justify-center">
<Icon name="database" size={16} class="text-success" strokeWidth={2} />
</div>
<h4 class="text-sm font-semibold text-text">{$t.migration?.datasets || "Datasets"}</h4>
</div>
<div class="space-y-1.5 text-sm">
<div class="flex items-center justify-between">
<span class="text-success font-medium">{$t.migration?.create || "Create"}</span>
<span class="font-semibold text-success">{model.dryRunResult.summary.datasets.create}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-warning font-medium">{$t.migration?.update || "Update"}</span>
<span class="font-semibold text-warning">{model.dryRunResult.summary.datasets.update}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-destructive font-medium">{$t.migration?.delete || "Delete"}</span>
<span class="font-semibold text-destructive">{model.dryRunResult.summary.datasets.delete}</span>
</div>
</div>
</div>
</div>
<!-- Diff Object List -->
<div class="mb-6">
<DryRunDiffList diff={model.dryRunResult.diff} />
</div>
<!-- Risk Assessment -->
<div class="mb-6 p-4 border border-border rounded-xl bg-surface-card">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01" />
</svg>
<h3 class="text-sm font-semibold text-text">{$t.migration?.risk_assessment || "Risk Assessment"}</h3>
</div>
<div class="flex items-center gap-3">
<div class="flex items-center gap-2">
<span class="text-xs text-text-muted">{$t.migration?.risk_score || "Score"}:</span>
<div class={model.dryRunResult.risk.score <= 20 ? 'px-2.5 py-1 rounded-full text-xs font-bold bg-success-light text-success' : model.dryRunResult.risk.score <= 50 ? 'px-2.5 py-1 rounded-full text-xs font-bold bg-warning-light text-warning' : 'px-2.5 py-1 rounded-full text-xs font-bold bg-destructive-light text-destructive'}>
{model.dryRunResult.risk.score}/100
</div>
</div>
<div class={model.dryRunResult.risk.level === 'low' ? 'px-2.5 py-1 rounded-full text-xs font-bold bg-success-light text-success' : model.dryRunResult.risk.level === 'medium' ? 'px-2.5 py-1 rounded-full text-xs font-bold bg-warning-light text-warning' : 'px-2.5 py-1 rounded-full text-xs font-bold bg-destructive-light text-destructive'}>
{model.dryRunResult.risk.level === "low"
? ($t.migration?.risk_level_low || "Low")
: model.dryRunResult.risk.level === "medium"
? ($t.migration?.risk_level_medium || "Medium")
: ($t.migration?.risk_level_high || "High")}
</div>
</div>
</div>
<!-- Risk Progress Bar -->
<div class="w-full h-2 bg-surface-muted rounded-full mb-4 overflow-hidden">
<div
class="h-full rounded-full transition-all duration-700"
class:bg-success={model.dryRunResult.risk.score <= 20}
class:bg-warning={model.dryRunResult.risk.score > 20 && model.dryRunResult.risk.score <= 50}
class:bg-destructive={model.dryRunResult.risk.score > 50}
style="width: {model.dryRunResult.risk.score}%"
></div>
</div>
<div class="mb-4 grid grid-cols-1 gap-2 text-xs sm:grid-cols-3">
<div class="rounded bg-destructive-light px-3 py-2 text-destructive">{($t.migration?.risk_blockers || "Blockers: {n}").replace("{n}", String(model.dryRunResult.risk.blockers))}</div>
<div class="rounded bg-warning-light px-3 py-2 text-warning">{($t.migration?.risk_warnings || "Warnings: {n}").replace("{n}", String(model.dryRunResult.risk.warnings))}</div>
<div class="rounded bg-info-light px-3 py-2 text-info">{($t.migration?.risk_confirmations || "Updates to confirm: {n}").replace("{n}", String(model.dryRunResult.risk.confirmations))}</div>
</div>
{#if hasBlockingRisks}
<p class="mb-4 text-sm text-destructive">{$t.migration?.risk_blocked_hint || "Resolve the blocking issues before starting migration."}</p>
{:else}
<p class="mb-4 text-sm text-text-muted">{$t.migration?.risk_ready_hint || "No blocking issues detected. Review updates, then start migration when ready."}</p>
{/if}
<!-- Risk Items -->
{#if visibleRiskItems.length > 0}
<div class="space-y-2">
{#each visibleRiskItems as item (`${item.object_type}:${item.object_uuid}:${item.message}`)}
<div class={item.category === 'blocker' ? 'flex items-start gap-3 rounded-lg bg-destructive-light p-2.5 text-sm' : item.severity === 'medium' ? 'flex items-start gap-3 rounded-lg bg-warning-light p-2.5 text-sm' : 'flex items-start gap-3 rounded-lg bg-info-light p-2.5 text-sm'}>
<div class={item.category === 'blocker' ? 'mt-1.5 h-2 w-2 flex-shrink-0 rounded-full bg-destructive' : item.severity === 'medium' ? 'mt-1.5 h-2 w-2 flex-shrink-0 rounded-full bg-warning' : 'mt-1.5 h-2 w-2 flex-shrink-0 rounded-full bg-info'}></div>
<div>
<p class="font-medium text-text">{item.message}</p>
<p class="text-xs text-text-muted mt-0.5">{item.object_type} · {item.object_uuid}</p>
</div>
</div>
{/each}
</div>
{#if hiddenRiskCount > 0}
<details class="mt-3 text-sm text-text-muted">
<summary class="cursor-pointer text-primary hover:text-primary-hover">{($t.migration?.risk_show_all || "Show {n} more issues").replace("{n}", String(hiddenRiskCount))}</summary>
<div class="mt-2 max-h-64 space-y-2 overflow-y-auto pr-1">
{#each (model.dryRunResult.risk.items || []).filter((item) => item.category !== "confirmation").slice(5) as item (`more:${item.object_type}:${item.object_uuid}:${item.message}`)}
<p class="rounded bg-surface-muted px-3 py-2 text-xs text-text">{item.message}</p>
{/each}
</div>
</details>
{/if}
{:else}
<p class="text-sm text-text-muted">{$t.migration?.no_issues || "No issues detected."}</p>
{/if}
</div>
{:else}
<div class="flex flex-col items-center justify-center py-8 text-text-subtle">
<svg class="w-12 h-12 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p class="text-sm">{$t.migration?.no_dry_run || "Run a dry-run to see the migration summary here."}</p>
</div>
{/if}
<MigrationReviewPanel {model} />
<div class="mt-6 flex items-center justify-between border-t border-border pt-6">
<Button variant="secondary" onclick={() => model.goToStep(2)}>
← {$t.common.back}
</Button>
<Button
onclick={() => {
model.goToStep(4);
model.executeMigration();
}}
onclick={() => (showExecuteConfirm = true)}
disabled={!model.dryRunResult || hasBlockingRisks}
>
{$t.migration?.start || "Start Migration"}
@@ -745,9 +548,11 @@
<Icon name="lightning" size={64} class="text-info mb-4 animate-pulse" strokeWidth={1.5} />
<h3 class="text-lg font-semibold text-text mb-2">{$t.migration?.starting || "Starting Migration..."}</h3>
<p class="text-sm text-text-muted">{$t.common.loading || "Please wait..."}</p>
<Button variant="secondary" class="mt-6" onclick={() => model.goToStep(3)}>
← {$t.common.back}
</Button>
{#if !model.submittingMigration}
<Button variant="secondary" class="mt-6" onclick={() => model.goToStep(3)}>
← {$t.common.back}
</Button>
{/if}
</div>
</Card>
{/if}
@@ -785,6 +590,23 @@
onresume={({ passwords }) => model.resumeMigration(passwords)}
oncancel={() => model.showPasswordPrompt = false}
/>
<ConfirmDialog
bind:show={showExecuteConfirm}
title={$t.migration?.confirm_start_title || "Start dashboard migration?"}
message={($t.migration?.confirm_start_message || "{source} → {target} · {total} dashboards · {overwrite} existing target dashboards will be overwritten.")
.replace("{source}", model.environmentName(model.sourceEnvId))
.replace("{target}", model.environmentName(model.targetEnvId))
.replace("{total}", String(reviewDashboards.length))
.replace("{overwrite}", String(reviewOverwriteCount))}
confirmLabel={$t.migration?.start || "Start Migration"}
cancelLabel={$t.common?.cancel || "Cancel"}
variant="destructive"
confirmDisabled={hasBlockingRisks}
onConfirm={() => {
model.goToStep(4);
void model.executeMigration();
}}
/>
<!-- #endregion Migration.Page.MigrationModals -->
<!-- #endregion Migration.Page.MigrationPage -->

View File

@@ -49,13 +49,31 @@ export interface DryRunRiskItem {
category: "blocker" | "warning" | "confirmation";
object_type: string;
object_uuid: string;
dataset_uuid?: string | null;
message: string;
}
export type DashboardDryRunOutcomeKind = "new" | "overwrite" | "identical";
export interface DashboardDryRunOutcome {
id: number | string;
title: string;
outcome: DashboardDryRunOutcomeKind;
target_title?: string | null;
counts: {
charts: number;
datasets: number;
};
importable: boolean;
issues: DryRunRiskItem[];
}
export interface MigrationDryRunResult {
generated_at: string;
selection: DashboardSelection;
selected_dashboard_titles: string[];
/** Per-dashboard primary review data. Optional for rolling API compatibility. */
dashboards?: DashboardDryRunOutcome[];
diff: {
dashboards: DiffBucket;
charts: DiffBucket;