From 08a0e95a3ab76d798e14201abe795fe0f1ee1a1b Mon Sep 17 00:00:00 2001 From: Tej Kashi Date: Mon, 15 Dec 2025 12:02:14 -0500 Subject: [PATCH 1/3] Add tests for advanced repair --- .github/workflows/test.yml | 5 +- internal/consistency/repair/executor.go | 13 ++- tests/integration/advanced_repair_test.go | 134 ++++++++++++++++++++++ 3 files changed, 149 insertions(+), 3 deletions(-) create mode 100644 tests/integration/advanced_repair_test.go diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0c6a177..a40ce25 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -38,4 +38,7 @@ jobs: run: go test -count=1 -v ./tests/integration -run 'CDC' - name: Run crash recovery origin-filter test - run: go test -count=1 -v ./tests/integration -run 'TestTableDiffOnlyOriginWithUntil' + run: go test -count=1 -v ./tests/integration -run 'TestTableDiffAgainstOriginWithUntil' + + - name: Run crash recovery origin-filter test + run: go test -count=1 -v ./tests/integration -run 'TestAdvancedRepairPlan' diff --git a/internal/consistency/repair/executor.go b/internal/consistency/repair/executor.go index be42a75..25f0e63 100644 --- a/internal/consistency/repair/executor.go +++ b/internal/consistency/repair/executor.go @@ -283,11 +283,20 @@ func matchPKIn(matchers []planner.RepairPKMatcher, rowPk map[string]any, pkOrder return true } + equal := func(a, b any) bool { + if af, ok := asFloat(a); ok { + if bf, ok2 := asFloat(b); ok2 { + return af == bf + } + } + return reflect.DeepEqual(a, b) + } + for _, m := range matchers { if simple { val := rowPk[pkOrder[0]] for _, eq := range m.Equals { - if reflect.DeepEqual(eq, val) { + if equal(eq, val) { return true } } @@ -305,7 +314,7 @@ func matchPKIn(matchers []planner.RepairPKMatcher, rowPk map[string]any, pkOrder } all := true for i, col := range pkOrder { - if !reflect.DeepEqual(rowPk[col], tuple[i]) { + if !equal(rowPk[col], tuple[i]) { all = false break } diff --git a/tests/integration/advanced_repair_test.go b/tests/integration/advanced_repair_test.go new file mode 100644 index 0000000..0cdb482 --- /dev/null +++ b/tests/integration/advanced_repair_test.go @@ -0,0 +1,134 @@ +// /////////////////////////////////////////////////////////////////////////// +// +// # ACE - Active Consistency Engine +// +// Copyright (C) 2023 - 2025, pgEdge (https://www.pgedge.com/) +// +// This software is released under the PostgreSQL License: +// https://opensource.org/license/postgresql +// +// /////////////////////////////////////////////////////////////////////////// + +package integration + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +// These tests exercise advanced repair plans (selectors + actions) end-to-end against the +// dockerised cluster spun up by the integration suite. + +func TestAdvancedRepairPlan_MixedSelectorsAndActions(t *testing.T) { + ctx := context.Background() + qualifiedTableName := "public.customers" + + setupDivergence(t, ctx, qualifiedTableName, false) + diffFile := runTableDiff(t, qualifiedTableName, []string{serviceN1, serviceN2}) + + plan := ` +version: 1 +default_action: { type: keep_n1 } +tables: + public.customers: + rules: + - name: prefer_n2_for_modified + diff_type: [row_mismatch] + pk_in: + - equals: [1] + - equals: [2] + action: { type: keep_n2 } + - name: insert_missing_on_n2 + diff_type: [missing_on_n2] + action: + type: apply_from + from: n1 + mode: insert + - name: insert_missing_on_n1 + diff_type: [missing_on_n1] + action: + type: apply_from + from: n2 + mode: insert + - name: coalesce_email + columns_changed: [email] + action: + type: custom + helpers: + coalesce_priority: [n2, n1] +` + planPath := filepath.Join(t.TempDir(), "repair.yaml") + require.NoError(t, os.WriteFile(planPath, []byte(plan), 0o644)) + + task := newTestTableRepairTask("", qualifiedTableName, diffFile) + task.RepairPlanPath = planPath + + require.NoError(t, task.ValidateAndPrepare()) + require.NoError(t, task.Run(true)) + + assertNoTableDiff(t, qualifiedTableName) + + // Rows 1 and 2 should now reflect the n2 version (modified emails) on both nodes. + var email1 string + require.NoError(t, pgCluster.Node1Pool.QueryRow(ctx, "SELECT email FROM "+qualifiedTableName+" WHERE index = 1").Scan(&email1)) + require.Equal(t, "modified.email1@example.com", email1) + require.NoError(t, pgCluster.Node2Pool.QueryRow(ctx, "SELECT email FROM "+qualifiedTableName+" WHERE index = 1").Scan(&email1)) + require.Equal(t, "modified.email1@example.com", email1) +} + +func TestAdvancedRepairPlan_DeleteAndWhenPredicate(t *testing.T) { + ctx := context.Background() + qualifiedTableName := "public.customers" + + setupDivergence(t, ctx, qualifiedTableName, false) + diffFile := runTableDiff(t, qualifiedTableName, []string{serviceN1, serviceN2}) + + plan := ` +version: 1 +tables: + public.customers: + default_action: { type: keep_n1 } + rules: + - name: delete_extras_on_n2 + diff_type: [missing_on_n1] + action: { type: delete } + - name: insert_missing_on_n2 + diff_type: [missing_on_n2] + action: + type: apply_from + from: n1 + mode: insert + - name: prefer_modified_from_n2 + diff_type: [row_mismatch] + columns_changed: [email] + when: "NOT (n2.email = n1.email)" + action: { type: keep_n2 } +` + planPath := filepath.Join(t.TempDir(), "repair.yaml") + require.NoError(t, os.WriteFile(planPath, []byte(plan), 0o644)) + + task := newTestTableRepairTask("", qualifiedTableName, diffFile) + task.RepairPlanPath = planPath + + require.NoError(t, task.ValidateAndPrepare()) + require.NoError(t, task.Run(true)) + + // Extras on n2 should be deleted instead of copied, so counts should match the n1 baseline (7 rows). + countN1 := getTableCount(t, ctx, pgCluster.Node1Pool, qualifiedTableName) + countN2 := getTableCount(t, ctx, pgCluster.Node2Pool, qualifiedTableName) + require.Equal(t, countN1, countN2) + require.Equal(t, 7, countN1) + + assertNoTableDiff(t, qualifiedTableName) + + // Modified emails from n2 should be present on both sides due to the when predicate. + var email1 string + require.NoError(t, pgCluster.Node1Pool.QueryRow(ctx, "SELECT email FROM "+qualifiedTableName+" WHERE index = 1").Scan(&email1)) + require.Equal(t, "modified.email1@example.com", email1) + require.NoError(t, pgCluster.Node2Pool.QueryRow(ctx, "SELECT email FROM "+qualifiedTableName+" WHERE index = 1").Scan(&email1)) + require.Equal(t, "modified.email1@example.com", email1) +} From 3d5e2bacef993bb1592b97fd1bd4fd0b7e03d0c7 Mon Sep 17 00:00:00 2001 From: Tej Kashi Date: Tue, 16 Dec 2025 10:17:21 -0500 Subject: [PATCH 2/3] Update HTML reporter --- pkg/common/html_reporter.go | 736 ++++++++++++++++++++++++++++++++---- 1 file changed, 671 insertions(+), 65 deletions(-) diff --git a/pkg/common/html_reporter.go b/pkg/common/html_reporter.go index b56d4a9..466af60 100644 --- a/pkg/common/html_reporter.go +++ b/pkg/common/html_reporter.go @@ -37,6 +37,11 @@ func writeHTMLDiffReport(diffResult types.DiffOutput, jsonFilePath string) (stri return "", nil } + rawJSON, err := json.Marshal(diffResult) + if err != nil { + return "", fmt.Errorf("failed to marshal diff result for HTML embedding: %w", err) + } + htmlPath := strings.TrimSuffix(jsonFilePath, filepath.Ext(jsonFilePath)) + ".html" summary := diffResult.Summary @@ -57,10 +62,14 @@ func writeHTMLDiffReport(diffResult types.DiffOutput, jsonFilePath string) (stri NodeAClass string NodeBHTML template.HTML NodeBClass string + HasDiff bool } type row struct { - Cells []cell + PKey string + Cells []cell + RowType string // "value_diff", "missing_in_a", "missing_in_b" + HasDiffs bool } type missingGroup struct { @@ -79,8 +88,9 @@ func writeHTMLDiffReport(diffResult types.DiffOutput, jsonFilePath string) (stri } type reportData struct { - Summary summaryData - Pairs []pairSection + Summary summaryData + Pairs []pairSection + RawDiffJSON template.JS } summaryItems := []summaryItem{ @@ -199,25 +209,34 @@ func writeHTMLDiffReport(diffResult types.DiffOutput, jsonFilePath string) (stri } var cells []cell + hasDiffs := false for _, col := range columns { valA := stringifyCellValue(rowA[col]) valB := stringifyCellValue(rowB[col]) _, isPK := pkSet[col] htmlA, htmlB := highlightDifference(valA, valB) + hasDiff := valA != valB c := cell{ Column: col, IsKey: isPK, NodeAHTML: htmlA, NodeBHTML: htmlB, + HasDiff: hasDiff, } - if valA != valB { + if hasDiff { c.NodeAClass = "value-diff" c.NodeBClass = "value-diff" + hasDiffs = true } cells = append(cells, c) } - valueDiffs = append(valueDiffs, row{Cells: cells}) + valueDiffs = append(valueDiffs, row{ + PKey: key, + Cells: cells, + RowType: "value_diff", + HasDiffs: hasDiffs, + }) } missingGroups := make([]missingGroup, 0) @@ -235,9 +254,15 @@ func writeHTMLDiffReport(diffResult types.DiffOutput, jsonFilePath string) (stri NodeAHTML: plainHTML(valA), NodeBHTML: plainHTML("MISSING"), NodeBClass: "missing", + HasDiff: false, }) } - group.Rows = append(group.Rows, row{Cells: cells}) + group.Rows = append(group.Rows, row{ + PKey: key, + Cells: cells, + RowType: "missing_in_b", + HasDiffs: true, + }) } missingGroups = append(missingGroups, group) } @@ -256,9 +281,15 @@ func writeHTMLDiffReport(diffResult types.DiffOutput, jsonFilePath string) (stri NodeAHTML: plainHTML("MISSING"), NodeAClass: "missing", NodeBHTML: plainHTML(valB), + HasDiff: false, }) } - group.Rows = append(group.Rows, row{Cells: cells}) + group.Rows = append(group.Rows, row{ + PKey: key, + Cells: cells, + RowType: "missing_in_a", + HasDiffs: true, + }) } if len(missingGroups) > 0 { group.DividerBefore = true @@ -282,7 +313,8 @@ func writeHTMLDiffReport(diffResult types.DiffOutput, jsonFilePath string) (stri Items: filteredItems, Breakdown: buildDiffBreakdown(summary.DiffRowsCount), }, - Pairs: pairs, + Pairs: pairs, + RawDiffJSON: template.JS(rawJSON), } tmpl, err := template.New("tableDiffReport").Parse(htmlDiffTemplate) @@ -375,6 +407,7 @@ const htmlDiffTemplate = `

ACE Table Diff Report

+
+

Build an advanced repair plan

+

Create a starter repair file (YAML) from this diff. The plan will set keep_n1 for mismatches, apply_from n1 insert for rows missing on node2, and apply_from n2 insert for rows missing on node1. You can edit the file to add rules or change actions.

+ +
Generated plan uses per-row overrides. For large diffs, convert repeating patterns into rules manually.
+

Summary

@@ -662,50 +1000,115 @@ const htmlDiffTemplate = ` {{if .Pairs}} {{range .Pairs}} -
-
Differences between {{.NodeA}} and {{.NodeB}} ({{.DiffCount}} entries)
+
+ {{ $nodeA := .NodeA }}{{ $nodeB := .NodeB }} +
+
Differences between {{.NodeA}} and {{.NodeB}}
+ {{.DiffCount}} entries +
- - - - - - +
Column{{.NodeA}}{{.NodeB}}
+ + + + + + + {{if .ValueDiffs}} - + {{range $i, $row := .ValueDiffs}} - {{if $i}}{{end}} - {{range $row.Cells}} - - {{.Column}} - {{.NodeAHTML}} - {{.NodeBHTML}} + {{if $i}}{{end}} + + + - {{end}} {{end}} {{end}} {{if .Missing}} - + {{range $gIndex, $group := .Missing}} - {{if $group.DividerBefore}}{{end}} - + {{if $group.DividerBefore}}{{end}} + {{range $rIndex, $row := $group.Rows}} - {{if $rIndex}}{{end}} - {{range $row.Cells}} - - {{.Column}} - {{.NodeAHTML}} - {{.NodeBHTML}} + {{if $rIndex}}{{end}} + + + - {{end}} {{end}} {{end}} {{end}} {{if not .HasDiffs}} - + {{end}} +
ActionRow Data
Value Differences
Value Differences
+
+ Row Action + +
{{$row.PKey}}
+
+
+ + + + + + + + + + {{range $row.Cells}} + + + + + + {{end}} + +
Column{{ $nodeA }}{{ $nodeB }}
{{.Column}}{{.NodeAHTML}}{{.NodeBHTML}}
+
Missing Rows
Missing Rows
{{$group.Title}}
{{$group.Title}}
+
+ Row Action + +
{{$row.PKey}}
+
+
+ + + + + + + + + + {{range $row.Cells}} + + + + + + {{end}} + +
Column{{ $nodeA }}{{ $nodeB }}
{{.Column}}{{.NodeAHTML}}{{.NodeBHTML}}
+
No column level differences detected for this node pair.
No column level differences detected for this node pair.
@@ -715,6 +1118,209 @@ const htmlDiffTemplate = `
No row-level differences were recorded.
{{end}} + +
` From 540b1b10fb4e6a89fb37c48703b3e4bac698cc2b Mon Sep 17 00:00:00 2001 From: Tej Kashi Date: Wed, 17 Dec 2025 16:11:56 -0500 Subject: [PATCH 3/3] Refactor to separate html, css, and js files --- pkg/common/html_reporter.go | 940 +------------------------- pkg/common/templates/diff_report.css | 557 +++++++++++++++ pkg/common/templates/diff_report.html | 166 +++++ pkg/common/templates/diff_report.js | 200 ++++++ 4 files changed, 937 insertions(+), 926 deletions(-) create mode 100644 pkg/common/templates/diff_report.css create mode 100644 pkg/common/templates/diff_report.html create mode 100644 pkg/common/templates/diff_report.js diff --git a/pkg/common/html_reporter.go b/pkg/common/html_reporter.go index 466af60..a6ac114 100644 --- a/pkg/common/html_reporter.go +++ b/pkg/common/html_reporter.go @@ -13,6 +13,7 @@ package common import ( "bytes" + _ "embed" "encoding/json" "fmt" "html/template" @@ -26,6 +27,15 @@ import ( "github.com/pgedge/ace/pkg/types" ) +//go:embed templates/diff_report.html +var htmlDiffTemplate string + +//go:embed templates/diff_report.css +var htmlDiffCSS string + +//go:embed templates/diff_report.js +var htmlDiffJS string + // htmlPairCount captures the diff counts grouped by node pair for the report summary. type htmlPairCount struct { Name string @@ -91,6 +101,8 @@ func writeHTMLDiffReport(diffResult types.DiffOutput, jsonFilePath string) (stri Summary summaryData Pairs []pairSection RawDiffJSON template.JS + CSS template.CSS + JS template.JS } summaryItems := []summaryItem{ @@ -315,6 +327,8 @@ func writeHTMLDiffReport(diffResult types.DiffOutput, jsonFilePath string) (stri }, Pairs: pairs, RawDiffJSON: template.JS(rawJSON), + CSS: template.CSS(htmlDiffCSS), + JS: template.JS(htmlDiffJS), } tmpl, err := template.New("tableDiffReport").Parse(htmlDiffTemplate) @@ -399,932 +413,6 @@ func plainHTML(value string) template.HTML { return template.HTML(template.HTMLEscapeString(value)) } -const htmlDiffTemplate = ` - - - - ACE Table Diff Report - - - -
-

ACE Table Diff Report

-
-

Build an advanced repair plan

-

Create a starter repair file (YAML) from this diff. The plan will set keep_n1 for mismatches, apply_from n1 insert for rows missing on node2, and apply_from n2 insert for rows missing on node1. You can edit the file to add rules or change actions.

- -
Generated plan uses per-row overrides. For large diffs, convert repeating patterns into rules manually.
-
-
-

Summary

-
- {{range .Summary.Items}} -
-
{{.Label}}
-
{{.Value}}
-
- {{end}} -
- {{if .Summary.Breakdown}} -
-
Diffs by node pair
-
- {{range .Summary.Breakdown}} -
- {{.Name}} - {{.Count}} -
- {{end}} -
-
- {{end}} -
- - {{if .Pairs}} - {{range .Pairs}} -
- {{ $nodeA := .NodeA }}{{ $nodeB := .NodeB }} -
-
Differences between {{.NodeA}} and {{.NodeB}}
- {{.DiffCount}} entries -
-
- - - - - - - - - {{if .ValueDiffs}} - - {{range $i, $row := .ValueDiffs}} - {{if $i}}{{end}} - - - - - {{end}} - {{end}} - - {{if .Missing}} - - {{range $gIndex, $group := .Missing}} - {{if $group.DividerBefore}}{{end}} - - {{range $rIndex, $row := $group.Rows}} - {{if $rIndex}}{{end}} - - - - - {{end}} - {{end}} - {{end}} - - {{if not .HasDiffs}} - - {{end}} - -
ActionRow Data
Value Differences
-
- Row Action - -
{{$row.PKey}}
-
-
- - - - - - - - - - {{range $row.Cells}} - - - - - - {{end}} - -
Column{{ $nodeA }}{{ $nodeB }}
{{.Column}}{{.NodeAHTML}}{{.NodeBHTML}}
-
Missing Rows
{{$group.Title}}
-
- Row Action - -
{{$row.PKey}}
-
-
- - - - - - - - - - {{range $row.Cells}} - - - - - - {{end}} - -
Column{{ $nodeA }}{{ $nodeB }}
{{.Column}}{{.NodeAHTML}}{{.NodeBHTML}}
-
No column level differences detected for this node pair.
-
-
- {{end}} - {{else}} -
-
No row-level differences were recorded.
-
- {{end}} - - -
- -` - func totalDiffs(diffCounts map[string]int) int64 { var total int64 for _, count := range diffCounts { diff --git a/pkg/common/templates/diff_report.css b/pkg/common/templates/diff_report.css new file mode 100644 index 0000000..3818ed2 --- /dev/null +++ b/pkg/common/templates/diff_report.css @@ -0,0 +1,557 @@ +:root { + --primary-color: #1f6feb; + --primary-strong: #1557b0; + --success-color: #1a7f37; + --warning-color: #bf8700; + --danger-color: #d73a49; + --text-primary: #1b1f23; + --text-secondary: #57606a; + --bg-primary: #ffffff; + --bg-secondary: #f6f8fa; + --border-color: #d0d7de; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; + line-height: 1.5; + color: var(--text-primary); + background: linear-gradient(180deg, #f4f7fb 0%, #eef1f7 100%); +} + +.container { + max-width: 1400px; + margin: 2rem auto; + padding: 0 1rem 3rem; +} + +h1, h2 { + color: var(--text-primary); + font-weight: 600; + margin-bottom: 1rem; +} + +h1 { + font-size: 2rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--border-color); +} + +.summary-box { + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 1.5rem; + margin: 1.5rem 0; + box-shadow: 0 6px 16px rgba(27, 31, 35, 0.07); +} + +.plan-builder { + margin: 1.5rem 0; + padding: 1rem 1.25rem; + border: 1px solid var(--border-color); + border-radius: 8px; + background: #eef6ff; +} + +.plan-builder h3 { + margin-bottom: 0.5rem; +} + +.plan-builder button { + background: var(--primary-color); + color: #fff; + border: none; + padding: 0.6rem 1rem; + border-radius: 6px; + cursor: pointer; + font-weight: 600; +} + +.plan-builder button:hover { + background: #1557b0; +} + +.plan-builder .note { + margin-top: 0.35rem; + color: var(--text-secondary); + font-size: 0.9rem; +} + +.summary-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 1.5rem; +} + +.summary-item { + padding: 1rem; + background: var(--bg-secondary); + border-radius: 6px; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.summary-item:hover { + transform: translateY(-2px); + box-shadow: 0 10px 18px rgba(27, 31, 35, 0.12); +} + +.summary-label { + font-size: 0.85rem; + font-weight: 500; + color: var(--text-secondary); + margin-bottom: 0.35rem; + letter-spacing: 0.02em; +} + +.summary-value { + font-size: 1.1rem; + font-weight: 600; + color: var(--text-primary); + word-break: break-word; +} + +.summary-breakdown { + margin-top: 1.5rem; + padding: 1rem; + border: 1px dashed var(--border-color); + border-radius: 6px; + background: rgba(31, 111, 235, 0.05); + color: var(--text-secondary); +} + +.summary-breakdown-title { + font-weight: 600; + font-size: 0.95rem; + color: var(--text-primary); + margin-bottom: 0.5rem; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.summary-breakdown-items { + display: flex; + flex-wrap: wrap; + gap: 1rem; +} + +.breakdown-item { + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 4px; + padding: 0.35rem 0.75rem; + font-size: 0.9rem; + display: flex; + align-items: baseline; + gap: 0.5rem; + box-shadow: 0 3px 6px rgba(27, 31, 35, 0.06); +} + +.breakdown-name { + font-weight: 600; + color: var(--primary-color); +} + +.breakdown-count { + font-variant-numeric: tabular-nums; + color: var(--text-secondary); +} + +.diff-section { + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 8px; + margin: 1.5rem 0; + box-shadow: 0 6px 16px rgba(27, 31, 35, 0.07); + overflow: hidden; +} + +.diff-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + background: linear-gradient(120deg, #1f6feb 0%, #1b5dcc 100%); + color: white; + padding: 1rem 1.5rem; + font-size: 1.05rem; + font-weight: 650; +} + +.pair-title { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; +} + +.diff-count-pill { + background: rgba(255, 255, 255, 0.14); + border: 1px solid rgba(255, 255, 255, 0.3); + padding: 0.25rem 0.65rem; + border-radius: 999px; + font-variant-numeric: tabular-nums; + font-size: 0.9rem; + color: #f6f8fa; + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.03); +} + +.table-wrapper { + width: 100%; + overflow-x: auto; + background: var(--bg-secondary); + border-top: 1px solid var(--border-color); +} + +.diff-table { + width: 100%; + border-collapse: collapse; + font-size: 0.92rem; +} + +.diff-table .action-cell, +.diff-table .action-header { + width: 220px; + min-width: 220px; + max-width: 280px; +} + +.diff-table thead th { + background: var(--bg-secondary); + padding: 0.75rem 1rem; + text-align: left; + font-weight: 650; + color: var(--text-secondary); + border-bottom: 2px solid var(--border-color); + position: sticky; + top: 0; + z-index: 5; +} + +.inner-columns-table { + width: 100%; + border-collapse: collapse; + table-layout: auto; +} + +.diff-row { + border-bottom: 1px solid var(--border-color); +} + +.diff-row:hover { + background-color: rgba(31, 111, 235, 0.02); +} + +.diff-row .action-cell { + background: #eef3ff; + border-right: 2px solid var(--border-color); + vertical-align: top; + padding: 1rem; +} + +.diff-row .columns-cell { + padding: 0; + vertical-align: top; +} + +.action-wrapper { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.action-label { + display: block; + font-size: 0.75rem; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--text-secondary); + font-weight: 600; +} + +.plan-action { + width: 100%; + padding: 0.5rem; + border-radius: 6px; + border: 1px solid var(--border-color); + background: var(--bg-primary); + font-size: 0.85rem; + cursor: pointer; +} + +.plan-action:hover { + border-color: var(--primary-color); +} + +.row-key { + padding: 0.4rem 0.6rem; + background: rgba(31, 111, 235, 0.08); + border-radius: 4px; + color: var(--text-primary); + font-size: 0.8rem; + font-family: 'Courier New', monospace; + word-break: break-all; + font-weight: 500; +} + +.inner-columns-table thead { + position: sticky; + top: 0; + z-index: 2; +} + +.inner-columns-table th { + background: #e8ecf3; + padding: 0.6rem 1rem; + text-align: left; + font-weight: 600; + font-size: 0.85rem; + color: var(--text-primary); + border-bottom: 2px solid var(--border-color); + border-right: 1px solid #d8dde5; +} + +.inner-columns-table th:last-child { + border-right: none; +} + +.inner-columns-table .inner-col-header { + width: 150px; + min-width: 150px; + max-width: 200px; + color: var(--text-secondary); +} + +.inner-columns-table .inner-node-header { + min-width: 250px; + text-align: left; +} + +.inner-columns-table tbody tr { + border-bottom: 1px solid #e8ecf0; +} + +.inner-columns-table tbody tr:last-child { + border-bottom: none; +} + +.inner-columns-table tbody tr.has-diff { + background: rgba(255, 247, 230, 0.5); +} + +.inner-columns-table td { + padding: 0.75rem 1rem; + vertical-align: top; + border-right: 1px solid #e8ecf0; +} + +.inner-columns-table td:last-child { + border-right: none; +} + +.col-name { + font-weight: 600; + color: var(--text-secondary); + background: #f8f9fb; + white-space: nowrap; + width: 150px; + min-width: 150px; + max-width: 200px; +} + +.col-name.key-column { + background: #e3f2fd; + color: var(--primary-color); + font-weight: 700; +} + +.col-name.key-column::after { + content: " \1F511"; + font-size: 0.75em; + opacity: 0.7; +} + +.value-cell { + background: var(--bg-primary); + white-space: pre-wrap; + word-break: break-word; + min-width: 200px; +} + +.value-diff { + background-color: rgba(255, 235, 238, 0.6); + border-left: 3px solid var(--danger-color); + position: relative; +} + +.value-diff::before { + content: ""; + position: absolute; + left: -3px; + top: 0; + bottom: 0; + width: 3px; + background: var(--danger-color); +} + +.diff-chunk { + background-color: rgba(215, 58, 73, 0.35); + color: var(--danger-color); + border-radius: 3px; + padding: 0 2px; +} + +.key-column { + font-weight: 600; + color: var(--primary-color); +} + +.missing { + background-color: rgba(191, 135, 0, 0.12); + color: var(--warning-color); + font-style: italic; + font-weight: 600; +} + +.row-separator td { + background: rgba(31, 111, 235, 0.1); + color: var(--primary-color); + font-weight: 650; + text-align: center; + padding: 0.85rem; + letter-spacing: 0.04em; + text-transform: uppercase; + border-bottom: 2px solid var(--primary-color); + font-size: 0.9rem; +} + +.row-subseparator td { + background: rgba(31, 111, 235, 0.05); + color: var(--text-primary); + font-weight: 600; + text-align: center; + padding: 0.7rem; + letter-spacing: 0.02em; + border-bottom: 1px solid var(--border-color); + font-size: 0.88rem; +} + +.row-divider td { + background: var(--bg-secondary); + border-bottom: none; + height: 0.75rem; + padding: 0; +} + +.empty-message { + padding: 1.5rem; + color: var(--text-secondary); + text-align: center; + background: var(--bg-secondary); + border: 1px dashed var(--border-color); +} + +.bulk-bar { + display: flex; + align-items: center; + gap: 0.65rem; + padding: 0.9rem 1.5rem; + background: #f7f9ff; + border-bottom: 1px solid var(--border-color); +} + +.bulk-label { + font-weight: 600; + color: var(--text-primary); + white-space: nowrap; +} + +.bulk-select { + min-width: 200px; + padding: 0.45rem 0.5rem; + border-radius: 6px; + border: 1px solid var(--border-color); + background: var(--bg-primary); +} + +.bulk-apply { + padding: 0.45rem 0.85rem; + border: 1px solid var(--primary-strong); + background: var(--primary-color); + color: #fff; + border-radius: 6px; + font-weight: 600; + cursor: pointer; + box-shadow: 0 6px 16px rgba(27, 31, 35, 0.12); +} + +.bulk-apply:hover { + background: var(--primary-strong); +} + +@media (max-width: 768px) { + .container { + padding: 0 0.5rem 2rem; + } + + h1 { + font-size: 1.5rem; + } + + .diff-table .action-cell, + .diff-table .action-header { + width: 180px; + min-width: 180px; + } + + .inner-columns-table th, + .inner-columns-table td { + padding: 0.5rem 0.75rem; + font-size: 0.85rem; + } + + .inner-columns-table .inner-col-header, + .col-name { + width: 100px; + min-width: 100px; + max-width: 120px; + } + + .value-cell { + min-width: 150px; + } + + .action-cell { + padding: 0.75rem; + } + + .plan-action { + font-size: 0.8rem; + padding: 0.4rem; + } + + .row-key { + font-size: 0.75rem; + padding: 0.3rem 0.5rem; + } + + .summary-breakdown-items { + flex-direction: column; + gap: 0.75rem; + } + + .bulk-bar { + flex-direction: column; + align-items: stretch; + gap: 0.5rem; + } + + .bulk-select { + min-width: 100%; + } +} diff --git a/pkg/common/templates/diff_report.html b/pkg/common/templates/diff_report.html new file mode 100644 index 0000000..b8d9779 --- /dev/null +++ b/pkg/common/templates/diff_report.html @@ -0,0 +1,166 @@ + + + + + ACE Table Diff Report + + + +
+

ACE Table Diff Report

+
+

Build an advanced repair plan

+

Create a starter repair file (YAML) from this diff. The plan will set keep_n1 for mismatches, apply_from n1 insert for rows missing on node2, and apply_from n2 insert for rows missing on node1. You can edit the file to add rules or change actions.

+ +
Generated plan uses per-row overrides. For large diffs, convert repeating patterns into rules manually.
+
+
+

Summary

+
+ {{range .Summary.Items}} +
+
{{.Label}}
+
{{.Value}}
+
+ {{end}} +
+ {{if .Summary.Breakdown}} +
+
Diffs by node pair
+
+ {{range .Summary.Breakdown}} +
+ {{.Name}} + {{.Count}} +
+ {{end}} +
+
+ {{end}} +
+ + {{if .Pairs}} + {{range .Pairs}} +
+ {{ $nodeA := .NodeA }}{{ $nodeB := .NodeB }} +
+
Differences between {{.NodeA}} and {{.NodeB}}
+ {{.DiffCount}} entries +
+
+ + + + + + + + + {{if .ValueDiffs}} + + {{range $i, $row := .ValueDiffs}} + {{if $i}}{{end}} + + + + + {{end}} + {{end}} + + {{if .Missing}} + + {{range $gIndex, $group := .Missing}} + {{if $group.DividerBefore}}{{end}} + + {{range $rIndex, $row := $group.Rows}} + {{if $rIndex}}{{end}} + + + + + {{end}} + {{end}} + {{end}} + + {{if not .HasDiffs}} + + {{end}} + +
ActionRow Data
Value Differences
+
+ Row Action + +
{{$row.PKey}}
+
+
+ + + + + + + + + + {{range $row.Cells}} + + + + + + {{end}} + +
Column{{ $nodeA }}{{ $nodeB }}
{{.Column}}{{.NodeAHTML}}{{.NodeBHTML}}
+
Missing Rows
{{$group.Title}}
+
+ Row Action + +
{{$row.PKey}}
+
+
+ + + + + + + + + + {{range $row.Cells}} + + + + + + {{end}} + +
Column{{ $nodeA }}{{ $nodeB }}
{{.Column}}{{.NodeAHTML}}{{.NodeBHTML}}
+
No column level differences detected for this node pair.
+
+
+ {{end}} + {{else}} +
+
No row-level differences were recorded.
+
+ {{end}} + + +
+ + diff --git a/pkg/common/templates/diff_report.js b/pkg/common/templates/diff_report.js new file mode 100644 index 0000000..9c57081 --- /dev/null +++ b/pkg/common/templates/diff_report.js @@ -0,0 +1,200 @@ +(function () { + const diffDataEl = document.getElementById('diff-data'); + const downloadBtn = document.getElementById('download-plan'); + const sections = document.querySelectorAll('.diff-section'); + if (!diffDataEl || !downloadBtn) return; + + const diff = JSON.parse(diffDataEl.textContent); + + sections.forEach(section => { + const controls = section.querySelectorAll('.plan-action'); + if (controls.length === 0) return; + const nodeALabel = section.dataset.nodea || 'node A'; + const nodeBLabel = section.dataset.nodeb || 'node B'; + const bulkBar = document.createElement('div'); + bulkBar.className = 'bulk-bar'; + const label = document.createElement('span'); + label.className = 'bulk-label'; + label.textContent = 'Apply to all for ' + nodeALabel + ' vs ' + nodeBLabel + ':'; + const select = document.createElement('select'); + select.className = 'bulk-select'; + select.setAttribute('aria-label', 'Bulk action for ' + nodeALabel + ' and ' + nodeBLabel); + [ + { value: '', label: 'Choose action' }, + { value: 'keep_n1', label: 'Keep ' + nodeALabel }, + { value: 'keep_n2', label: 'Keep ' + nodeBLabel }, + { value: 'apply_from_n1_insert', label: 'Insert from ' + nodeALabel }, + { value: 'apply_from_n2_insert', label: 'Insert from ' + nodeBLabel }, + { value: 'delete', label: 'Delete' }, + { value: 'skip', label: 'Skip' } + ].forEach(opt => { + const option = document.createElement('option'); + option.value = opt.value; + option.textContent = opt.label; + select.appendChild(option); + }); + const applyBtn = document.createElement('button'); + applyBtn.type = 'button'; + applyBtn.className = 'bulk-apply'; + applyBtn.textContent = 'Apply'; + applyBtn.addEventListener('click', () => { + const val = select.value; + if (!val) return; + controls.forEach(sel => sel.value = val); + }); + bulkBar.appendChild(label); + bulkBar.appendChild(select); + bulkBar.appendChild(applyBtn); + section.insertBefore(bulkBar, section.children[1]); + }); + + downloadBtn.addEventListener('click', () => { + try { + const yaml = buildPlanYaml(diff); + const blob = new Blob([yaml], { type: 'text/yaml' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + const tableKey = diff.summary.schema + '.' + diff.summary.table; + a.download = tableKey.replace('.', '_') + '_repair_plan.yaml'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } catch (e) { + alert('Failed to build repair plan: ' + e); + } + }); + + function buildPlanYaml(diff) { + const pkCols = diff.summary.primary_key || []; + if (!pkCols.length) throw new Error('No primary key info available'); + const tableKey = diff.summary.schema + '.' + diff.summary.table; + + const overrides = []; + const seen = new Set(); + + const nodeDiffs = diff.NodeDiffs || diff.diffs || {}; + for (const pairKey of Object.keys(nodeDiffs)) { + const nodeDiff = nodeDiffs[pairKey]; + if (!nodeDiff || !nodeDiff.Rows && !nodeDiff.rows) continue; + const rowsMap = nodeDiff.Rows || nodeDiff.rows; + const nodeNames = Object.keys(rowsMap || {}).sort(); + if (nodeNames.length < 2) continue; + const n1 = nodeNames[0]; + const n2 = nodeNames[1]; + const rows1 = rowsMap[n1] || []; + const rows2 = rowsMap[n2] || []; + + const map1 = rowsToMap(rows1, pkCols); + const map2 = rowsToMap(rows2, pkCols); + + const keys = new Set([...map1.keys(), ...map2.keys()]); + for (const key of keys) { + if (seen.has(key)) continue; + seen.add(key); + const row1 = map1.get(key); + const row2 = map2.get(key); + const pkMap = keyToPkMap(key, pkCols); + + let action = selectionForKey(key) || { type: 'keep_n1' }; + let name = 'pk_' + key.replace(/\|/g, '_'); + if (row1 && !row2) { + action = selectionForKey(key) || { type: 'apply_from', from: n1, mode: 'insert' }; + } else if (row2 && !row1) { + action = selectionForKey(key) || { type: 'apply_from', from: n2, mode: 'insert' }; + } + + overrides.push({ name, pk: pkMap, action }); + } + } + + const lines = []; + lines.push('version: 1'); + lines.push('tables:'); + lines.push(' ' + tableKey + ':'); + lines.push(' default_action:'); + lines.push(' type: keep_n1'); + if (overrides.length) { + lines.push(' row_overrides:'); + for (const ov of overrides) { + lines.push(' - name: ' + quote(ov.name)); + lines.push(' pk:'); + for (const col of pkCols) { + const v = ov.pk[col]; + lines.push(' ' + col + ': ' + scalar(v)); + } + lines.push(' action:'); + lines.push(' type: ' + ov.action.type); + if (ov.action.from) lines.push(' from: ' + ov.action.from); + if (ov.action.mode) lines.push(' mode: ' + ov.action.mode); + } + } + return lines.join('\n') + '\n'; + } + + function selectionForKey(key) { + const sel = document.querySelector('.plan-action[data-pk="' + key + '"]'); + if (!sel) return null; + const val = sel.value; + if (!val) return null; + switch (val) { + case 'keep_n1': + return { type: 'keep_n1' }; + case 'keep_n2': + return { type: 'keep_n2' }; + case 'apply_from_n1_insert': + return { type: 'apply_from', from: sel.dataset.nodea, mode: 'insert' }; + case 'apply_from_n2_insert': + return { type: 'apply_from', from: sel.dataset.nodeb, mode: 'insert' }; + case 'delete': + return { type: 'delete' }; + case 'skip': + return { type: 'skip' }; + default: + return null; + } + } + + function rowsToMap(rows, pkCols) { + const m = new Map(); + for (const r of rows) { + const keyParts = pkCols.map(c => stringify(r[c])); + const k = keyParts.join('|'); + m.set(k, r); + } + return m; + } + + function keyToPkMap(key, pkCols) { + const parts = key.split('|'); + const pk = {}; + pkCols.forEach((c, i) => pk[c] = parseMaybeNumber(parts[i])); + return pk; + } + + function stringify(v) { + if (v === null || v === undefined) return ''; + return '' + v; + } + + function parseMaybeNumber(v) { + if (v === undefined || v === null) return v; + const n = Number(v); + if (!Number.isNaN(n) && v !== '') return n; + return v; + } + + function quote(s) { + if (/^[A-Za-z0-9._-]+$/.test(s)) return s; + return JSON.stringify(s); + } + + function scalar(v) { + if (v === null || v === undefined) return 'null'; + if (typeof v === 'number' || typeof v === 'boolean') return String(v); + const str = String(v); + if (/^[A-Za-z0-9._-]+$/.test(str)) return str; + return JSON.stringify(str); + } +})();