271 lines
8.1 KiB
TypeScript
271 lines
8.1 KiB
TypeScript
import { createTwoFilesPatch } from 'diff';
|
|
|
|
import { Dashboard } from '@grafana/schema';
|
|
|
|
import { DashboardModel } from '../../state/DashboardModel';
|
|
|
|
export type JSONValue = null | boolean | number | string | JSONArray | JSONObject;
|
|
|
|
export type JSONArray = JSONValue[];
|
|
|
|
export type JSONObject = {
|
|
[key: string]: JSONValue;
|
|
};
|
|
|
|
export function orderProperties(obj1: JSONValue, obj2: JSONValue) {
|
|
// If obj1 and obj2 are the same object, return obj2
|
|
if (obj1 === obj2) {
|
|
return obj2; // No need to order properties, they are already the same
|
|
}
|
|
|
|
if (Array.isArray(obj1) && Array.isArray(obj2)) {
|
|
// They are both arrays
|
|
return orderArrayProperties(obj1, obj2);
|
|
}
|
|
|
|
// Use a type guard to check if they are both non-array objects
|
|
else if (isObject(obj1) && isObject(obj2)) {
|
|
// Both non-array objects
|
|
return orderObjectProperties(obj1, obj2);
|
|
}
|
|
|
|
return obj2;
|
|
}
|
|
|
|
export function isObject(obj: JSONValue): obj is JSONObject {
|
|
return typeof obj === 'object' && !Array.isArray(obj) && obj !== null;
|
|
}
|
|
|
|
export function orderObjectProperties(obj1: JSONObject, obj2: JSONObject) {
|
|
const orderedProperties = Object.keys(obj1);
|
|
const orderedObj2: Record<string, JSONValue> = {};
|
|
|
|
for (const prop of orderedProperties) {
|
|
if (obj2.hasOwnProperty(prop)) {
|
|
if (Array.isArray(obj1[prop]) && Array.isArray(obj2[prop])) {
|
|
// Recursive call orderProperties for arrays
|
|
orderedObj2[prop] = orderProperties(obj1[prop], obj2[prop]);
|
|
} else if (typeof obj1[prop] === 'object' && typeof obj2[prop] === 'object') {
|
|
// Recursively call orderProperties for nested objects
|
|
orderedObj2[prop] = orderProperties(obj1[prop], obj2[prop]);
|
|
} else {
|
|
orderedObj2[prop] = obj2[prop];
|
|
}
|
|
}
|
|
}
|
|
return orderedObj2;
|
|
}
|
|
|
|
export function orderArrayProperties(obj1: JSONArray, obj2: JSONArray) {
|
|
const orderedObj2: JSONValue[] = new Array(obj1.length).fill(undefined);
|
|
|
|
const unseen1 = new Set<number>([...Array(obj1.length).keys()]);
|
|
const unseen2 = new Set<number>([...Array(obj2.length).keys()]);
|
|
|
|
// Loop to match up elements that match exactly
|
|
for (let i = 0; i < obj1.length; i++) {
|
|
if (unseen2.size === 0) {
|
|
break;
|
|
}
|
|
let item1 = obj1[i];
|
|
for (let j = 0; j < obj2.length; j++) {
|
|
if (!unseen2.has(j)) {
|
|
continue;
|
|
}
|
|
let item2 = obj2[j];
|
|
item2 = orderProperties(item1, item2);
|
|
if (JSON.stringify(item1) === JSON.stringify(item2)) {
|
|
unseen1.delete(i);
|
|
unseen2.delete(j);
|
|
orderedObj2[i] = item2;
|
|
}
|
|
}
|
|
}
|
|
|
|
fillBySimilarity(obj1, obj2, orderedObj2, unseen1, unseen2);
|
|
|
|
return orderedObj2.filter((value) => value !== undefined);
|
|
}
|
|
|
|
// Compare all pairings by similarity and match greedily from highest to lowest
|
|
// Similarity is simply measured by number of k:v pairs in fair
|
|
// O(n^2), which is more or less unavoidable
|
|
// Can be made a better match by using levenshtein distance and Hungarian matching
|
|
export function fillBySimilarity(
|
|
// TODO: Investigate not using any
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
obj1: any[],
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
obj2: any[],
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
orderedObj2: any[],
|
|
unseen1: Set<number>,
|
|
unseen2: Set<number>
|
|
): void {
|
|
let rankings: Record<number, number[][]> = {}; // Maps scores to arrays of value pairs
|
|
// Unpacking it because I'm not sure removing items while iterating is safe
|
|
|
|
unseen2.forEach((j: number) => {
|
|
// Index name matches calling function
|
|
let item2 = obj2[j];
|
|
|
|
// If not object, or if array, just push item2 to orderedObj2 and remove j from unseen2
|
|
if (typeof item2 !== 'object' || Array.isArray(item2)) {
|
|
orderedObj2.push(item2);
|
|
unseen2.delete(j);
|
|
return;
|
|
}
|
|
|
|
unseen1.forEach((i: number) => {
|
|
let item1 = obj1[i];
|
|
if (typeof item1 !== 'object' || Array.isArray(item1)) {
|
|
unseen1.delete(i);
|
|
return;
|
|
}
|
|
|
|
let score = 0;
|
|
|
|
for (const key in item1) {
|
|
let val1 = item1[key];
|
|
if (!item2.hasOwnProperty(key)) {
|
|
continue;
|
|
}
|
|
let val2 = item2[key];
|
|
if ((typeof val1 !== 'string' && typeof val1 !== 'number') || typeof val1 !== typeof val2) {
|
|
continue;
|
|
}
|
|
if (val1 === val2) {
|
|
if (key === 'id') {
|
|
score += 1000; // Can probably be caught earlier in the call tree.
|
|
}
|
|
score++;
|
|
}
|
|
}
|
|
|
|
if (score !== 0) {
|
|
if (rankings[score] === undefined) {
|
|
rankings[score] = [];
|
|
}
|
|
rankings[score].push([i, j]);
|
|
}
|
|
});
|
|
});
|
|
|
|
const keys: number[] = Object.keys(rankings).map(Number); // Get keys as an array of numbers
|
|
keys.sort((a, b) => b - a); // Sort in descending order
|
|
|
|
for (const key of keys) {
|
|
let pairs: number[][] = rankings[key];
|
|
for (const pair of pairs) {
|
|
const [i, j] = pair;
|
|
if (unseen1.has(i) && unseen2.has(j)) {
|
|
orderedObj2[i] = obj2[j];
|
|
unseen1.delete(i);
|
|
unseen2.delete(j);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Get anything that had no matches whatsoever
|
|
for (const j of unseen2) {
|
|
orderedObj2.push(obj2[j]);
|
|
}
|
|
}
|
|
|
|
function shortenDiff(diffS: string) {
|
|
const diffLines = diffS.split('\n');
|
|
let headerEnd = diffS[0].startsWith('Index') ? 4 : 3;
|
|
let ret = diffLines.slice(0, headerEnd);
|
|
|
|
const titleOrBracket = /("title"|Title|\{|\}|\[|\])/i;
|
|
for (let i = headerEnd; i < diffLines.length; i++) {
|
|
let line = diffLines[i];
|
|
if (titleOrBracket.test(line)) {
|
|
ret.push(line);
|
|
} else if (line.startsWith('+') || line.startsWith('-')) {
|
|
ret.push(line);
|
|
}
|
|
}
|
|
return ret.join('\n') + '\n';
|
|
}
|
|
|
|
export function removeEmptyFields(input: JSONValue): JSONValue {
|
|
if (input === null || input === '') {
|
|
return null;
|
|
}
|
|
|
|
if (Array.isArray(input)) {
|
|
// Filter out empty values and recursively process the non-empty ones
|
|
const filteredArray = input.map((item) => removeEmptyFields(item)).filter((item) => item !== null);
|
|
|
|
return filteredArray.length > 0 ? filteredArray : null;
|
|
}
|
|
|
|
if (typeof input !== 'object') {
|
|
// If it's not an object, return as is
|
|
return input;
|
|
}
|
|
|
|
// For objects, recursively process each key-value pair
|
|
const result: JSONObject = {};
|
|
for (const key in input) {
|
|
const processedValue = removeEmptyFields(input[key]);
|
|
|
|
if (processedValue !== null) {
|
|
if (Array.isArray(processedValue) && processedValue.length === 0) {
|
|
continue;
|
|
}
|
|
|
|
if (typeof processedValue === 'object') {
|
|
const keys = Object.keys(processedValue);
|
|
if (keys.length === 0) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
result[key] = processedValue;
|
|
}
|
|
}
|
|
|
|
return Object.keys(result).length > 0 ? result : null;
|
|
}
|
|
|
|
function jsonSanitize(obj: Dashboard | DashboardModel | null) {
|
|
return JSON.parse(JSON.stringify(obj, null, 2));
|
|
}
|
|
|
|
export function getDashboardStringDiff(dashboard: DashboardModel): { migrationDiff: string; userDiff: string } {
|
|
let originalDashboard = jsonSanitize(dashboard.getOriginalDashboard());
|
|
let dashboardAfterMigration = jsonSanitize(new DashboardModel(originalDashboard).getSaveModelClone());
|
|
let currentDashboard = jsonSanitize(dashboard.getSaveModelClone());
|
|
|
|
dashboardAfterMigration = removeEmptyFields(orderProperties(originalDashboard, dashboardAfterMigration));
|
|
currentDashboard = removeEmptyFields(orderProperties(dashboardAfterMigration, currentDashboard));
|
|
originalDashboard = removeEmptyFields(originalDashboard);
|
|
|
|
let migrationDiff: string = createTwoFilesPatch(
|
|
'Before migration changes',
|
|
'After migration changes',
|
|
JSON.stringify(originalDashboard, null, 2),
|
|
JSON.stringify(dashboardAfterMigration, null, 2),
|
|
'',
|
|
'',
|
|
{ context: 20 }
|
|
);
|
|
|
|
let userDiff: string = createTwoFilesPatch(
|
|
'Before user changes',
|
|
'After user changes',
|
|
JSON.stringify(dashboardAfterMigration, null, 2),
|
|
JSON.stringify(currentDashboard, null, 2),
|
|
'',
|
|
'',
|
|
{ context: 20 }
|
|
);
|
|
|
|
migrationDiff = shortenDiff(migrationDiff);
|
|
userDiff = shortenDiff(userDiff);
|
|
|
|
return { migrationDiff, userDiff };
|
|
}
|