{ "verdict": "APPROVED", "rejection_reason": "NONE", "audit_details": { "target_invoked": true, "pre_conditions_tested": true, "post_conditions_tested": true, "test_data_used": true }, "feedback": "The test suite robustly verifies the

MigrationEngine
 contracts. It avoids Tautologies by cleanly substituting IdMappingService without mocking the engine itself. Cross-filter parsing asserts against hard-coded, predefined validation dictionaries (no Logic Mirroring). It successfully addresses @PRE negative cases (e.g. invalid zip paths, missing YAMLs) and rigorously validates @POST file transformations (e.g. in-place UUID substitutions and archive reconstruction)." }
This commit is contained in:
2026-02-25 17:47:55 +03:00
parent 590ba49ddb
commit 99f19ac305
20 changed files with 1211 additions and 308 deletions

View File

@@ -57,6 +57,8 @@
let sourceDatabases = [];
let targetDatabases = [];
let isEditingMappings = false;
let useDbMappings = true;
let fixCrossFilters = true;
// Individual action dropdown state
let openActionDropdown = null; // stores dashboard ID
@@ -292,31 +294,31 @@
// Handle validate - LLM dashboard validation
async function handleValidate(dashboard) {
if (validatingIds.has(dashboard.id)) return;
validatingIds.add(dashboard.id);
validatingIds = new Set(validatingIds); // Trigger reactivity
closeActionDropdown();
try {
const response = await api.postApi('/tasks', {
plugin_id: 'llm_dashboard_validation',
const response = await api.postApi("/tasks", {
plugin_id: "llm_dashboard_validation",
params: {
dashboard_id: dashboard.id.toString(),
environment_id: selectedEnv
}
environment_id: selectedEnv,
},
});
console.log('[DashboardHub][Action] Validation task started:', response);
console.log("[DashboardHub][Action] Validation task started:", response);
// Open task drawer if task was created
if (response.task_id || response.id) {
const taskId = response.task_id || response.id;
openDrawerForTask(taskId);
}
} catch (err) {
console.error('[DashboardHub][Coherence:Failed] Validation failed:', err);
alert('Failed to start validation: ' + (err.message || 'Unknown error'));
console.error("[DashboardHub][Coherence:Failed] Validation failed:", err);
alert("Failed to start validation: " + (err.message || "Unknown error"));
} finally {
validatingIds.delete(dashboard.id);
validatingIds = new Set(validatingIds);
@@ -407,8 +409,9 @@
source_env_id: selectedEnv,
target_env_id: targetEnvId,
dashboard_ids: Array.from(selectedIds),
db_mappings: dbMappings,
replace_db_config: Object.keys(dbMappings).length > 0,
db_mappings: useDbMappings ? dbMappings : {},
replace_db_config: useDbMappings && Object.keys(dbMappings).length > 0,
fix_cross_filters: fixCrossFilters,
});
console.log(
"[DashboardHub][Action] Bulk migration task created:",
@@ -483,7 +486,9 @@
}
function navigateToDashboardDetail(dashboardId) {
goto(`/dashboards/${dashboardId}?env_id=${encodeURIComponent(selectedEnv)}`);
goto(
`/dashboards/${dashboardId}?env_id=${encodeURIComponent(selectedEnv)}`,
);
}
// Get status badge class
@@ -562,7 +567,9 @@
<div class="mx-auto w-full max-w-7xl space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-gray-900">{$t.nav?.dashboard || "Dashboards"}</h1>
<h1 class="text-2xl font-bold text-gray-900">
{$t.nav?.dashboard || "Dashboards"}
</h1>
<div class="flex items-center space-x-4">
<select
class="px-4 py-2 border border-gray-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-blue-500"
@@ -573,7 +580,10 @@
<option value={env.id}>{env.name}</option>
{/each}
</select>
<button class="inline-flex items-center justify-center rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-hover" on:click={loadDashboards}>
<button
class="inline-flex items-center justify-center rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-hover"
on:click={loadDashboards}
>
{$t.common?.refresh || "Refresh"}
</button>
</div>
@@ -581,9 +591,14 @@
<!-- Error Banner -->
{#if error}
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4 flex items-center justify-between">
<div
class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4 flex items-center justify-between"
>
<span>{error}</span>
<button class="px-4 py-2 bg-destructive text-white rounded hover:bg-destructive-hover transition-colors" on:click={loadDashboards}>
<button
class="px-4 py-2 bg-destructive text-white rounded hover:bg-destructive-hover transition-colors"
on:click={loadDashboards}
>
{$t.common?.retry || "Retry"}
</button>
</div>
@@ -592,7 +607,9 @@
<!-- Loading State -->
{#if isLoading}
<div class="bg-white border border-gray-200 rounded-lg overflow-hidden">
<div class="grid grid-cols-12 gap-4 px-6 py-3 bg-gray-50 border-b border-gray-200 font-semibold text-sm text-gray-700">
<div
class="grid grid-cols-12 gap-4 px-6 py-3 bg-gray-50 border-b border-gray-200 font-semibold text-sm text-gray-700"
>
<div class="col-span-1 skeleton h-4"></div>
<div class="col-span-3 font-medium text-gray-900 skeleton h-4"></div>
<div class="col-span-2 skeleton h-4"></div>
@@ -600,7 +617,9 @@
<div class="col-span-3 flex items-center skeleton h-4"></div>
</div>
{#each Array(5) as _}
<div class="grid grid-cols-12 gap-4 px-6 py-4 border-b border-gray-200 last:border-b-0 hover:bg-gray-50 transition-colors">
<div
class="grid grid-cols-12 gap-4 px-6 py-4 border-b border-gray-200 last:border-b-0 hover:bg-gray-50 transition-colors"
>
<div class="col-span-1 skeleton h-4"></div>
<div class="col-span-3 font-medium text-gray-900 skeleton h-4"></div>
<div class="col-span-2 skeleton h-4"></div>
@@ -662,17 +681,27 @@
<!-- Dashboard Grid -->
<div class="bg-white border border-gray-200 rounded-lg overflow-hidden">
<!-- Grid Header -->
<div class="grid grid-cols-12 gap-4 px-6 py-3 bg-gray-50 border-b border-gray-200 font-semibold text-sm text-gray-700">
<div
class="grid grid-cols-12 gap-4 px-6 py-3 bg-gray-50 border-b border-gray-200 font-semibold text-sm text-gray-700"
>
<div class="col-span-1"></div>
<div class="col-span-3 font-medium text-gray-900">{$t.dashboards?.title || "Title"}</div>
<div class="col-span-2">{$t.dashboards?.git_status || "Git Status"}</div>
<div class="col-span-3 font-medium text-gray-900">
{$t.dashboards?.title || "Title"}
</div>
<div class="col-span-2">
{$t.dashboards?.git_status || "Git Status"}
</div>
<div class="col-span-3">{$t.dashboards?.last_task || "Last Task"}</div>
<div class="col-span-3 flex items-center">{$t.dashboards?.actions || "Actions"}</div>
<div class="col-span-3 flex items-center">
{$t.dashboards?.actions || "Actions"}
</div>
</div>
<!-- Grid Rows -->
{#each dashboards as dashboard}
<div class="grid grid-cols-12 gap-4 px-6 py-4 border-b border-gray-200 last:border-b-0 hover:bg-gray-50 transition-colors">
<div
class="grid grid-cols-12 gap-4 px-6 py-4 border-b border-gray-200 last:border-b-0 hover:bg-gray-50 transition-colors"
>
<!-- Checkbox -->
<div class="col-span-1">
<input
@@ -749,8 +778,15 @@
on:click={() => handleAction(dashboard, "migrate")}
title={$t.dashboards?.action_migrate || "Migrate"}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M5 12h14M12 5l7 7-7 7"/>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
</button>
<button
@@ -760,13 +796,34 @@
title={$t.dashboards?.action_validate || "Validate"}
>
{#if validatingIds.has(dashboard.id)}
<svg class="animate-spin" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" stroke-dasharray="32" stroke-dashoffset="12"/>
<svg
class="animate-spin"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle
cx="12"
cy="12"
r="10"
stroke-dasharray="32"
stroke-dashoffset="12"
/>
</svg>
{:else}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 12l2 2 4-4"/>
<circle cx="12" cy="12" r="10"/>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M9 12l2 2 4-4" />
<circle cx="12" cy="12" r="10" />
</svg>
{/if}
</button>
@@ -775,10 +832,17 @@
on:click={() => handleAction(dashboard, "backup")}
title={$t.dashboards?.action_backup || "Backup"}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7,10 12,15 17,10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7,10 12,15 17,10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
</button>
</div>
@@ -789,7 +853,9 @@
<!-- Pagination -->
{#if totalPages > 1}
<div class="flex items-center justify-between px-4 py-3 bg-gray-50 border-t border-gray-200">
<div
class="flex items-center justify-between px-4 py-3 bg-gray-50 border-t border-gray-200"
>
<div class="text-sm text-gray-600">
Showing {(currentPage - 1) * pageSize + 1}-{Math.min(
currentPage * pageSize,
@@ -856,7 +922,9 @@
<!-- Floating Bulk Action Panel -->
{#if selectedIds.size > 0}
<div class="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 shadow-lg p-4 transition-transform transform translate-y-0">
<div
class="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 shadow-lg p-4 transition-transform transform translate-y-0"
>
<div class="flex items-center justify-between max-w-7xl mx-auto">
<div class="flex items-center gap-4">
<span class="font-medium">
@@ -884,7 +952,10 @@
>
Backup
</button>
<button class="px-3 py-1 text-sm border border-gray-300 rounded hover:bg-gray-100 transition-colors" on:click={() => selectedIds.clear()}>
<button
class="px-3 py-1 text-sm border border-gray-300 rounded hover:bg-gray-100 transition-colors"
on:click={() => selectedIds.clear()}
>
Cancel
</button>
</div>
@@ -910,7 +981,9 @@
aria-labelledby="migrate-modal-title"
on:keydown={(e) => e.stopPropagation()}
>
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between relative">
<div
class="px-6 py-4 border-b border-gray-200 flex items-center justify-between relative"
>
<h2 id="migrate-modal-title" class="text-xl font-bold">
Migrate {selectedIds.size} Dashboards
</h2>
@@ -973,99 +1046,150 @@
</select>
</div>
<!-- Database Mappings Table -->
<!-- Database Mappings Toggle -->
<div>
<label
for="db-mappings-modal"
class="block text-sm font-medium text-gray-700"
>Database Mappings</label
>
<div class="flex items-center justify-between mb-2">
{#if availableDbMappings.length > 0}
<button
class="text-sm text-blue-600 hover:underline"
on:click={() => (isEditingMappings = !isEditingMappings)}
<label
for="use-db-mappings"
class="block text-sm font-medium text-gray-700"
>Database Mappings</label
>
<label class="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
id="use-db-mappings"
bind:checked={useDbMappings}
class="sr-only peer"
/>
<div
class="w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-blue-600"
></div>
<span class="ml-2 text-xs text-gray-500"
>{useDbMappings ? "On" : "Off"}</span
>
{isEditingMappings ? "View Summary" : "Edit Mappings"}
</button>
{/if}
</label>
</div>
{#if isEditingMappings}
<div class="border border-gray-200 rounded-lg overflow-hidden">
<MappingTable
{sourceDatabases}
{targetDatabases}
mappings={Object.entries(dbMappings).map(([s, t]) => ({
source_db_uuid: s,
target_db_uuid: t,
}))}
suggestions={availableDbMappings}
on:update={handleMappingUpdate}
/>
{#if useDbMappings}
<div class="flex items-center justify-between mb-2">
{#if availableDbMappings.length > 0}
<button
class="text-sm text-blue-600 hover:underline"
on:click={() => (isEditingMappings = !isEditingMappings)}
>
{isEditingMappings ? "View Summary" : "Edit Mappings"}
</button>
{/if}
</div>
{:else}
<div class="border border-gray-200 rounded-lg overflow-hidden">
<table class="w-full text-sm text-left">
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<th class="px-4 py-2 font-semibold text-gray-700"
>Source Database</th
>
<th class="px-4 py-2 font-semibold text-gray-700"
>Target Database</th
>
<th class="px-4 py-2 font-semibold text-gray-700"
>Match %</th
>
</tr>
</thead>
<tbody>
{#if availableDbMappings.length > 0}
{#each availableDbMappings as mapping}
<tr class="border-b border-gray-200 last:border-b-0">
<td class="px-4 py-2">{mapping.source_db}</td>
<td class="px-4 py-2">
{#if dbMappings[mapping.source_db_uuid]}
{targetDatabases.find(
(d) =>
d.uuid ===
dbMappings[mapping.source_db_uuid],
)?.database_name || mapping.target_db}
{:else}
<span class="text-red-500">Not mapped</span>
{/if}
</td>
<td class="px-4 py-2">
<span
class="px-2 py-0.5 rounded-full text-xs font-medium {mapping.confidence >
0.9
? 'bg-green-100 text-green-800'
: 'bg-yellow-100 text-yellow-800'}"
>
{Math.round(mapping.confidence * 100)}%
</span>
{#if isEditingMappings}
<div
class="border border-gray-200 rounded-lg overflow-hidden"
>
<MappingTable
{sourceDatabases}
{targetDatabases}
mappings={Object.entries(dbMappings).map(([s, t]) => ({
source_db_uuid: s,
target_db_uuid: t,
}))}
suggestions={availableDbMappings}
on:update={handleMappingUpdate}
/>
</div>
{:else}
<div
class="border border-gray-200 rounded-lg overflow-hidden"
>
<table class="w-full text-sm text-left">
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<th class="px-4 py-2 font-semibold text-gray-700"
>Source Database</th
>
<th class="px-4 py-2 font-semibold text-gray-700"
>Target Database</th
>
<th class="px-4 py-2 font-semibold text-gray-700"
>Match %</th
>
</tr>
</thead>
<tbody>
{#if availableDbMappings.length > 0}
{#each availableDbMappings as mapping}
<tr
class="border-b border-gray-200 last:border-b-0"
>
<td class="px-4 py-2">{mapping.source_db}</td>
<td class="px-4 py-2">
{#if dbMappings[mapping.source_db_uuid]}
{targetDatabases.find(
(d) =>
d.uuid ===
dbMappings[mapping.source_db_uuid],
)?.database_name || mapping.target_db}
{:else}
<span class="text-red-500">Not mapped</span>
{/if}
</td>
<td class="px-4 py-2">
<span
class="px-2 py-0.5 rounded-full text-xs font-medium {mapping.confidence >
0.9
? 'bg-green-100 text-green-800'
: 'bg-yellow-100 text-yellow-800'}"
>
{Math.round(mapping.confidence * 100)}%
</span>
</td>
</tr>
{/each}
{:else}
<tr>
<td
colspan="3"
class="px-4 py-4 text-center text-gray-500 italic"
>
{targetEnvId
? "No databases found to map"
: "Select target environment to see mappings"}
</td>
</tr>
{/each}
{:else}
<tr>
<td
colspan="3"
class="px-4 py-4 text-center text-gray-500 italic"
>
{targetEnvId
? "No databases found to map"
: "Select target environment to see mappings"}
</td>
</tr>
{/if}
</tbody>
</table>
</div>
{/if}
</tbody>
</table>
</div>
{/if}
{:else}
<p class="text-xs text-gray-400 italic">
Database mapping is disabled. Dashboards will be imported with
original database references.
</p>
{/if}
</div>
<!-- Fix Cross-Filters (Spec 022 FR-007) -->
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
<label class="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
bind:checked={fixCrossFilters}
class="mt-0.5 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<div>
<span class="text-sm font-medium text-gray-900"
>Исправить связи кросс-фильтрации</span
>
<p class="text-xs text-gray-500 mt-0.5">
Автоматически перепривязать ID чартов и датасетов в
кросс-фильтрах к ID целевого окружения. Рекомендуется при
миграции дашбордов с кросс-фильтрами.
</p>
</div>
</label>
</div>
<!-- Selected Dashboards List -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2"
@@ -1089,14 +1213,17 @@
</div>
</div>
<div class="px-6 py-4 border-t border-gray-200 flex justify-end gap-3">
<button class="px-3 py-1 text-sm border border-gray-300 rounded hover:bg-gray-100 transition-colors" on:click={() => (showMigrateModal = false)}
disabled={isSubmittingMigrate}
>Cancel</button
<button
class="px-3 py-1 text-sm border border-gray-300 rounded hover:bg-gray-100 transition-colors"
on:click={() => (showMigrateModal = false)}
disabled={isSubmittingMigrate}>Cancel</button
>
<button
class="px-3 py-1 text-sm bg-primary text-white border-primary rounded hover:bg-primary-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
on:click={handleBulkMigrate}
disabled={!targetEnvId || selectedIds.size === 0 || isSubmittingMigrate}
disabled={!targetEnvId ||
selectedIds.size === 0 ||
isSubmittingMigrate}
>
{isSubmittingMigrate ? "Starting..." : "Start Migration"}
</button>
@@ -1122,7 +1249,9 @@
aria-labelledby="backup-modal-title"
on:keydown={(e) => e.stopPropagation()}
>
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between relative">
<div
class="px-6 py-4 border-b border-gray-200 flex items-center justify-between relative"
>
<h2 id="backup-modal-title" class="text-xl font-bold">
Backup {selectedIds.size} Dashboards
</h2>
@@ -1238,9 +1367,10 @@
</div>
</div>
<div class="px-6 py-4 border-t border-gray-200 flex justify-end gap-3">
<button class="px-3 py-1 text-sm border border-gray-300 rounded hover:bg-gray-100 transition-colors" on:click={() => (showBackupModal = false)}
disabled={isSubmittingBackup}
>Cancel</button
<button
class="px-3 py-1 text-sm border border-gray-300 rounded hover:bg-gray-100 transition-colors"
on:click={() => (showBackupModal = false)}
disabled={isSubmittingBackup}>Cancel</button
>
<button
class="px-3 py-1 text-sm bg-primary text-white border-primary rounded hover:bg-primary-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed"