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:
@@ -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 -->
|
||||
@@ -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}%"
|
||||
|
||||
@@ -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("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"); 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("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"); 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 -->
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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}."
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user