// ///////////////////////////////////////////////////////////////////////////
//
// # ACE - Active Consistency Engine
//
// Copyright (C) 2023 - 2026, pgEdge (https://www.pgedge.com/)
//
// This software is released under the PostgreSQL License:
// https://opensource.org/license/postgresql
//
// ///////////////////////////////////////////////////////////////////////////
package common
import (
"bufio"
"context"
_ "embed"
"encoding/json"
"errors"
"fmt"
"html/template"
"io"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/pgedge/ace/pkg/logger"
"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
// DefaultMaxHTMLRows is how many rows the HTML report shows for each node pair
// when neither the command line nor the config file sets max_html_rows. A row
// here is one primary key, whether it differs in value or is missing on one
// node. One row takes several kilobytes of markup, so without a limit a large
// diff gives a file of gigabytes that no browser can open.
const DefaultMaxHTMLRows int64 = 10000
// DefaultMaxHTMLValueLength is the number of characters of a value that the
// HTML report shows when neither the command line nor the config file sets
// max_html_value_length. A longer value is cut at the right side.
const DefaultMaxHTMLValueLength int64 = 1024
// htmlWriteBufferSize is the size of the buffer in front of the report file.
// It is a variable only so that tests can make every write reach the writer.
var htmlWriteBufferSize = 256 * 1024
// htmlPairCount captures the diff counts grouped by node pair for the report summary.
type htmlPairCount struct {
Name string
Count string
}
type htmlSummaryItem struct {
Label string
Value string
}
type htmlSummaryData struct {
Items []htmlSummaryItem
Breakdown []htmlPairCount
}
// htmlTruncation is shown at the top of a report that does not show every
// row of the diff.
type htmlTruncation struct {
Shown string
Total string
Limit string
DiffFile string
}
type htmlReportHead struct {
CSS template.CSS
Truncation *htmlTruncation
Summary htmlSummaryData
}
type htmlReportTail struct {
JS template.JS
}
// htmlPairHead is the data for the "pair_head" and "pair_tail" blocks.
// Hidden is empty when the section shows every row of the pair. NotRendered
// is empty unless table-diff counted rows that the report leaves out because
// they show no difference.
type htmlPairHead struct {
NodeA string
NodeB string
DiffCount string
Shown string
Total string
Hidden string
HiddenBreakdown string
NotRendered string
DiffFile string
HasDiffs bool
}
type htmlCell struct {
Column string
IsKey bool
NodeAHTML template.HTML
NodeAClass string
NodeBHTML template.HTML
NodeBClass string
HasDiff bool
}
// htmlRow is the data for one "value_row" or "missing_row" block.
type htmlRow struct {
NodeA string
NodeB string
First bool
PKey string
RowType string // "value_diff", "missing_in_a", "missing_in_b"
Cells []htmlCell
NodeAJSON string
}
// htmlReportInfo goes into the embedded diff data, so that the page script
// knows when the repair plan it builds covers only part of the diff. The
// list of rows follows it in the page.
type htmlReportInfo struct {
Truncated bool `json:"truncated"`
MaxRows int64 `json:"max_html_rows"`
DiffFile string `json:"diff_file"`
IntegerPK bool `json:"integer_pk"`
Pairs []htmlPairInfo `json:"pairs"`
}
// htmlPlanRow is what the page script needs to write a repair-plan rule for
// one shown row. Key is the display key, as in data-pk. Type uses the names
// of the repair executor. PK holds each primary key value as the JSON text
// that the diff file has for it. The script copies that text into the YAML
// as it is and never turns it into a JavaScript number, so a bigint keeps
// every digit and a text key such as "007" stays text.
type htmlPlanRow struct {
Pair string `json:"pair"`
NodeA string `json:"node_a"`
NodeB string `json:"node_b"`
Key string `json:"key"`
Type string `json:"type"`
PK []string `json:"pk"`
}
type htmlPairInfo struct {
Pair string `json:"pair"`
Shown int64 `json:"shown"`
Total int64 `json:"total"`
}
// The kinds of a report row, as the page shows them.
const (
htmlKindValue = iota // a value difference
htmlKindMissingB // the row exists on node A only
htmlKindMissingA // the row exists on node B only
htmlKinds
)
// htmlEntry is one shown row of the report: its markup data, with every
// long value already cut, and its primary key as JSON text.
type htmlEntry struct {
kind int
row htmlRow
pk []string
display string
id string
}
// htmlPairPlan holds the shown rows of one node pair, in report order: the
// blocks of the diff in block order, and the rows of a block sorted by their
// key. Only these rows are in memory, with their values cut to maxLen; the
// other rows of the pair are only counted.
type htmlPairPlan struct {
pairKey string
nodeA string
nodeB string
columns []string
pkSet map[string]struct{}
maxLen int64
entries []htmlEntry
// all and shown count the rows of each kind in the diff and in the
// report. notRendered counts the value differences of the read blocks
// that show no difference after stringifyCellValue.
all [htmlKinds]int64
shown [htmlKinds]int64
notRendered int64
}
// total is the number of rows that the report would show without a limit,
// as far as the read blocks tell.
func (p *htmlPairPlan) total() int64 {
var n int64
for _, c := range p.all {
n += c
}
return n - p.notRendered
}
func (p *htmlPairPlan) shownCount() int64 {
var n int64
for _, c := range p.shown {
n += c
}
return n
}
// hidden returns the number of rows of one kind that the report does not
// show.
func (p *htmlPairPlan) hidden(kind int) int64 {
h := p.all[kind] - p.shown[kind]
if kind == htmlKindValue {
h -= p.notRendered
}
return h
}
// hiddenBreakdown describes the rows that the report does not show, for
// example "400,000 value differences, 50,000 missing in n3".
func (p *htmlPairPlan) hiddenBreakdown() string {
var parts []string
add := func(one, many string, hidden int64) {
if hidden <= 0 {
return
}
label := many
if hidden == 1 {
label = one
}
parts = append(parts, formatInt64WithCommas(hidden)+" "+label)
}
add("value difference", "value differences", p.hidden(htmlKindValue))
add("missing in "+p.nodeB, "missing in "+p.nodeB, p.hidden(htmlKindMissingB))
add("missing in "+p.nodeA, "missing in "+p.nodeA, p.hidden(htmlKindMissingA))
return strings.Join(parts, ", ")
}
// planType is the repair executor's name for the kind of difference of a row
// in this pair: row_mismatch, missing_on_n2 or missing_on_n1, where n1 and n2
// are node A and node B of the pair.
const (
planTypeMismatch = "row_mismatch"
planTypeMissingN2 = "missing_on_n2"
planTypeMissingN1 = "missing_on_n1"
)
// pkLiterals returns each primary key value of the row as JSON text, the
// same text the diff file has for it.
func pkLiterals(row types.OrderedMap, primaryKey []string) []string {
m := OrderedMapToMap(row)
out := make([]string, len(primaryKey))
for i, col := range primaryKey {
b, err := json.Marshal(m[col])
if err != nil {
b = []byte("null")
}
out[i] = string(b)
}
return out
}
// errStopBlock ends the read of a block file early.
var errStopBlock = errors.New("stop reading the block")
// buildHTMLPairPlan reads the blocks of one pair in block order until maxRows
// rows are shown. The last block read can be cut: it gives its first rows in
// the order of its file. A value difference that shows no difference after
// stringifyCellValue (for example 1 against "1", or a difference only in
// _spock_metadata_) is left out and counted, and does not take a place of
// the limit. A block file is read only until the limit is reached.
func buildHTMLPairPlan(ctx context.Context, r *StoredDiff, pair DiffResultPair, columns, primaryKey []string, maxRows, maxLen int64) (*htmlPairPlan, error) {
nodeA, nodeB := pair.Node1, pair.Node2
if names := strings.Split(pair.Pair, "/"); len(names) == 2 && names[0] == pair.Node2 && names[1] == pair.Node1 {
nodeA, nodeB = pair.Node2, pair.Node1
}
swap := nodeA != pair.Node1
p := &htmlPairPlan{
pairKey: pair.Pair,
nodeA: nodeA,
nodeB: nodeB,
columns: columns,
pkSet: make(map[string]struct{}, len(primaryKey)),
maxLen: maxLen,
}
for _, col := range primaryKey {
p.pkSet[col] = struct{}{}
}
p.all[htmlKindValue] = pair.Changed
p.all[htmlKindMissingB] = pair.Node1Only
p.all[htmlKindMissingA] = pair.Node2Only
if swap {
p.all[htmlKindMissingB], p.all[htmlKindMissingA] = pair.Node2Only, pair.Node1Only
}
files, err := r.BlockFiles(pair)
if err != nil {
return nil, err
}
for _, file := range files {
if err := ctx.Err(); err != nil {
return nil, err
}
taken := p.shownCount()
if taken >= maxRows {
break
}
b, err := p.readBlock(ctx, file, maxRows-taken, swap, primaryKey)
if err != nil {
return nil, err
}
p.add(b)
}
return p, nil
}
// htmlBlock is what one read of a block file gives: the rows to show, sorted
// by key, and the number of the entries that show no difference.
type htmlBlock struct {
entries []htmlEntry
shown [htmlKinds]int64
notRendered int64
}
// add appends the rows of a block to the plan and counts them.
func (p *htmlPairPlan) add(b htmlBlock) {
p.entries = append(p.entries, b.entries...)
for k, n := range b.shown {
p.shown[k] += n
}
p.notRendered += b.notRendered
}
// readBlock reads one block file until it has limit rows to show, and
// returns these rows, sorted by key. It does not change the plan.
func (p *htmlPairPlan) readBlock(ctx context.Context, file string, limit int64, swap bool, primaryKey []string) (htmlBlock, error) {
var b htmlBlock
base := int(p.shownCount())
err := ReadDiffBlock(file, func(e *DiffEntryLine) error {
if err := ctx.Err(); err != nil {
return err
}
if int64(len(b.entries)) >= limit {
return errStopBlock
}
var ent htmlEntry
var rows [2]types.OrderedMap
for i, raw := range []json.RawMessage{e.Row1, e.Row2} {
if len(raw) == 0 {
continue
}
if err := rows[i].UnmarshalJSON(raw); err != nil {
return fmt.Errorf("%s: %w", file, err)
}
}
rowA, rowB := rows[0], rows[1]
if swap {
rowA, rowB = rowB, rowA
}
switch {
case e.Kind == DiffKindChanged:
ent.kind = htmlKindValue
if !rowsDiffer(rowA, rowB, p.columns) {
b.notRendered++
return nil
}
case rowB == nil:
ent.kind = htmlKindMissingB
default:
ent.kind = htmlKindMissingA
}
row := rowA
if row == nil {
row = rowB
}
idx := base + len(b.entries)
ent.id = buildRowKey(row, primaryKey, idx)
ent.display = buildRowDisplayKey(row, primaryKey, idx)
ent.pk = pkLiterals(row, primaryKey)
if ent.kind == htmlKindValue {
ent.row = p.valueRow(rowA, rowB, ent.display)
} else {
ent.row = p.missingRow(ent.kind, row, ent.display)
}
b.shown[ent.kind]++
b.entries = append(b.entries, ent)
// Stop here: the next line of the file is not read.
if int64(len(b.entries)) >= limit {
return errStopBlock
}
return nil
})
if err != nil && !errors.Is(err, errStopBlock) {
return htmlBlock{}, err
}
// The order is only for display. It does not depend on the order of the
// rows in the file, so the same diff gives the same report.
sort.Slice(b.entries, func(i, j int) bool {
return b.entries[i].id < b.entries[j].id
})
return b, nil
}
// cutValue cuts s at the right side to max characters. It returns the text
// to show and the length of s in characters if s was cut, or 0.
func cutValue(s string, max int64) (string, int) {
if max <= 0 || int64(len(s)) <= max {
return s, 0
}
n := utf8.RuneCountInString(s)
if int64(n) <= max {
return s, 0
}
i := 0
for k := int64(0); k < max; k++ {
_, size := utf8.DecodeRuneInString(s[i:])
i += size
}
return s[:i], n
}
// cutMark is the markup after a value that the report shows cut. hidden
// says that the two values differ only in the part that is not shown. The
// markup holds only a number and constant text, so it needs no escaping.
func cutMark(full int, hidden bool) template.HTML {
note := ""
if hidden {
note = ", differs after the shown part"
}
return template.HTML(`… (` + formatInt64WithCommas(int64(full)) + ` characters` + note + `)`) // nosemgrep
}
// rowJSON returns the row as JSON for the custom row editor, or an empty
// string when a value of the row is cut on the page: an editor filled with
// cut values would write cut data in a repair.
func (p *htmlPairPlan) rowJSON(row types.OrderedMap, cut bool) string {
if cut {
return ""
}
return buildRowJSONPretty(row, p.columns)
}
func (p *htmlPairPlan) valueRow(a, b types.OrderedMap, display string) htmlRow {
rowA := OrderedMapToMap(a)
rowB := OrderedMapToMap(b)
cut := false
cells := make([]htmlCell, 0, len(p.columns))
for _, col := range p.columns {
valA := stringifyCellValue(rowA[col])
valB := stringifyCellValue(rowB[col])
_, isPK := p.pkSet[col]
shownA, fullA := valA, 0
shownB, fullB := valB, 0
if !isPK {
shownA, fullA = cutValue(valA, p.maxLen)
shownB, fullB = cutValue(valB, p.maxLen)
}
hidden := valA != valB && shownA == shownB
htmlA, htmlB := highlightDifference(shownA, shownB)
if fullA > 0 {
htmlA += cutMark(fullA, hidden)
cut = true
}
if fullB > 0 {
htmlB += cutMark(fullB, hidden)
cut = true
}
c := htmlCell{
Column: col,
IsKey: isPK,
NodeAHTML: htmlA,
NodeBHTML: htmlB,
HasDiff: valA != valB,
}
if c.HasDiff {
c.NodeAClass = "value-diff"
c.NodeBClass = "value-diff"
}
cells = append(cells, c)
}
return htmlRow{
NodeA: p.nodeA,
NodeB: p.nodeB,
PKey: display,
RowType: "value_diff",
Cells: cells,
NodeAJSON: p.rowJSON(a, cut),
}
}
// missingRow builds a row that exists on one node only: on node A for
// htmlKindMissingB, on node B for htmlKindMissingA.
func (p *htmlPairPlan) missingRow(kind int, present types.OrderedMap, display string) htmlRow {
missingInB := kind == htmlKindMissingB
row := htmlRow{
NodeA: p.nodeA,
NodeB: p.nodeB,
PKey: display,
}
values := OrderedMapToMap(present)
if missingInB {
row.RowType = "missing_in_b"
} else {
row.RowType = "missing_in_a"
}
cut := false
row.Cells = make([]htmlCell, 0, len(p.columns))
for _, col := range p.columns {
_, isPK := p.pkSet[col]
text, full := stringifyCellValue(values[col]), 0
if !isPK {
text, full = cutValue(text, p.maxLen)
}
val := plainHTML(text)
if full > 0 {
val += cutMark(full, false)
cut = true
}
c := htmlCell{Column: col, IsKey: isPK}
if missingInB {
c.NodeAHTML = val
c.NodeBHTML = plainHTML("MISSING")
c.NodeBClass = "missing"
} else {
c.NodeAHTML = plainHTML("MISSING")
c.NodeAClass = "missing"
c.NodeBHTML = val
}
row.Cells = append(row.Cells, c)
}
if missingInB {
row.NodeAJSON = p.rowJSON(present, cut)
}
return row
}
// formatSlicer describes how the table was cut into blocks. When ACE chose
// the way to cut, the request follows in parentheses, for example
// "sample (auto)".
func formatSlicer(s *types.SlicerSummary) string {
if s == nil || s.Method == "" {
return ""
}
if s.Requested != "" && s.Requested != s.Method {
return s.Method + " (" + s.Requested + ")"
}
return s.Method
}
// buildHTMLSummaryItems returns the label and value pairs of the summary at
// the top of the HTML report. shown and total are the numbers of rows that
// the report shows and that the diff found; truncated says that the report
// shows fewer rows. Items with an empty or zero value are left out.
func buildHTMLSummaryItems(summary types.DiffSummary, shown, total int64, truncated bool) []htmlSummaryItem {
items := []htmlSummaryItem{
{Label: "Table", Value: fmt.Sprintf("%s.%s", summary.Schema, summary.Table)},
{Label: "Nodes", Value: strings.Join(summary.Nodes, ", ")},
{Label: "Primary Key", Value: formatPrimaryKey(summary.PrimaryKey)},
{Label: "Total Differences", Value: formatInt64WithCommas(totalDiffs(summary.DiffRowsCount))},
}
if truncated {
items = append(items, htmlSummaryItem{
Label: "Rows Shown in Report",
Value: formatInt64WithCommas(shown) + " of " + formatInt64WithCommas(total),
})
}
items = append(items,
htmlSummaryItem{Label: "Total Rows Checked", Value: formatInt64WithCommas(summary.TotalRowsChecked)},
htmlSummaryItem{Label: "Initial Ranges", Value: formatInt64WithCommas(int64(summary.InitialRangesCount))},
htmlSummaryItem{Label: "Mismatched Ranges", Value: formatInt64WithCommas(int64(summary.MismatchedRangesCount))},
htmlSummaryItem{Label: "Block Size", Value: formatInt64WithCommas(int64(summary.BlockSize))},
htmlSummaryItem{Label: "Slicer", Value: formatSlicer(summary.Slicer)},
htmlSummaryItem{Label: "Concurrency Factor", Value: strconv.FormatFloat(summary.ConcurrencyFactor, 'f', -1, 64)},
htmlSummaryItem{Label: "Time Taken", Value: formatDurationHuman(summary.TimeTaken)},
htmlSummaryItem{Label: "Start Time", Value: formatTimestampHuman(summary.StartTime)},
htmlSummaryItem{Label: "End Time", Value: formatTimestampHuman(summary.EndTime)},
)
items = append(items, summaryLimitItems(summary)...)
var filtered []htmlSummaryItem
for _, item := range items {
if item.Value != "" && item.Value != "0" {
filtered = append(filtered, item)
}
}
return filtered
}
// summaryLimitItems returns the summary items that tell the reader the diff
// may be incomplete.
func summaryLimitItems(summary types.DiffSummary) []htmlSummaryItem {
var items []htmlSummaryItem
if summary.MaxDiffRows > 0 {
items = append(items, htmlSummaryItem{
Label: "Max Diff Rows",
Value: formatInt64WithCommas(summary.MaxDiffRows),
})
if summary.DiffRowLimitReached {
items = append(items, htmlSummaryItem{
Label: "Stopped Early",
Value: "yes (max_diff_rows limit)",
})
}
}
return items
}
// WriteHTMLDiffReport writes the HTML report of a diff result next to the
// result directory, with the same base name and the extension .html, and
// returns its path. It shows at most maxRows rows for each node pair and cuts
// each shown value to maxValueLen characters; a value of 0 or less means
// DefaultMaxHTMLRows or DefaultMaxHTMLValueLength.
func WriteHTMLDiffReport(ctx context.Context, r *StoredDiff, maxRows, maxValueLen int64) (htmlPath string, err error) {
path := r.Dir + ".html"
// Each run has its own temporary file, so two runs on one result do not
// write into the same file.
f, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)+".tmp-*")
if err != nil {
return "", fmt.Errorf("failed to create HTML diff report: %w", err)
}
tmp := f.Name()
if err := f.Chmod(SecureFileMode); err != nil {
f.Close()
os.Remove(tmp)
return "", fmt.Errorf("restrict permissions on %s: %w", tmp, err)
}
// A report cut off in the middle looks complete in a browser up to the
// point where it stops, so do not leave one behind.
defer func() {
if err != nil {
f.Close()
os.Remove(tmp)
}
}()
diffFile := filepath.Base(r.Dir) + ".json"
if err = renderHTMLDiffReport(ctx, f, r, diffFile, maxRows, maxValueLen); err != nil {
return "", err
}
if err = f.Close(); err != nil {
return "", fmt.Errorf("failed to close HTML diff report: %w", err)
}
// A canceled run must not replace a report that is already there.
if err = ctx.Err(); err != nil {
return "", err
}
if err = os.Rename(tmp, path); err != nil {
return "", fmt.Errorf("failed to rename HTML diff report: %w", err)
}
return path, nil
}
// htmlBlockWriter runs template blocks and other writes, and keeps the first
// error. After an error or a cancel of ctx it does nothing more, so a long run
// of writes needs a single check at the end.
type htmlBlockWriter struct {
ctx context.Context
tmpl *template.Template
w *bufio.Writer
err error
}
func (b *htmlBlockWriter) do(fn func() error) {
if b.err == nil {
b.err = b.ctx.Err()
}
if b.err == nil {
b.err = fn()
}
}
func (b *htmlBlockWriter) block(name string, data any) {
b.do(func() error { return b.tmpl.ExecuteTemplate(b.w, name, data) })
}
// renderHTMLDiffReport writes the report to out. diffFile is the name of the
// JSON report, which the page refers to for the full diff.
//
// The report goes out block by block through a buffered writer. The shown
// rows of every pair are held in memory, with their values cut to
// maxValueLen, before the first byte is written, because the page head says
// how many rows are shown.
func renderHTMLDiffReport(ctx context.Context, out io.Writer, r *StoredDiff, diffFile string, maxRows, maxValueLen int64) error {
if maxRows <= 0 {
maxRows = DefaultMaxHTMLRows
}
if maxValueLen <= 0 {
maxValueLen = DefaultMaxHTMLValueLength
}
tmpl, err := template.New("tableDiffReport").Parse(htmlDiffTemplate)
if err != nil {
return fmt.Errorf("failed to parse HTML template: %w", err)
}
m := r.Manifest
summary := m.Summary
columnNames := make([]string, 0, len(m.Columns))
for _, c := range m.Columns {
columnNames = append(columnNames, c.Name)
}
columns := reorderColumnsWithPriority(columnNames, m.PrimaryKey)
// The page header says whether the report is truncated, so every pair
// must be planned before the first byte is written.
var plans []*htmlPairPlan
for _, pair := range m.Pairs {
p, err := buildHTMLPairPlan(ctx, r, pair, columns, m.PrimaryKey, maxRows, maxValueLen)
if err != nil {
return err
}
plans = append(plans, p)
}
head, info := buildHTMLReportHead(summary, plans, diffFile, maxRows, m.IntegerKey)
w := bufio.NewWriterSize(out, htmlWriteBufferSize)
bw := &htmlBlockWriter{ctx: ctx, tmpl: tmpl, w: w}
bw.block("report_head", head)
if len(plans) == 0 {
bw.block("no_pairs", nil)
}
for _, p := range plans {
writeHTMLPair(bw, p, summary, diffFile)
}
bw.do(func() error { return writeHTMLDiffData(w, summary, plans, info) })
bw.block("report_tail", htmlReportTail{JS: template.JS(htmlDiffJS)})
bw.do(w.Flush)
if bw.err != nil {
return fmt.Errorf("failed to write HTML diff report: %w", bw.err)
}
return nil
}
// buildHTMLReportHead returns the data of the page head and the report info
// for the page script. Both say whether the report is truncated.
func buildHTMLReportHead(summary types.DiffSummary, plans []*htmlPairPlan, diffFile string, maxRows int64, integerPK bool) (htmlReportHead, htmlReportInfo) {
info := htmlReportInfo{
MaxRows: maxRows,
DiffFile: diffFile,
IntegerPK: integerPK && len(summary.PrimaryKey) == 1,
Pairs: make([]htmlPairInfo, 0, len(plans)),
}
var shownAll, totalAll int64
for _, p := range plans {
shownAll += p.shownCount()
totalAll += p.total()
info.Pairs = append(info.Pairs, htmlPairInfo{Pair: p.pairKey, Shown: p.shownCount(), Total: p.total()})
}
info.Truncated = shownAll < totalAll
head := htmlReportHead{
CSS: template.CSS(htmlDiffCSS),
Summary: htmlSummaryData{
Items: buildHTMLSummaryItems(summary, shownAll, totalAll, info.Truncated),
Breakdown: buildDiffBreakdown(summary.DiffRowsCount),
},
}
if info.Truncated {
head.Truncation = &htmlTruncation{
Shown: formatInt64WithCommas(shownAll),
Total: formatInt64WithCommas(totalAll),
Limit: formatInt64WithCommas(maxRows),
DiffFile: diffFile,
}
logger.Warn("HTML report shows %d of %d rows (max_html_rows=%d per node pair); the full diff is in %s",
shownAll, totalAll, maxRows, diffFile)
}
return head, info
}
// writeHTMLPair writes the section of one node pair, one row at a time.
func writeHTMLPair(bw *htmlBlockWriter, p *htmlPairPlan, summary types.DiffSummary, diffFile string) {
head := p.pairHead(summary, diffFile)
bw.block("pair_head", head)
for i, e := range p.entries {
if bw.err != nil {
return
}
row := e.row
row.First = i == 0
if e.kind == htmlKindValue {
bw.block("value_row", row)
} else {
bw.block("missing_row", row)
}
}
bw.block("pair_tail", head)
}
// pairHead returns the data of the pair head and tail.
func (p *htmlPairPlan) pairHead(summary types.DiffSummary, diffFile string) htmlPairHead {
counted := summary.DiffRowsCount[p.pairKey]
head := htmlPairHead{
NodeA: p.nodeA,
NodeB: p.nodeB,
DiffCount: formatInt64WithCommas(int64(counted)),
Total: formatInt64WithCommas(p.total()),
DiffFile: diffFile,
HasDiffs: p.total() > 0,
}
if hidden := p.total() - p.shownCount(); hidden > 0 {
head.Shown = formatInt64WithCommas(p.shownCount())
head.Hidden = formatInt64WithCommas(hidden)
head.HiddenBreakdown = p.hiddenBreakdown()
}
if p.notRendered > 0 {
head.NotRendered = formatInt64WithCommas(p.notRendered)
}
return head
}
// writeHTMLDiffData embeds the data that the page script needs to build a
// repair plan: the diff summary, htmlReportInfo, and one htmlPlanRow for each
// shown row, in report order. The rows are written one at a time.
//
// json.Marshal escapes '<', '>' and '&', so no value can close the script
// element early.
func writeHTMLDiffData(w *bufio.Writer, summary types.DiffSummary, plans []*htmlPairPlan, info htmlReportInfo) error {
w.WriteString(`\n")
return err
}
func writeJSONTo(w *bufio.Writer, v any) error {
b, err := json.Marshal(v)
if err != nil {
return err
}
_, err = w.Write(b)
return err
}
func highlightDifference(a, b string) (template.HTML, template.HTML) {
if a == b {
esc := template.HTMLEscapeString(a)
return template.HTML(esc), template.HTML(esc)
}
runesA := []rune(a)
runesB := []rune(b)
prefix := 0
maxPrefix := len(runesA)
if len(runesB) < maxPrefix {
maxPrefix = len(runesB)
}
for prefix < maxPrefix && runesA[prefix] == runesB[prefix] {
prefix++
}
suffix := 0
for suffix < len(runesA)-prefix && suffix < len(runesB)-prefix && runesA[len(runesA)-suffix-1] == runesB[len(runesB)-suffix-1] {
suffix++
}
prefixA, suffixA := prefix, suffix
prefixB, suffixB := prefix, suffix
if len(runesA)-prefixA-suffixA <= 0 {
prefixA = 0
suffixA = 0
}
if len(runesB)-prefixB-suffixB <= 0 {
prefixB = 0
suffixB = 0
}
highlightedA := renderHighlighted(runesA, prefixA, suffixA)
highlightedB := renderHighlighted(runesB, prefixB, suffixB)
return highlightedA, highlightedB
}
func renderHighlighted(value []rune, prefix, suffix int) template.HTML {
var builder strings.Builder
if prefix > 0 {
builder.WriteString(template.HTMLEscapeString(string(value[:prefix])))
}
middleLen := len(value) - prefix - suffix
if middleLen > 0 {
builder.WriteString(``)
builder.WriteString(template.HTMLEscapeString(string(value[prefix : prefix+middleLen])))
builder.WriteString(``)
}
if suffix > 0 {
builder.WriteString(template.HTMLEscapeString(string(value[len(value)-suffix:])))
}
return template.HTML(builder.String())
}
func plainHTML(value string) template.HTML {
return template.HTML(template.HTMLEscapeString(value))
}
func totalDiffs(diffCounts map[string]int) int64 {
var total int64
for _, count := range diffCounts {
total += int64(count)
}
return total
}
func buildDiffBreakdown(diffCounts map[string]int) []htmlPairCount {
if len(diffCounts) == 0 {
return nil
}
pairs := make([]htmlPairCount, 0, len(diffCounts))
for pair, count := range diffCounts {
pairs = append(pairs, htmlPairCount{Name: pair, Count: formatInt64WithCommas(int64(count))})
}
sort.Slice(pairs, func(i, j int) bool { return pairs[i].Name < pairs[j].Name })
return pairs
}
// buildRowKey returns the identity a row is matched by across the two nodes.
// It falls back to the row's position when there is no usable primary key.
// That keeps distinct rows of one node distinct, but it pairs rows of the two
// nodes by position only: __row_0 on A meets __row_0 on B, whatever they hold.
func buildRowKey(row types.OrderedMap, primaryKey []string, index int) string {
if len(primaryKey) == 0 {
return fmt.Sprintf("__row_%d", index)
}
key, err := StringifyOrderedMapKey(row, primaryKey)
if err != nil || key == "" {
return fmt.Sprintf("__row_%d", index)
}
return key
}
// buildRowDisplayKey renders the same primary key for human eyes: the raw
// values joined by a pipe, with no quoting. Two rows whose pkey values differ
// only in where the pipes fall share a display key; they still have distinct
// identities, so neither is dropped from the report.
func buildRowDisplayKey(row types.OrderedMap, primaryKey []string, index int) string {
if len(primaryKey) == 0 {
return fmt.Sprintf("__row_%d", index)
}
parts := make([]string, len(primaryKey))
for i, col := range primaryKey {
val, ok := row.Get(col)
if !ok {
return fmt.Sprintf("__row_%d", index)
}
parts[i] = fmt.Sprintf("%v", val)
}
return strings.Join(parts, "|")
}
func reorderColumnsWithPriority(columns, priority []string) []string {
ordered := make([]string, 0, len(columns))
seen := make(map[string]struct{}, len(columns))
for _, col := range priority {
for _, candidate := range columns {
if candidate == col {
if _, ok := seen[candidate]; !ok {
ordered = append(ordered, candidate)
seen[candidate] = struct{}{}
}
break
}
}
}
for _, col := range columns {
if _, ok := seen[col]; ok {
continue
}
ordered = append(ordered, col)
seen[col] = struct{}{}
}
return ordered
}
func rowsDiffer(rowA, rowB types.OrderedMap, columns []string) bool {
mapA := OrderedMapToMap(rowA)
mapB := OrderedMapToMap(rowB)
for _, col := range columns {
valA, okA := mapA[col]
valB, okB := mapB[col]
if !okA && !okB {
continue
}
if stringifyCellValue(valA) != stringifyCellValue(valB) {
return true
}
}
return false
}
func stringifyCellValue(value any) string {
switch v := value.(type) {
case nil:
return "NULL"
case string:
return v
case fmt.Stringer:
return v.String()
case json.Number:
return v.String()
default:
if v == nil {
return "NULL"
}
if b, err := json.Marshal(v); err == nil {
return string(b)
}
return fmt.Sprintf("%v", v)
}
}
func formatInt64WithCommas(value int64) string {
sign := ""
if value < 0 {
sign = "-"
value = -value
}
s := strconv.FormatInt(value, 10)
n := len(s)
if n <= 3 {
return sign + s
}
var builder strings.Builder
builder.Grow(len(s) + len(s)/3)
remainder := n % 3
if remainder == 0 {
remainder = 3
}
builder.WriteString(s[:remainder])
for i := remainder; i < n; i += 3 {
builder.WriteString(",")
builder.WriteString(s[i : i+3])
}
return sign + builder.String()
}
func formatDurationHuman(durationStr string) string {
if durationStr == "" {
return ""
}
dur, err := time.ParseDuration(durationStr)
if err != nil {
return durationStr
}
if dur < time.Millisecond {
return fmt.Sprintf("%dµs", dur/time.Microsecond)
}
if dur < time.Second {
return fmt.Sprintf("%.2f ms", float64(dur)/float64(time.Millisecond))
}
if dur < time.Minute {
return fmt.Sprintf("%.2f s", dur.Seconds())
}
minutes := int(dur.Minutes())
seconds := int(dur.Seconds()) % 60
if dur < time.Hour {
return fmt.Sprintf("%dm %ds", minutes, seconds)
}
hours := minutes / 60
minutes = minutes % 60
return fmt.Sprintf("%dh %dm %ds", hours, minutes, seconds)
}
func formatTimestampHuman(ts string) string {
if ts == "" {
return ""
}
t, err := time.Parse(time.RFC3339, ts)
if err != nil {
return ts
}
return t.Format("02 Jan 2006 15:04:05 MST")
}
func formatPrimaryKey(pk []string) string {
if len(pk) == 0 {
return "N/A"
}
return strings.Join(pk, ", ")
}
// buildRowJSONPretty returns the row as an indented JSON object with the
// given columns, with the keys in sorted order.
func buildRowJSONPretty(row types.OrderedMap, columns []string) string {
if len(row) == 0 || len(columns) == 0 {
return ""
}
rowMap := OrderedMapToMap(row)
ordered := make(map[string]any, len(columns))
for _, col := range columns {
if val, ok := rowMap[col]; ok {
ordered[col] = val
}
}
b, err := json.MarshalIndent(ordered, "", " ")
if err != nil {
return ""
}
return string(b)
}