diff --git a/DebugProbe.AspNetCore/DebugProbe.AspNetCore.csproj b/DebugProbe.AspNetCore/DebugProbe.AspNetCore.csproj index 0cf30dc..0995f20 100644 --- a/DebugProbe.AspNetCore/DebugProbe.AspNetCore.csproj +++ b/DebugProbe.AspNetCore/DebugProbe.AspNetCore.csproj @@ -36,8 +36,9 @@ - - + + + @@ -45,8 +46,11 @@ - - + + + + + diff --git a/DebugProbe.AspNetCore/Extensions/DebugProbeExtensions.cs b/DebugProbe.AspNetCore/Extensions/DebugProbeExtensions.cs index 3681298..fa49003 100644 --- a/DebugProbe.AspNetCore/Extensions/DebugProbeExtensions.cs +++ b/DebugProbe.AspNetCore/Extensions/DebugProbeExtensions.cs @@ -143,13 +143,16 @@ public static IApplicationBuilder UseDebugProbe(this IApplicationBuilder app) return item is null ? Results.NotFound() : Results.Json(item); }).ExcludeFromDescription(); - webApp.MapGet("/debug/compare.js", () => - Results.Text(EmbeddedResources.CompareJs, "application/javascript") - ).ExcludeFromDescription(); - webApp.MapGet("/debug/ui.js", () => - Results.Text(EmbeddedResources.UiJs, "application/javascript") - ).ExcludeFromDescription(); + webApp.MapGet("/debug/js/{file}", (string file) => + { + if (!EmbeddedResources.JavaScript.TryGetValue(file, out var content)) + { + return Results.NotFound(); + } + + return Results.Text(content, "application/javascript"); + }).ExcludeFromDescription(); webApp.MapPost("/debug/clear", (DebugEntryStore store) => { diff --git a/DebugProbe.AspNetCore/Internal/EmbeddedResources.cs b/DebugProbe.AspNetCore/Internal/EmbeddedResources.cs index 4e18199..ffa9262 100644 --- a/DebugProbe.AspNetCore/Internal/EmbeddedResources.cs +++ b/DebugProbe.AspNetCore/Internal/EmbeddedResources.cs @@ -7,10 +7,20 @@ internal static class EmbeddedResources { public static readonly string Css = ResourceLoader.LoadCss("debugprobe.css"); + public static readonly string Layout = ResourceLoader.LoadHtml("_shared.layout.html"); + public static readonly string Index = ResourceLoader.LoadHtml("index.html"); + public static readonly string Details = ResourceLoader.LoadHtml("details.html"); - public static readonly string CompareJs = ResourceLoader.LoadJs("debugprobe-compare.js"); - public static readonly string UiJs = ResourceLoader.LoadJs("debugprobe-ui.js"); + + public static readonly Dictionary JavaScript = new() + { + ["debugprobe-compare-renderer.js"] = ResourceLoader.LoadJs("debugprobe_compare_renderer.js"), + + ["debugprobe-compare-engine.js"] = ResourceLoader.LoadJs("debugprobe_compare_engine.js"), + + ["debugprobe-ui.js"] = ResourceLoader.LoadJs("debugprobe_ui.js") + }; } diff --git a/DebugProbe.AspNetCore/Resources/html/_shared/layout.html b/DebugProbe.AspNetCore/Resources/html/_shared/layout.html index 58c8cb9..51d2a1f 100644 --- a/DebugProbe.AspNetCore/Resources/html/_shared/layout.html +++ b/DebugProbe.AspNetCore/Resources/html/_shared/layout.html @@ -13,6 +13,7 @@ {{content}} - - + + + \ No newline at end of file diff --git a/DebugProbe.AspNetCore/Resources/js/debugprobe-compare.js b/DebugProbe.AspNetCore/Resources/js/debugprobe-compare.js deleted file mode 100644 index f544fd5..0000000 --- a/DebugProbe.AspNetCore/Resources/js/debugprobe-compare.js +++ /dev/null @@ -1,608 +0,0 @@ -window.runCompare = async function () { - const id = window.location.pathname.split('/').pop(); - const base = document.getElementById('baseUrl').value.trim(); - const remoteId = document.getElementById('compareId').value.trim(); - - if (!base || !remoteId) { - alert('Fill both fields'); - return; - } - - setCompareResult('Comparing...'); - - try { - const res = await fetch(`/debug/compare/${id}?baseUrl=${encodeURIComponent(base)}&remoteTraceId=${encodeURIComponent(remoteId)}`); - - if (!res.ok) { - const text = await res.json(); - setCompareResult(`${text || 'Compare failed'}`); - return; - } - - setCompareResult(renderCompare(await res.json())); - } catch { - setCompareResult('Error during compare'); - } -}; - -function setCompareResult(html) { - document.getElementById('compareResult').innerHTML = html; -} - -function renderCompare(result) { - - const environmentRows = [ - { field: 'Environment', local: result.environment?.local, remote: result.environment?.remote }, - { field: 'Culture', local: result.culture?.local, remote: result.culture?.remote } - ]; - var environmentRowsChangedCount = getChangedCount(environmentRows); - - const overviewRows = [ - { field: 'Method', local: result.method?.local, remote: result.method?.remote }, - { field: 'Path', local: result.path?.local, remote: result.path?.remote }, - { field: 'Status', local: result.status?.local, remote: result.status?.remote }, - { field: 'Request Time', local: result.requestTime?.local, remote: result.requestTime?.remote } - ]; - var overviewRowsChangedCount = getChangedCount(overviewRows); - - const localRequestBodyJson = result.requestBody?.local || '' - const remoteRequestBodyJson = result.requestBody?.remote || '' - const requestComparison = compareJsonBodies(localRequestBodyJson, remoteRequestBodyJson); - - const localResponseBodyJson = result.responseBody?.local || '' - const remoteResponseBodyJson = result.responseBody?.remote || '' - const responseComparison = compareJsonBodies(localResponseBodyJson, remoteResponseBodyJson); - - return [ - renderAccordionSection( - 'Environment', - renderSectionRows(environmentRows), - environmentRowsChangedCount > 0 ? true : false, - environmentRowsChangedCount - ), - renderAccordionSection( - 'Overview', - renderSectionRows(overviewRows), - overviewRowsChangedCount > 0 ? true : false, - overviewRowsChangedCount - ), - renderAccordionSection( - 'Request', - renderSideBySideJson(requestComparison, localRequestBodyJson, remoteRequestBodyJson), - requestComparison.changes > 0 ? true : false, - requestComparison.changes - ), - renderAccordionSection( - 'Response', - renderSideBySideJson(responseComparison, localResponseBodyJson, remoteResponseBodyJson), - responseComparison.changes > 0 ? true : false, - responseComparison.changes - ) - ].join(''); -} - -function renderSectionRows(rows) { - const body = rows.map(row => { - const changed = row.local !== row.remote; - const rowStyle = changed ? ' style="background:rgba(255,200,0,0.12)"' : ''; - const valueStyle = changed ? ' style="color:#e74c3c"' : ''; - - return ` - ${escapeHtml(row.field)} - ${escapeHtml(row.local ?? '')} - ${escapeHtml(row.remote ?? '')} - `; - }).join(''); - - return ` - - - - - - - ${body} -
FieldLocalRemote
- `; -} - -function renderSideBySideJson(comparison, localJson, remoteJson) { - return `
-
- Local - ${comparison.localError ? `
${comparison.localError}
` : '' } - ${renderAlignedJson(comparison.local, localJson)} -
- -
- Remote - ${comparison.remoteError ? `
${comparison.remoteError}
` : '' } - ${renderAlignedJson(comparison.remote, remoteJson)} -
-
`; -} - -function renderAccordionSection(title, content, expanded = false, changes = 0) { - return ` -
-
-
${escapeHtml(title)}
-
- ${changes > 0 ? `${changes}` : '' } - - - ${expanded ? '-' : '+'} - -
-
- -
- ${content} -
-
- `; -} - -function toggleAccordion(header) { - const body = header.nextElementSibling; - const toggle = header.querySelector('.accordion-toggle'); - - body.classList.toggle('open'); - - toggle.textContent = - body.classList.contains('open') ? '-' : '+'; -} - -function getChangedCount(rows) { - return rows.filter(x => x.local !== x.remote).length; -} - - -function compareJsonBodies(localJson, remoteJson) { - const local = parseJson(localJson); - const remote = parseJson(remoteJson); - - const localEmpty = !local.raw || local.raw.trim() === ''; - const remoteEmpty = !remote.raw || remote.raw.trim() === ''; - - if (localEmpty && remoteEmpty) { - return { - local: [{ text: '(empty)', state: '' }], - remote: [{ text: '(empty)', state: '' }], - changes: 0 - }; - } - - if ( - (!local.ok && !localEmpty) || - (!remote.ok && !remoteEmpty) - ) { - return { - local: (localEmpty ? '(empty)' : local.raw) - .split('\n') - .map(x => ({ - text: x, - state: !local.ok && !localEmpty ? 'invalid' : '' - })), - - remote: (remoteEmpty ? '(empty)' : remote.raw) - .split('\n') - .map(x => ({ - text: x, - state: !remote.ok && !remoteEmpty ? 'invalid' : '' - })), - - localError: !local.ok && !localEmpty - ? 'Failed To Parse JSON' - : null, - - remoteError: !remote.ok && !remoteEmpty - ? 'Failed To Parse JSON' - : null, - - changes: 1 - }; - } - - if (localEmpty || remoteEmpty) { - return { - local: localEmpty - ? [{ text: '(empty)', state: '' }] - : stringifyLines(local.value, 0, false) - .map(x => ({ text: x, state: 'added' })), - - remote: remoteEmpty - ? [{ text: '(empty)', state: '' }] - : stringifyLines(remote.value, 0, false) - .map(x => ({ text: x, state: 'added' })), - - changes: 1 - }; - } - - const rows = createRows(); - appendValue(rows, local.value, remote.value, local.ok, remote.ok, 0, false); - - return rows; -} - -function parseJson(json) { - if (!json || json.trim() === '') { - return { ok: false, value: null, raw: '' }; - } - - try { - return { - ok: true, - value: JSON.parse(json), - raw: json - }; - } catch (error) { - return { - ok: false, - value: null, - raw: json, - error: error.message - }; - } -} - -function emptyComparison() { - return { - local: [{ text: '(empty)', state: '' }], - remote: [{ text: '(empty)', state: '' }] - }; -} - -function createRows() { - return { local: [], remote: [], changes: 0 }; -} - -function pushPair(rows, localText, remoteText, state = '') { - rows.local.push({ text: localText, state }); - rows.remote.push({ text: remoteText, state }); -} - -function pushPresence(rows, value, side, depth, trailingComma) { - rows.changes++; - - const isLocal = side === 'local'; - const lines = stringifyLines(value, depth, trailingComma); - - lines.forEach(line => { - rows.local.push({ text: isLocal ? line : '', state: isLocal ? 'added' : 'missing' }); - rows.remote.push({ text: isLocal ? '' : line, state: isLocal ? 'missing' : 'added' }); - }); -} - -function appendValue(rows, localValue, remoteValue, hasLocal, hasRemote, depth, trailingComma) { - if (!hasLocal || !hasRemote) { - pushPresence(rows, hasLocal ? localValue : remoteValue, hasLocal ? 'local' : 'remote', depth, trailingComma); - return; - } - - if (isObject(localValue) && isObject(remoteValue)) { - appendObject(rows, localValue, remoteValue, depth, trailingComma); - return; - } - - if (Array.isArray(localValue) && Array.isArray(remoteValue)) { - appendArray(rows, localValue, remoteValue, depth, trailingComma); - return; - } - - if (isContainer(localValue) || isContainer(remoteValue)) { - appendChangedBlocks(rows, localValue, remoteValue, depth, trailingComma); - return; - } - - const changed = !jsonEquals(localValue, remoteValue); - - if (changed) { - rows.changes++; - } - - const state = changed ? 'changed' : ''; - pushPair(rows, primitiveLine(localValue, depth, trailingComma), primitiveLine(remoteValue, depth, trailingComma), state); -} - -function appendObject(rows, localObject, remoteObject, depth, trailingComma) { - pushPair(rows, `${indent(depth)}{`, `${indent(depth)}{`); - - unionKeys(localObject, remoteObject).forEach((key, index, keys) => { - appendProperty(rows, key, localObject, remoteObject, depth + 1, index < keys.length - 1); - }); - - pushPair(rows, `${indent(depth)}}${trailingComma ? ',' : ''}`, `${indent(depth)}}${trailingComma ? ',' : ''}`); -} - -function appendProperty(rows, key, localObject, remoteObject, depth, trailingComma) { - const hasLocal = hasOwn(localObject, key); - const hasRemote = hasOwn(remoteObject, key); - const localValue = hasLocal ? localObject[key] : null; - const remoteValue = hasRemote ? remoteObject[key] : null; - - if (!hasLocal || !hasRemote) { - pushNamedPresence(rows, key, hasLocal ? localValue : remoteValue, hasLocal ? 'local' : 'remote', depth, trailingComma); - return; - } - - if (isObject(localValue) && isObject(remoteValue)) { - pushPair(rows, `${indent(depth)}"${key}": {`, `${indent(depth)}"${key}": {`, jsonEquals(localValue, remoteValue) ? '' : 'changed'); - unionKeys(localValue, remoteValue).forEach((childKey, index, keys) => { - appendProperty(rows, childKey, localValue, remoteValue, depth + 1, index < keys.length - 1); - }); - pushPair(rows, `${indent(depth)}}${trailingComma ? ',' : ''}`, `${indent(depth)}}${trailingComma ? ',' : ''}`); - return; - } - - if (Array.isArray(localValue) && Array.isArray(remoteValue)) { - pushPair(rows, `${indent(depth)}"${key}": [`, `${indent(depth)}"${key}": [`, jsonEquals(localValue, remoteValue) ? '' : 'changed'); - appendArrayItems(rows, localValue, remoteValue, depth + 1); - pushPair(rows, `${indent(depth)}]${trailingComma ? ',' : ''}`, `${indent(depth)}]${trailingComma ? ',' : ''}`); - return; - } - - if (isContainer(localValue) || isContainer(remoteValue)) { - appendChangedNamedBlocks(rows, key, localValue, remoteValue, depth, trailingComma); - return; - } - - const changed = !jsonEquals(localValue, remoteValue); - - if (changed) { - rows.changes++; - } - - const state = changed ? 'changed' : ''; - pushPair(rows, propertyLine(key, localValue, depth, trailingComma), propertyLine(key, remoteValue, depth, trailingComma), state); -} - -function appendArray(rows, localArray, remoteArray, depth, trailingComma) { - pushPair(rows, `${indent(depth)}[`, `${indent(depth)}[`); - appendArrayItems(rows, localArray, remoteArray, depth + 1); - pushPair(rows, `${indent(depth)}]${trailingComma ? ',' : ''}`, `${indent(depth)}]${trailingComma ? ',' : ''}`); -} - -function appendArrayItems(rows, localArray, remoteArray, depth) { - alignArrayItems(localArray, remoteArray).forEach((pair, index, pairs) => { - appendValue(rows, pair.local, pair.remote, pair.hasLocal, pair.hasRemote, depth, index < pairs.length - 1); - }); -} - -function alignArrayItems(localArray, remoteArray) { - const pairs = []; - let localIndex = 0; - let remoteIndex = 0; - - while (localIndex < localArray.length || remoteIndex < remoteArray.length) { - if (localIndex < localArray.length && remoteIndex < remoteArray.length && jsonEquals(localArray[localIndex], remoteArray[remoteIndex])) { - pairs.push(pair(localArray[localIndex], remoteArray[remoteIndex], true, true)); - localIndex++; - remoteIndex++; - continue; - } - - const localStart = localIndex; - const remoteStart = remoteIndex; - const next = findNextEqualItem(localArray, remoteArray, localIndex, remoteIndex); - - localIndex = next.localIndex; - remoteIndex = next.remoteIndex; - - appendUnmatchedItems( - pairs, - localArray.slice(localStart, localIndex), - remoteArray.slice(remoteStart, remoteIndex) - ); - } - - return pairs; -} - -function findNextEqualItem(localArray, remoteArray, localStart, remoteStart) { - let best = { localIndex: localArray.length, remoteIndex: remoteArray.length, distance: Number.MAX_SAFE_INTEGER }; - - for (let i = localStart; i < localArray.length; i++) { - for (let j = remoteStart; j < remoteArray.length; j++) { - if (!jsonEquals(localArray[i], remoteArray[j])) { - continue; - } - - const distance = (i - localStart) + (j - remoteStart); - if (distance < best.distance) { - best = { localIndex: i, remoteIndex: j, distance }; - } - } - } - - return best; -} - -function appendUnmatchedItems(pairs, localItems, remoteItems) { - const sharedCount = Math.min(localItems.length, remoteItems.length); - const extraRemoteCount = Math.max(0, remoteItems.length - localItems.length); - const extraLocalCount = Math.max(0, localItems.length - remoteItems.length); - const remoteOffset = extraRemoteCount > 0 ? bestOffset(localItems, remoteItems, extraRemoteCount) : 0; - const localOffset = extraLocalCount > 0 ? bestOffset(remoteItems, localItems, extraLocalCount) : 0; - - for (let i = 0; i < remoteOffset; i++) { - pairs.push(pair(null, remoteItems[i], false, true)); - } - - for (let i = 0; i < localOffset; i++) { - pairs.push(pair(localItems[i], null, true, false)); - } - - for (let i = 0; i < sharedCount; i++) { - pairs.push(pair(localItems[localOffset + i], remoteItems[remoteOffset + i], true, true)); - } - - for (let i = remoteOffset + sharedCount; i < remoteItems.length; i++) { - pairs.push(pair(null, remoteItems[i], false, true)); - } - - for (let i = localOffset + sharedCount; i < localItems.length; i++) { - pairs.push(pair(localItems[i], null, true, false)); - } -} - -function bestOffset(shorterItems, longerItems, extraCount) { - let offset = 0; - let score = Number.NEGATIVE_INFINITY; - - for (let i = 0; i <= extraCount; i++) { - const currentScore = shorterItems.reduce( - (sum, item, index) => sum + similarity(item, longerItems[i + index]), - 0 - ); - - if (currentScore > score) { - score = currentScore; - offset = i; - } - } - - return offset; -} - -function similarity(localValue, remoteValue) { - if (jsonEquals(localValue, remoteValue)) { - return 1000; - } - - if (Array.isArray(localValue) && Array.isArray(remoteValue)) { - return localValue.reduce( - (score, item, index) => score + similarity(item, remoteValue[index]), - 10 - Math.abs(localValue.length - remoteValue.length) - ); - } - - if (isObject(localValue) && isObject(remoteValue)) { - return unionKeys(localValue, remoteValue).reduce((score, key) => { - if (!hasOwn(localValue, key) || !hasOwn(remoteValue, key)) { - return score - 5; - } - - return score + 5 + (jsonEquals(localValue[key], remoteValue[key]) ? 20 : similarity(localValue[key], remoteValue[key])); - }, 20); - } - - return typeof localValue === typeof remoteValue ? 1 : -10; -} - -function pair(local, remote, hasLocal, hasRemote) { - return { local, remote, hasLocal, hasRemote }; -} - -function pushNamedPresence(rows, key, value, side, depth, trailingComma) { - const lines = stringifyLines(value, depth, trailingComma, `"${key}": `); - const isLocal = side === 'local'; - - lines.forEach(line => { - rows.local.push({ text: isLocal ? line : '', state: isLocal ? 'added' : 'missing' }); - rows.remote.push({ text: isLocal ? '' : line, state: isLocal ? 'missing' : 'added' }); - }); -} - -function appendChangedBlocks(rows, localValue, remoteValue, depth, trailingComma) { - rows.changes++; - - const localLines = stringifyLines(localValue, depth, trailingComma); - const remoteLines = stringifyLines(remoteValue, depth, trailingComma); - const count = Math.max(localLines.length, remoteLines.length); - - for (let i = 0; i < count; i++) { - rows.local.push({ text: localLines[i] || '', state: localLines[i] ? 'changed' : 'missing' }); - rows.remote.push({ text: remoteLines[i] || '', state: remoteLines[i] ? 'changed' : 'missing' }); - } -} - -function appendChangedNamedBlocks(rows, key, localValue, remoteValue, depth, trailingComma) { - rows.changes++; - - const localLines = stringifyLines(localValue, depth, trailingComma, `"${key}": `); - const remoteLines = stringifyLines(remoteValue, depth, trailingComma, `"${key}": `); - const count = Math.max(localLines.length, remoteLines.length); - - for (let i = 0; i < count; i++) { - rows.local.push({ text: localLines[i] || '', state: localLines[i] ? 'changed' : 'missing' }); - rows.remote.push({ text: remoteLines[i] || '', state: remoteLines[i] ? 'changed' : 'missing' }); - } -} - -function stringifyLines(value, depth, trailingComma, firstLinePrefix = '') { - const lines = JSON.stringify(value, null, 2).split('\n'); - - return lines.map((line, index) => { - const prefix = index === 0 ? firstLinePrefix : ''; - const comma = index === lines.length - 1 && trailingComma ? ',' : ''; - return `${indent(depth)}${prefix}${line}${comma}`; - }); -} - -function renderAlignedJson(lines, originalJson) { - const content = lines.map(line => { - const className = line.state ? `diff-line diff-line-${line.state}` : ''; - const text = line.text ? escapeHtml(line.text) : ' '; - - return `
${text}
`; - }).join(''); - - return `
- -
${content}
-
`; -} - -function formatCopyValue(json, lines) { - const parsed = parseJson(json); - - if (parsed.ok) { - return JSON.stringify(parsed.value, null, 2); - } - - return lines.map(line => line.text).join('\n'); -} - -function propertyLine(key, value, depth, trailingComma) { - return `${indent(depth)}"${key}": ${JSON.stringify(value)}${trailingComma ? ',' : ''}`; -} - -function primitiveLine(value, depth, trailingComma) { - return `${indent(depth)}${JSON.stringify(value)}${trailingComma ? ',' : ''}`; -} - -function unionKeys(localObject, remoteObject) { - return [...new Set([...Object.keys(localObject || {}), ...Object.keys(remoteObject || {})])]; -} - -function hasOwn(value, key) { - return Object.prototype.hasOwnProperty.call(value, key); -} - -function isContainer(value) { - return Array.isArray(value) || isObject(value); -} - -function isObject(value) { - return value !== null && typeof value === 'object' && !Array.isArray(value); -} - -function jsonEquals(localValue, remoteValue) { - return JSON.stringify(localValue) === JSON.stringify(remoteValue); -} - -function indent(depth) { - return ' '.repeat(depth); -} - -function escapeHtml(value) { - return String(value) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} - diff --git a/DebugProbe.AspNetCore/Resources/js/debugprobe_compare_engine.js b/DebugProbe.AspNetCore/Resources/js/debugprobe_compare_engine.js new file mode 100644 index 0000000..f5b5bf1 --- /dev/null +++ b/DebugProbe.AspNetCore/Resources/js/debugprobe_compare_engine.js @@ -0,0 +1,819 @@ +function compareJsonBodies(localJson, remoteJson) { + const local = parseJson(localJson); + const remote = parseJson(remoteJson); + + const localEmpty = !local.raw || local.raw.trim() === ''; + const remoteEmpty = !remote.raw || remote.raw.trim() === ''; + + if (localEmpty && remoteEmpty) { + return { + local: [{ text: '(empty)', state: '' }], + remote: [{ text: '(empty)', state: '' }], + changes: 0 + }; + } + + if ((!local.ok && !localEmpty) || (!remote.ok && !remoteEmpty)) { + return { + local: (localEmpty ? '(empty)' : local.raw) + .split('\n') + .map(x => ({ + text: x, + state: !local.ok && !localEmpty ? 'invalid' : '' + })), + + remote: (remoteEmpty ? '(empty)' : remote.raw) + .split('\n') + .map(x => ({ + text: x, + state: !remote.ok && !remoteEmpty ? 'invalid' : '' + })), + + localError: !local.ok && !localEmpty ? 'Failed To Parse JSON' : null, + + remoteError: !remote.ok && !remoteEmpty ? 'Failed To Parse JSON' : null, + + changes: 1 + }; + } + + if (localEmpty || remoteEmpty) { + return { + local: localEmpty + ? [{ text: '(empty)', state: '' }] + : stringifyLines(local.value, 0, false) + .map(x => ({ text: x, state: 'added' })), + + remote: remoteEmpty + ? [{ text: '(empty)', state: '' }] + : stringifyLines(remote.value, 0, false) + .map(x => ({ text: x, state: 'added' })), + + changes: 1 + }; + } + + const rows = createRows(); + + appendValue( + rows, + local.value, + remote.value, + local.ok, + remote.ok, + 0, + false + ); + + return rows; +} + +function parseJson(json) { + if (!json || json.trim() === '') { + return { + ok: false, + value: null, + raw: '' + }; + } + + try { + return { + ok: true, + value: JSON.parse(json), + raw: json + }; + } catch (error) { + return { + ok: false, + value: null, + raw: json, + error: error.message + }; + } +} + +function createRows() { + return { + local: [], + remote: [], + changes: 0 + }; +} + +function pushPair(rows, localText, remoteText, state = '') { + + rows.local.push({ text: localText, state }); + + rows.remote.push({text: remoteText, state }); +} + +function pushPresence(rows, value, side, depth, trailingComma) { + rows.changes++; + + const isLocal = side === 'local'; + + const lines = stringifyLines(value, depth, trailingComma); + + lines.forEach(line => { + rows.local.push({ + text: isLocal ? line : '', + state: isLocal ? 'added' : 'missing' + }); + + rows.remote.push({ + text: isLocal ? '' : line, + state: isLocal ? 'missing' : 'added' + }); + }); +} + +function appendValue(rows, localValue, remoteValue, hasLocal, hasRemote, depth, trailingComma) { + + if (!hasLocal || !hasRemote) { + pushPresence(rows, hasLocal ? localValue : remoteValue, hasLocal ? 'local' : 'remote', depth, trailingComma ); + + return; + } + + if (isObject(localValue) && isObject(remoteValue)) { + appendObject(rows, localValue, remoteValue, depth, trailingComma); + + return; + } + + if (Array.isArray(localValue) && Array.isArray(remoteValue)) { + appendArray(rows, localValue, remoteValue, depth, trailingComma); + + return; + } + + if (isContainer(localValue) || isContainer(remoteValue)) { + appendChangedBlocks(rows, localValue, remoteValue, depth, trailingComma); + + return; + } + + const changed = !jsonEquals(localValue, remoteValue); + + if (changed) { + rows.changes++; + } + + pushPair( + rows, + primitiveLine(localValue, depth, trailingComma), + primitiveLine(remoteValue, depth, trailingComma), + changed ? 'changed' : '' + ); +} + +function appendObject(rows, localObject, remoteObject, depth, trailingComma) { + + pushPair( + rows, + `${indent(depth)}{`, + `${indent(depth)}{` + ); + + const keys = unionKeys(localObject, remoteObject); + + keys.forEach((key, index) => { + appendProperty( + rows, + key, + localObject, + remoteObject, + depth + 1, + index < keys.length - 1 + ); + }); + + pushPair( + rows, + `${indent(depth)}}${trailingComma ? ',' : ''}`, + `${indent(depth)}}${trailingComma ? ',' : ''}` + ); +} + +function appendProperty(rows, key, localObject, remoteObject, depth, trailingComma) { + + const hasLocal = hasOwn(localObject, key); + const hasRemote = hasOwn(remoteObject, key); + + const localValue = hasLocal ? localObject[key] : null; + + const remoteValue = hasRemote ? remoteObject[key] : null; + + if (!hasLocal || !hasRemote) { + pushNamedPresence( + rows, + key, + hasLocal ? localValue : remoteValue, + hasLocal ? 'local' : 'remote', + depth, + trailingComma + ); + + return; + } + + if (isObject(localValue) && isObject(remoteValue)) { + pushPair( + rows, + `${indent(depth)}"${key}": {`, + `${indent(depth)}"${key}": {`, + jsonEquals(localValue, remoteValue) + ? '' + : 'changed' + ); + + const keys = unionKeys(localValue, remoteValue); + + keys.forEach((childKey, index) => { + appendProperty( + rows, + childKey, + localValue, + remoteValue, + depth + 1, + index < keys.length - 1 + ); + }); + + pushPair( + rows, + `${indent(depth)}}${trailingComma ? ',' : ''}`, + `${indent(depth)}}${trailingComma ? ',' : ''}` + ); + + return; + } + + if (Array.isArray(localValue) && Array.isArray(remoteValue)) { + pushPair( + rows, + `${indent(depth)}"${key}": [`, + `${indent(depth)}"${key}": [`, + jsonEquals(localValue, remoteValue) + ? '' + : 'changed' + ); + + appendArrayItems( + rows, + localValue, + remoteValue, + depth + 1 + ); + + pushPair( + rows, + `${indent(depth)}]${trailingComma ? ',' : ''}`, + `${indent(depth)}]${trailingComma ? ',' : ''}` + ); + + return; + } + + if (isContainer(localValue) || isContainer(remoteValue)) { + appendChangedNamedBlocks( + rows, + key, + localValue, + remoteValue, + depth, + trailingComma + ); + + return; + } + + const changed = !jsonEquals(localValue, remoteValue); + + if (changed) { + rows.changes++; + } + + pushPair( + rows, + propertyLine(key, localValue, depth, trailingComma), + propertyLine(key, remoteValue, depth, trailingComma), + changed ? 'changed' : '' + ); +} + +function appendArray(rows, localArray, remoteArray, depth, trailingComma) { + + pushPair( + rows, + `${indent(depth)}[`, + `${indent(depth)}[` + ); + + appendArrayItems( + rows, + localArray, + remoteArray, + depth + 1 + ); + + pushPair( + rows, + `${indent(depth)}]${trailingComma ? ',' : ''}`, + `${indent(depth)}]${trailingComma ? ',' : ''}` + ); +} + +function appendArrayItems(rows, localArray, remoteArray, depth) { + + alignArrayItems(localArray, remoteArray) + .forEach((pair, index, pairs) => { + + appendValue( + rows, + pair.local, + pair.remote, + pair.hasLocal, + pair.hasRemote, + depth, + index < pairs.length - 1 + ); + }); +} + +function alignArrayItems(localArray, remoteArray) { + + const pairs = []; + + let localIndex = 0; + let remoteIndex = 0; + + while ( + localIndex < localArray.length || + remoteIndex < remoteArray.length + ) { + + if ( + localIndex < localArray.length && + remoteIndex < remoteArray.length && + jsonEquals( + localArray[localIndex], + remoteArray[remoteIndex] + ) + ) { + pairs.push( + pair( + localArray[localIndex], + remoteArray[remoteIndex], + true, + true + ) + ); + + localIndex++; + remoteIndex++; + + continue; + } + + const localStart = localIndex; + const remoteStart = remoteIndex; + + const next = findNextEqualItem( + localArray, + remoteArray, + localIndex, + remoteIndex + ); + + localIndex = next.localIndex; + remoteIndex = next.remoteIndex; + + appendUnmatchedItems( + pairs, + localArray.slice(localStart, localIndex), + remoteArray.slice(remoteStart, remoteIndex) + ); + } + + return pairs; +} + +function findNextEqualItem(localArray, remoteArray, localStart, remoteStart) { + + let best = { + localIndex: localArray.length, + remoteIndex: remoteArray.length, + distance: Number.MAX_SAFE_INTEGER + }; + + for (let i = localStart; i < localArray.length; i++) { + for (let j = remoteStart; j < remoteArray.length; j++) { + + if (!jsonEquals(localArray[i], remoteArray[j])) { + continue; + } + + const distance = + (i - localStart) + + (j - remoteStart); + + if (distance < best.distance) { + best = { + localIndex: i, + remoteIndex: j, + distance + }; + } + } + } + + return best; +} + +function appendUnmatchedItems(pairs, localItems, remoteItems) { + + const sharedCount = Math.min(localItems.length, remoteItems.length); + + const extraRemoteCount = Math.max(0, remoteItems.length - localItems.length); + + const extraLocalCount = Math.max(0, localItems.length - remoteItems.length); + + const remoteOffset = extraRemoteCount > 0 ? bestOffset(localItems, remoteItems, extraRemoteCount) : 0; + + const localOffset = extraLocalCount > 0 ? bestOffset(remoteItems, localItems, extraLocalCount) : 0; + + for (let i = 0; i < remoteOffset; i++) { + pairs.push( + pair( + null, + remoteItems[i], + false, + true + ) + ); + } + + for (let i = 0; i < localOffset; i++) { + pairs.push( + pair( + localItems[i], + null, + true, + false + ) + ); + } + + for (let i = 0; i < sharedCount; i++) { + pairs.push( + pair( + localItems[localOffset + i], + remoteItems[remoteOffset + i], + true, + true + ) + ); + } + + for (let i = remoteOffset + sharedCount; i < remoteItems.length; i++) { + pairs.push( + pair( + null, + remoteItems[i], + false, + true + ) + ); + } + + for (let i = localOffset + sharedCount; i < localItems.length; i++) { + pairs.push( + pair( + localItems[i], + null, + true, + false + ) + ); + } +} + +function bestOffset(shorterItems, longerItems, extraCount) { + + let offset = 0; + + let score = Number.NEGATIVE_INFINITY; + + for (let i = 0; i <= extraCount; i++) { + + const currentScore = + shorterItems.reduce( + (sum, item, index) => + sum + + similarity( + item, + longerItems[i + index] + ), + 0 + ); + + if (currentScore > score) { + score = currentScore; + offset = i; + } + } + + return offset; +} + +function similarity(localValue, remoteValue) { + + if (jsonEquals(localValue, remoteValue)) { + return 1000; + } + + if (Array.isArray(localValue) && Array.isArray(remoteValue)) { + + return localValue.reduce( + (score, item, index) => + score + + similarity(item, remoteValue[index]), + 10 - + Math.abs( + localValue.length - + remoteValue.length + ) + ); + } + + if (isObject(localValue) && isObject(remoteValue)) { + + return unionKeys(localValue, remoteValue) + .reduce((score, key) => { + + if (!hasOwn(localValue, key) || !hasOwn(remoteValue, key)) { + return score - 5; + } + + return score + + 5 + + ( + jsonEquals( + localValue[key], + remoteValue[key] + ) + ? 20 + : similarity( + localValue[key], + remoteValue[key] + ) + ); + }, 20); + } + + return typeof localValue === typeof remoteValue ? 1 : -10; +} + +function pair(local, remote, hasLocal, hasRemote) { + + return { + local, + remote, + hasLocal, + hasRemote + }; +} + +function pushNamedPresence(rows, key, value, side, depth, trailingComma +) { + const lines = stringifyLines( + value, + depth, + trailingComma, + `"${key}": ` + ); + + const isLocal = side === 'local'; + + lines.forEach(line => { + + rows.local.push({ + text: isLocal ? line : '', + state: isLocal ? 'added' : 'missing' + }); + + rows.remote.push({ + text: isLocal ? '' : line, + state: isLocal ? 'missing' : 'added' + }); + }); +} + +function appendChangedBlocks(rows, localValue, remoteValue, depth, trailingComma) { + + rows.changes++; + + const localLines = + stringifyLines( + localValue, + depth, + trailingComma + ); + + const remoteLines = + stringifyLines( + remoteValue, + depth, + trailingComma + ); + + const count = + Math.max( + localLines.length, + remoteLines.length + ); + + for (let i = 0; i < count; i++) { + + rows.local.push({ + text: localLines[i] || '', + state: localLines[i] + ? 'changed' + : 'missing' + }); + + rows.remote.push({ + text: remoteLines[i] || '', + state: remoteLines[i] + ? 'changed' + : 'missing' + }); + } +} + +function appendChangedNamedBlocks(rows, key, localValue, remoteValue, depth, trailingComma) { + + rows.changes++; + + const localLines = + stringifyLines( + localValue, + depth, + trailingComma, + `"${key}": ` + ); + + const remoteLines = + stringifyLines( + remoteValue, + depth, + trailingComma, + `"${key}": ` + ); + + const count = + Math.max( + localLines.length, + remoteLines.length + ); + + for (let i = 0; i < count; i++) { + + rows.local.push({ + text: localLines[i] || '', + state: localLines[i] + ? 'changed' + : 'missing' + }); + + rows.remote.push({ + text: remoteLines[i] || '', + state: remoteLines[i] + ? 'changed' + : 'missing' + }); + } +} + +function stringifyLines(value, depth, trailingComma, firstLinePrefix = '') { + + const lines = JSON.stringify(value, null, 2).split('\n'); + + return lines.map((line, index) => { + + const prefix = + index === 0 + ? firstLinePrefix + : ''; + + const comma = + index === lines.length - 1 && + trailingComma + ? ',' + : ''; + + return `${indent(depth)}${prefix}${line}${comma}`; + }); +} + +function propertyLine(key, value, depth, trailingComma) { + + return `${indent(depth)}"${key}": ${JSON.stringify(value)}${trailingComma ? ',' : ''}`; +} + +function primitiveLine(value, depth, trailingComma) { + + return `${indent(depth)}${JSON.stringify(value)}${trailingComma ? ',' : ''}`; +} + +function unionKeys(localObject, remoteObject) { + + return [ + ...new Set([ + ...Object.keys(localObject || {}), + ...Object.keys(remoteObject || {}) + ]) + ]; +} + +function hasOwn(value, key) { + + return Object.prototype.hasOwnProperty.call(value, key); +} + +function isContainer(value) { + + return Array.isArray(value) || isObject(value); +} + +function isObject(value) { + + return (value !== null && typeof value === 'object' && !Array.isArray(value)); +} + +function jsonEquals(a, b) { + + if (a === b) { + return true; + } + + if (typeof a !== typeof b) { + return false; + } + + if (a === null || b === null) { + return false; + } + + if (Array.isArray(a)) { + + if (!Array.isArray(b) || a.length !== b.length) { + + return false; + } + + for (let i = 0; i < a.length; i++) { + + if (!jsonEquals(a[i], b[i])) { + + return false; + } + } + + return true; + } + + if (typeof a === 'object') { + + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + + if (aKeys.length !== bKeys.length) { + return false; + } + + for (const key of aKeys) { + + if (!hasOwn(b, key)) { + return false; + } + + if (!jsonEquals(a[key], b[key])) { + return false; + } + } + + return true; + } + + return false; +} + +function indent(depth) { + + return ' '.repeat(depth); +} + +window.compareJsonBodies = compareJsonBodies; \ No newline at end of file diff --git a/DebugProbe.AspNetCore/Resources/js/debugprobe_compare_renderer.js b/DebugProbe.AspNetCore/Resources/js/debugprobe_compare_renderer.js new file mode 100644 index 0000000..7989b65 --- /dev/null +++ b/DebugProbe.AspNetCore/Resources/js/debugprobe_compare_renderer.js @@ -0,0 +1,291 @@ +window.runCompare = async function () { + + const id = window.location.pathname.split('/').pop(); + + const base = document.getElementById('baseUrl').value.trim(); + + const remoteId = document.getElementById('compareId').value.trim(); + + if (!base || !remoteId) { + alert('Fill both fields'); + return; + } + + setCompareResult('Comparing...'); + + try { + + const res = + await fetch( + `/debug/compare/${id}?baseUrl=${encodeURIComponent(base)}&remoteTraceId=${encodeURIComponent(remoteId)}` + ); + + if (!res.ok) { + + const text = await res.text(); + + setCompareResult(`${text || 'Compare failed'}`); + + return; + } + + const result = await res.json(); + + setCompareResult(renderCompare(result)); + + } catch { + + setCompareResult( + `${error}` + ); + } +}; + +function setCompareResult(html) { + + document.getElementById('compareResult') + .innerHTML = html; +} + +function renderCompare(result) { + + const environmentRows = [ + { + field: 'Environment', + local: result.environment?.local, + remote: result.environment?.remote + }, + { + field: 'Culture', + local: result.culture?.local, + remote: result.culture?.remote + } + ]; + + const environmentRowsChangedCount = + getChangedCount(environmentRows); + + const overviewRows = [ + { + field: 'Method', + local: result.method?.local, + remote: result.method?.remote + }, + { + field: 'Path', + local: result.path?.local, + remote: result.path?.remote + }, + { + field: 'Status', + local: result.status?.local, + remote: result.status?.remote + }, + { + field: 'Request Time', + local: result.requestTime?.local, + remote: result.requestTime?.remote + } + ]; + + const overviewRowsChangedCount = getChangedCount(overviewRows); + + const localRequestBodyJson = result.requestBody?.local || ''; + + const remoteRequestBodyJson = result.requestBody?.remote || ''; + + const requestComparison = compareJsonBodies(localRequestBodyJson, remoteRequestBodyJson); + + const localResponseBodyJson = result.responseBody?.local || ''; + + const remoteResponseBodyJson = result.responseBody?.remote || ''; + + const responseComparison = compareJsonBodies(localResponseBodyJson, remoteResponseBodyJson); + + return [ + + renderAccordionSection( + 'Environment', + renderSectionRows(environmentRows), + environmentRowsChangedCount > 0, + environmentRowsChangedCount + ), + + renderAccordionSection( + 'Overview', + renderSectionRows(overviewRows), + overviewRowsChangedCount > 0, + overviewRowsChangedCount + ), + + renderAccordionSection( + 'Request', + renderSideBySideJson( + requestComparison, + localRequestBodyJson, + remoteRequestBodyJson + ), + requestComparison.changes > 0, + requestComparison.changes + ), + + renderAccordionSection( + 'Response', + renderSideBySideJson( + responseComparison, + localResponseBodyJson, + remoteResponseBodyJson + ), + responseComparison.changes > 0, + responseComparison.changes + ) + + ].join(''); +} + +function renderSectionRows(rows) { + + const body = + rows.map(row => { + const changed = row.local !== row.remote; + + const rowStyle = changed ? ' style="background:rgba(255,200,0,0.12)"' : ''; + + const valueStyle = changed ? ' style="color:#e74c3c"' : ''; + + return ` + + ${escapeHtml(row.field)} + ${escapeHtml(row.local ?? '')} + ${escapeHtml(row.remote ?? '')} + + `; + }).join(''); + + return ` + + + + + + + + ${body} +
FieldLocalRemote
+ `; +} + +function renderSideBySideJson(comparison, localJson, remoteJson ) { + return ` +
+ +
+ Local + + ${comparison.localError ? `
${comparison.localError}
` : ''} + + ${renderAlignedJson(comparison.local,localJson)} +
+ +
+ Remote + + ${comparison.remoteError ? `
${comparison.remoteError}
` : ''} + + ${renderAlignedJson(comparison.remote, remoteJson )} +
+ +
+ `; +} + +function renderAccordionSection(title, content, expanded = false, changes = 0) { + return ` +
+ +
+ +
+ ${escapeHtml(title)} +
+ +
+ + ${changes > 0 ? `${changes}` : ''} + + + ${expanded ? '-' : '+'} + + +
+
+ +
+ ${content} +
+ +
+ `; +} + +function renderAlignedJson(lines, originalJson) { + + const content = + lines.map(line => { + + const className = line.state ? `diff-line diff-line-${line.state}` : ''; + + const text = line.text ? escapeHtml(line.text) : ' '; + + return `
${text}
`; + }).join(''); + + return ` +
+ + + +
${content}
+
+ `; +} + +function formatCopyValue(json, lines) { + + try { + + return JSON.stringify(JSON.parse(json), null, 2); + + } catch { + + return lines.map(line => line.text).join('\n'); + } +} + +function toggleAccordion(header) { + + const body = header.nextElementSibling; + + const toggle = header.querySelector('.accordion-toggle'); + + body.classList.toggle('open'); + + toggle.textContent = body.classList.contains('open') ? '-' : '+'; +} + +function getChangedCount(rows) { + + return rows.filter(x => x.local !== x.remote).length; +} + +function escapeHtml(value) { + + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} \ No newline at end of file diff --git a/DebugProbe.AspNetCore/Resources/js/debugprobe-ui.js b/DebugProbe.AspNetCore/Resources/js/debugprobe_ui.js similarity index 100% rename from DebugProbe.AspNetCore/Resources/js/debugprobe-ui.js rename to DebugProbe.AspNetCore/Resources/js/debugprobe_ui.js