diff --git a/.gitignore b/.gitignore index 27e006e2..115a5964 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ dist node_modules .vscode resources/reports +resources/*.js tmp/knit tmp/interm /tests/large-payload.json diff --git a/package-lock.json b/package-lock.json index d83ffab0..28ac74e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,9 @@ "koa-router": "12.0.0", "mustache": "4.2.0", "pg": "8.7.3", - "promisify-child-process": "4.1.1" + "promisify-child-process": "4.1.1", + "tslog": "3.3.3", + "uplot": "1.6.22" }, "devDependencies": { "@octokit/types": "6.34.0", @@ -39,6 +41,7 @@ "nodemon": "2.0.19", "prettier": "2.7.1", "source-map-support": "0.5.21", + "terser": "5.14.2", "ts-jest": "28.0.7", "typescript": "4.7.4", "typescript-json-schema": "0.54.0" @@ -1077,6 +1080,30 @@ "node": ">=6.0.0" } }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.2.tgz", + "integrity": "sha512-m7O9o2uR8k2ObDysZYzdfhb08VuEml5oWGiosa1VdaPZ/A6QyPkAJuwN0Q1lhULOf6B7MtQmHENS743hWtCrgw==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, + "node_modules/@jridgewell/source-map/node_modules/@jridgewell/gen-mapping": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz", + "integrity": "sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==", + "dev": true, + "dependencies": { + "@jridgewell/set-array": "^1.0.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.9" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.4.14", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", @@ -2450,8 +2477,7 @@ "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" }, "node_modules/buffer-writer": { "version": "2.0.0", @@ -2658,6 +2684,12 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -5967,7 +5999,6 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -5976,7 +6007,6 @@ "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -6147,6 +6177,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/terser": { + "version": "5.14.2", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.14.2.tgz", + "integrity": "sha512-oL0rGeM/WFQCUd0y2QrWxYnq7tfSuKBiqTjRPWrRgB46WD/kiwHwF8T23z78H6Q6kGCuuHcPB+KULHRdxvVGQA==", + "dev": true, + "dependencies": { + "@jridgewell/source-map": "^0.3.2", + "acorn": "^8.5.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -6311,6 +6359,17 @@ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", "dev": true }, + "node_modules/tslog": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/tslog/-/tslog-3.3.3.tgz", + "integrity": "sha512-lGrkndwpAohZ9ntQpT+xtUw5k9YFV1DjsksiWQlBSf82TTqsSAWBARPRD9juI730r8o3Awpkjp2aXy9k+6vr+g==", + "dependencies": { + "source-map-support": "^0.5.21" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/tsscmp": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", @@ -6484,6 +6543,11 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uplot": { + "version": "1.6.22", + "resolved": "https://registry.npmjs.org/uplot/-/uplot-1.6.22.tgz", + "integrity": "sha512-2jtSb/YHUgtmIUn0+QJjf7ggcJicb5PKe7ijBiRDTPsG/f8F/MFayZ+g6/0kATNkDyF/qQsHJDmCp6cxncg1EQ==" + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -7495,6 +7559,29 @@ "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", "dev": true }, + "@jridgewell/source-map": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.2.tgz", + "integrity": "sha512-m7O9o2uR8k2ObDysZYzdfhb08VuEml5oWGiosa1VdaPZ/A6QyPkAJuwN0Q1lhULOf6B7MtQmHENS743hWtCrgw==", + "dev": true, + "requires": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + }, + "dependencies": { + "@jridgewell/gen-mapping": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz", + "integrity": "sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==", + "dev": true, + "requires": { + "@jridgewell/set-array": "^1.0.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.9" + } + } + } + }, "@jridgewell/sourcemap-codec": { "version": "1.4.14", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", @@ -8648,8 +8735,7 @@ "buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" }, "buffer-writer": { "version": "2.0.0", @@ -8800,6 +8886,12 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, + "commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true + }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -11257,14 +11349,12 @@ "source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" }, "source-map-support": { "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, "requires": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -11386,6 +11476,18 @@ "supports-hyperlinks": "^2.0.0" } }, + "terser": { + "version": "5.14.2", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.14.2.tgz", + "integrity": "sha512-oL0rGeM/WFQCUd0y2QrWxYnq7tfSuKBiqTjRPWrRgB46WD/kiwHwF8T23z78H6Q6kGCuuHcPB+KULHRdxvVGQA==", + "dev": true, + "requires": { + "@jridgewell/source-map": "^0.3.2", + "acorn": "^8.5.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + } + }, "test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -11486,6 +11588,14 @@ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", "dev": true }, + "tslog": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/tslog/-/tslog-3.3.3.tgz", + "integrity": "sha512-lGrkndwpAohZ9ntQpT+xtUw5k9YFV1DjsksiWQlBSf82TTqsSAWBARPRD9juI730r8o3Awpkjp2aXy9k+6vr+g==", + "requires": { + "source-map-support": "^0.5.21" + } + }, "tsscmp": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", @@ -11601,6 +11711,11 @@ "picocolors": "^1.0.0" } }, + "uplot": { + "version": "1.6.22", + "resolved": "https://registry.npmjs.org/uplot/-/uplot-1.6.22.tgz", + "integrity": "sha512-2jtSb/YHUgtmIUn0+QJjf7ggcJicb5PKe7ijBiRDTPsG/f8F/MFayZ+g6/0kATNkDyF/qQsHJDmCp6cxncg1EQ==" + }, "uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", diff --git a/package.json b/package.json index f6355d85..ac036fdb 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,9 @@ "koa-router": "12.0.0", "mustache": "4.2.0", "pg": "8.7.3", - "promisify-child-process": "4.1.1" + "promisify-child-process": "4.1.1", + "tslog": "3.3.3", + "uplot": "1.6.22" }, "engines": { "node": ">=18.4.0" @@ -42,6 +44,7 @@ "nodemon": "2.0.19", "prettier": "2.7.1", "source-map-support": "0.5.21", + "terser": "5.14.2", "ts-jest": "28.0.7", "typescript": "4.7.4", "typescript-json-schema": "0.54.0" @@ -64,7 +67,8 @@ } }, "moduleNameMapper": { - "^(\\.{1,2}/.*)\\.js$": "$1" + "^(\\.{1,2}/.*)\\.js$": "$1", + "/static/uPlot.esm.min.js": "/resources/uPlot.esm.min.js" }, "roots": ["tests/"] }, @@ -72,15 +76,16 @@ "postinstall": "npm run compile", "start": "node --enable-source-maps --experimental-json-modules ./dist/src/index.js", "nodemon": "DEV=true nodemon --enable-source-maps --experimental-json-modules ./dist/src/index.js --watch ./dist/src --watch ./dist/package.json", - "compile": "tsc && npm run prep-resources && npm run prep-reports && npm run prep-static", - "prep-static": "cp dist/src/views/*.js resources", - "prep-resources": "(cd tests; bzip2 -d -f -k large-payload.json.bz2)", + "precompile": "terser --module --ecma 2018 --compress --mangle -o ./resources/uPlot.esm.min.js -- node_modules/uplot/dist/uPlot.esm.js", + "compile": "tsc && npm run prep-reports && npm run prep-static", + "prep-static": "cp dist/src/views/*.js ./resources/", "prep-reports": "mkdir -p tmp/interm tmp/knit resources/reports resources/exp-data", "format": "prettier --config .prettierrc '{src,tests}/**/*.{ts}' --write", "verify": "npm run lint", "lint": "eslint . --ext .ts,.tsx", "update": "git pull && npm install . && pm2 restart 0", "watch": "tsc -w", + "pretest": "(cd tests; bzip2 -d -f -k large-payload.json.bz2)", "test": "node --no-warnings --experimental-vm-modules ./node_modules/jest/bin/jest.js" } } diff --git a/resources/style.css b/resources/style.css index 82773bbb..7062700f 100644 --- a/resources/style.css +++ b/resources/style.css @@ -174,7 +174,7 @@ th[scope=col] { width: auto !important; } -table.benchmark-details tr th:nth-child(1) { +table.benchmark-details > tbody > tr > th:nth-child(1) { min-width: 5em; max-width: 7em; word-break: break-all; @@ -225,50 +225,37 @@ td.warmup-plot { border-top: 0; } -.btn-expand { +.btn-expand, .btn-profile, .btn-timeline, .btn-cmdline, .btn-environment { flex-shrink: 0; - width: 1.25rem; - height: 1.25rem; + width: 20px; + height: 20px; margin-left: auto; content: ""; - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23212529'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e"); background-repeat: no-repeat; - background-size: 1.25rem; + background-size: 16px; +} + +.btn-expand { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23212529'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e"); } .btn-profile { - flex-shrink: 0; - width: 1.25rem; - height: 1.25rem; - margin-left: auto; - content: ""; /* src https://icons.getbootstrap.com/icons/stopwatch/ */ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='currentColor' class='bi bi-stopwatch' viewBox='0 0 16 16'%3E%3Cpath d='M8.5 5.6a.5.5 0 1 0-1 0v2.9h-3a.5.5 0 0 0 0 1H8a.5.5 0 0 0 .5-.5V5.6z'/%3E%3Cpath d='M6.5 1A.5.5 0 0 1 7 .5h2a.5.5 0 0 1 0 1v.57c1.36.196 2.594.78 3.584 1.64a.715.715 0 0 1 .012-.013l.354-.354-.354-.353a.5.5 0 0 1 .707-.708l1.414 1.415a.5.5 0 1 1-.707.707l-.353-.354-.354.354a.512.512 0 0 1-.013.012A7 7 0 1 1 7 2.071V1.5a.5.5 0 0 1-.5-.5zM8 3a6 6 0 1 0 .001 12A6 6 0 0 0 8 3z'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-size: 1rem; +} + +.btn-timeline { + /* src https://icons.getbootstrap.com/icons/clipboard-pulse/ */ + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='currentColor' class='bi bi-clipboard-pulse' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M10 1.5a.5.5 0 0 0-.5-.5h-3a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-1Zm-5 0A1.5 1.5 0 0 1 6.5 0h3A1.5 1.5 0 0 1 11 1.5v1A1.5 1.5 0 0 1 9.5 4h-3A1.5 1.5 0 0 1 5 2.5v-1Zm-2 0h1v1H3a1 1 0 0 0-1 1V14a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V3.5a1 1 0 0 0-1-1h-1v-1h1a2 2 0 0 1 2 2V14a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V3.5a2 2 0 0 1 2-2Zm6.979 3.856a.5.5 0 0 0-.968.04L7.92 10.49l-.94-3.135a.5.5 0 0 0-.895-.133L4.232 10H3.5a.5.5 0 0 0 0 1h1a.5.5 0 0 0 .416-.223l1.41-2.115 1.195 3.982a.5.5 0 0 0 .968-.04L9.58 7.51l.94 3.135A.5.5 0 0 0 11 11h1.5a.5.5 0 0 0 0-1h-1.128L9.979 5.356Z'/%3E%3C/svg%3E"); } .btn-cmdline { - flex-shrink: 0; - width: 1.25rem; - height: 1.25rem; - margin-left: auto; - content: ""; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='currentColor' class='bi bi-terminal' viewBox='0 0 16 16'%3E%3Cpath d='M6 9a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 0 1h-3A.5.5 0 0 1 6 9zM3.854 4.146a.5.5 0 1 0-.708.708L4.793 6.5 3.146 8.146a.5.5 0 1 0 .708.708l2-2a.5.5 0 0 0 0-.708l-2-2z'/%3E%3Cpath d='M2 1a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V3a2 2 0 0 0-2-2H2zm12 1a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1h12z'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-size: 1rem; } .btn-environment { - flex-shrink: 0; - width: 1.25rem; - height: 1.25rem; - margin-left: auto; - content: ""; /* src https://icons.getbootstrap.com/icons/cpu/ */ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='currentColor' class='bi bi-motherboard' viewBox='0 0 16 16'%3E%3Cpath d='M5 0a.5.5 0 0 1 .5.5V2h1V.5a.5.5 0 0 1 1 0V2h1V.5a.5.5 0 0 1 1 0V2h1V.5a.5.5 0 0 1 1 0V2A2.5 2.5 0 0 1 14 4.5h1.5a.5.5 0 0 1 0 1H14v1h1.5a.5.5 0 0 1 0 1H14v1h1.5a.5.5 0 0 1 0 1H14v1h1.5a.5.5 0 0 1 0 1H14a2.5 2.5 0 0 1-2.5 2.5v1.5a.5.5 0 0 1-1 0V14h-1v1.5a.5.5 0 0 1-1 0V14h-1v1.5a.5.5 0 0 1-1 0V14h-1v1.5a.5.5 0 0 1-1 0V14A2.5 2.5 0 0 1 2 11.5H.5a.5.5 0 0 1 0-1H2v-1H.5a.5.5 0 0 1 0-1H2v-1H.5a.5.5 0 0 1 0-1H2v-1H.5a.5.5 0 0 1 0-1H2A2.5 2.5 0 0 1 4.5 2V.5A.5.5 0 0 1 5 0zm-.5 3A1.5 1.5 0 0 0 3 4.5v7A1.5 1.5 0 0 0 4.5 13h7a1.5 1.5 0 0 0 1.5-1.5v-7A1.5 1.5 0 0 0 11.5 3h-7zM5 6.5A1.5 1.5 0 0 1 6.5 5h3A1.5 1.5 0 0 1 11 6.5v3A1.5 1.5 0 0 1 9.5 11h-3A1.5 1.5 0 0 1 5 9.5v-3zM6.5 6a.5.5 0 0 0-.5.5v3a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-3a.5.5 0 0 0-.5-.5h-3z'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-size: 1rem; } .card-columns { @@ -327,3 +314,177 @@ td.warmup-plot { margin-left: -70px; } } + +.uplot { + margin-left: auto; + margin-right: auto; +} + +.u-legend tr { + display: none !important; +} + +.u-legend tr:nth-child(1), .u-legend tr:nth-child(3), .u-legend tr:nth-child(6) { + display: table-row !important; +} + +.u-legend tr.hidden.u-series { + display: none !important; +} + +.u-legend td, .u-legend th { + border-top: unset; +} + +.u-value { + min-width: 150px; +} + +.u-series { + text-align: right; +} + +.uplot, +.uplot *, +.uplot *::before, +.uplot *::after { + box-sizing: border-box; +} + +.uplot { + font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + line-height: 1.5; + width: min-content; +} + +.u-title { + text-align: center; + font-size: 18px; + font-weight: bold; +} + +.u-wrap { + position: relative; + user-select: none; +} + +.u-over, +.u-under { + position: absolute; +} + +.u-under { + overflow: hidden; +} + +.uplot canvas { + display: block; + position: relative; + width: 100%; + height: 100%; +} + +.u-axis { + position: absolute; +} + +.u-legend { + font-size: 14px; + margin: auto; + text-align: center; +} + +.u-inline tr { + margin-right: 16px; +} + +.u-legend th { + font-weight: 600; +} + +.u-legend .u-marker { + width: 1em; + height: 1em; + margin-right: 4px; + background-clip: padding-box !important; +} + +.u-marker { + display: block; + float: right; + margin-top: 4px; + margin-left: 5px; +} + +.u-label { + display: inline; +} + +.u-inline.u-live th::after { + content: ":"; +} + +.u-inline:not(.u-live) .u-value { + display: none; +} + +.u-series > * { + padding: 4px; +} + +.u-series th { + cursor: pointer; +} + +.u-legend .u-off > * { + opacity: 0.3; +} + +.u-select { + background: rgba(0,0,0,0.07); + position: absolute; + pointer-events: none; +} + +.u-cursor-x, +.u-cursor-y { + position: absolute; + left: 0; + top: 0; + pointer-events: none; + will-change: transform; + z-index: 100; +} + +.u-hz .u-cursor-x, +.u-vt .u-cursor-y { + height: 100%; + border-right: 1px dashed #607D8B; +} + +.u-hz .u-cursor-y, +.u-vt .u-cursor-x { + width: 100%; + border-bottom: 1px dashed #607D8B; +} + +.u-cursor-pt { + position: absolute; + top: 0; + left: 0; + border-radius: 50%; + border: 0 solid; + pointer-events: none; + will-change: transform; + z-index: 100; + /* this has to be !important since we set inline "background" shorthand */ + background-clip: padding-box !important; +} + +.u-axis.u-off, +.u-select.u-off, +.u-cursor-x.u-off, +.u-cursor-y.u-off, +.u-cursor-pt.u-off { + display: none; +} diff --git a/src/api.ts b/src/api.ts index 4a8965d7..ef5ce152 100644 --- a/src/api.ts +++ b/src/api.ts @@ -157,3 +157,66 @@ export interface VersionInfo { name: string; version: string; } + +export interface TimelineRequest { + /** commit id for baseline */ + baseline: string; + + /** commit id for change */ + change: string; + + /** benchmark name */ + b: string; + + /** exe name */ + e: string; + + /** suite name */ + s: string; + + /** varValue */ + v?: string; + + /** cores */ + c?: string; + + /** input size */ + i?: string; + + /** extra args */ + ea?: string; +} + +export interface TimelineResponse { + baseBranchName: string; + changeBranchName: string; + baseTimestamp: number | null; + changeTimestamp: number | null; + data: PlotData; +} + +export type PlotData = [ + number[] /** UNIX time stamps */, + + /** Baseline Branch */ + + /** bootstrap confidence interval, 95th, low, millisecond values */ + (number | null)[], + + /** median, millisecond values */ + (number | null)[], + + /** bootstrap confidence interval, 95th, high, millisecond values */ + (number | null)[], + + /** Change Branch */ + + /** bootstrap confidence interval, 95th, low, millisecond values */ + (number | null)[], + + /** median, millisecond values */ + (number | null)[], + + /** bootstrap confidence interval, 95th, high, millisecond values */ + (number | null)[] +]; diff --git a/src/dashboard.ts b/src/dashboard.ts index 25779e06..538a0df2 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -6,6 +6,7 @@ import { BenchmarkCompletion } from './api.js'; import { GitHub } from './github.js'; import { robustPath, siteConfig } from './util.js'; import { getDirname } from './util.js'; +import { log } from './logging.js'; const __dirname = getDirname(import.meta.url); @@ -196,7 +197,7 @@ export function startReportGeneration( ]; const cmd = 'Rscript'; - console.log(`Generate Report: ${cmd} '${args.join(`' '`)}'`); + log.debug(`Generate Report: ${cmd} '${args.join(`' '`)}'`); return execFile(cmd, args, { cwd: reportOutputFolder }); } @@ -315,7 +316,7 @@ export async function dashCompare( }) .catch(async (e) => { const { stdout, stderr } = e; - console.error(`Report generation error: ${e}`); + log.error('Report generation error', e); reportGeneration.set(reportId, { e, stdout, @@ -405,7 +406,7 @@ export async function dashGetExpData( expDataFile ]; - console.log( + log.debug( `Prepare Data for Download:` + `${__dirname}/../../src/stats/get-exp-data.R ${args.join(' ')}` ); @@ -424,7 +425,7 @@ export async function dashGetExpData( await completeRequest(start, db, 'prep-exp-data'); }) .catch(async (error) => { - console.error(`Data preparation failed: ${error}`); + log.error('Data preparation failed', error); expDataPreparation.set(expDataId, { error, stdout: error.stdout, diff --git a/src/db.ts b/src/db.ts index 4fcf3e2d..41a3872d 100644 --- a/src/db.ts +++ b/src/db.ts @@ -11,12 +11,16 @@ import { Criterion as ApiCriterion, DataPoint as ApiDataPoint, ProfileData as ApiProfileData, - BenchmarkCompletion + BenchmarkCompletion, + TimelineRequest, + TimelineResponse, + PlotData } from './api'; import pg, { PoolConfig, QueryConfig, QueryResultRow } from 'pg'; import { SingleRequestOnly } from './single-requester.js'; import { startRequest, completeRequest } from './perf-tracker.js'; import { getDirname } from './util.js'; +import { assert, log } from './logging.js'; const __dirname = getDirname(import.meta.url); @@ -180,7 +184,7 @@ export abstract class Database { private readonly timelineEnabled: boolean; /** Number of bootstrap samples to take for timeline. */ - private readonly numReplicates: number; + private readonly numBootstrapSamples: number; private readonly executors: Map; private readonly suites: Map; @@ -327,19 +331,32 @@ export abstract class Database { JOIN Source s ON t.sourceId = s.id WHERE p.name = $1 AND s.commitid = $2 - OR s.commitid = $3` + OR s.commitid = $3`, + + fetchBranchNamesForChange: { + name: 'fetchBranchNamesForChange', + text: `SELECT DISTINCT branchOrTag, s.commitId + FROM Source s + JOIN Trial tr ON tr.sourceId = s.id + JOIN Experiment e ON tr.expId = e.id + JOIN Project p ON p.id = e.projectId + WHERE + p.name = $1 AND + (s.commitid = $2 OR s.commitid = $3)`, + values: [] + } }; private static readonly batchN = 50; constructor( config: PoolConfig, - numReplicates = 1000, + numBootstrapSamples = 1000, timelineEnabled = false ) { - console.assert(config !== undefined); + assert(config !== undefined); this.dbConfig = config; - this.numReplicates = numReplicates; + this.numBootstrapSamples = numBootstrapSamples; this.timelineEnabled = timelineEnabled; this.executors = new Map(); this.suites = new Map(); @@ -464,7 +481,7 @@ export abstract class Database { result = await this.query(insertQ, insertVals); } - console.assert(result.rowCount === 1); + assert(result.rowCount === 1); cache.set(cacheKey, result.rows[0]); return result.rows[0]; } @@ -817,7 +834,7 @@ export abstract class Database { const crit = run[r.criterion]; - console.assert( + assert( !(r.inv in crit), `${r.runid}, ${r.criterion}, ${r.inv} in ${JSON.stringify(crit)}` ); @@ -1049,7 +1066,7 @@ export abstract class Database { const q = this.queries.insertMeasurement; // [runId, trialId, invocation, iteration, critId, value]; q.values = values; - return (await this.query(this.queries.insertMeasurement)).rowCount; + return (await this.query(q)).rowCount; } public async recordProfile( @@ -1061,7 +1078,7 @@ export abstract class Database { ): Promise { const q = this.queries.insertProfile; q.values = [runId, trialId, invocation, numIterations, value]; - return (await this.query(this.queries.insertProfile)).rowCount; + return (await this.query(q)).rowCount; } public async recordTimelineJob(values: number[]): Promise { @@ -1089,7 +1106,7 @@ export abstract class Database { this.dbConfig.database, this.dbConfig.user, this.dbConfig.password, - this.numReplicates + this.numBootstrapSamples ]; const start = startRequest(); execFile( @@ -1099,7 +1116,7 @@ export abstract class Database { async (errorCode, stdout, stderr) => { function handleResult() { if (errorCode) { - console.log(`timeline.R failed: ${errorCode} + log.debug(`timeline.R failed: ${errorCode} Stdout: ${stdout} @@ -1118,6 +1135,199 @@ export abstract class Database { }); return prom; } + + public async getBranchNames( + projectName: string, + base: string, + change: string + ): Promise { + const q = { ...this.queries.fetchBranchNamesForChange }; + q.values = [projectName, base, change]; + const result = await this.query(q); + + if (result.rowCount < 1) { + return null; + } + + if (result.rowCount == 1) { + return { + baseBranchName: result.rows[0].branchortag, + changeBranchName: result.rows[0].branchortag + }; + } + + assert(result.rowCount == 2); + if (result.rows[0].commitid == base) { + return { + baseBranchName: result.rows[0].branchortag, + changeBranchName: result.rows[1].branchortag + }; + } + + assert(result.rows[0].commitid == change); + return { + baseBranchName: result.rows[1].branchortag, + changeBranchName: result.rows[0].branchortag + }; + } + + public async getTimelineData( + projectName: string, + request: TimelineRequest + ): Promise { + const branches = await this.getBranchNames( + projectName, + request.baseline, + request.change + ); + + if (branches === null) { + return null; + } + + const q = this.constructTimelineQuery(projectName, branches, request); + const result = await this.query(q); + + if (result.rowCount < 1) { + return null; + } + + const data = this.convertToTimelineResponse( + result.rows, + branches.baseBranchName, + branches.changeBranchName + ); + return data; + } + + private convertToTimelineResponse( + rows: any[], + baseBranchName: string, + changeBranchName: string + ): TimelineResponse { + let baseTimestamp: number | null = null; + let changeTimestamp: number | null = null; + const data: PlotData = [ + [], // time stamp + [], // baseline bci low + [], // baseline median + [], // baseline bci high + [], // change bci low + [], // change median + [] // change bci high + ]; + + for (const row of rows) { + data[0].push(row.starttime); + if (row.branch == baseBranchName) { + if (row.iscurrent) { + baseTimestamp = row.starttime; + } + data[1].push(row.bci95low); + data[2].push(row.median); + data[3].push(row.bci95up); + data[4].push(null); + data[5].push(null); + data[6].push(null); + } else { + if (row.iscurrent) { + changeTimestamp = row.starttime; + } + data[1].push(null); + data[2].push(null); + data[3].push(null); + data[4].push(row.bci95low); + data[5].push(row.median); + data[6].push(row.bci95up); + } + } + + return { + baseBranchName, + changeBranchName, + baseTimestamp, + changeTimestamp, + data + }; + } + + private constructTimelineQuery( + projectName: string, + branches: { baseBranchName: string; changeBranchName: string }, + request: TimelineRequest + ): QueryConfig { + let sql = ` + SELECT + extract(epoch from tr.startTime at time zone 'UTC')::int as startTime, + s.branchOrTag as branch, s.commitid IN ($1, $2) as isCurrent, + ti.median, ti.bci95low, ti.bci95up + FROM Timeline ti + JOIN Trial tr ON tr.id = ti.trialId + JOIN Source s ON tr.sourceId = s.id + JOIN Experiment e ON tr.expId = e.id + JOIN Project p ON p.id = e.projectId + JOIN Run r ON r.id = ti.runId + JOIN Executor exe ON exe.id = r.execId + JOIN Benchmark b ON b.id = r.benchmarkId + JOIN Suite su ON su.id = r.suiteId + JOIN Criterion c ON ti.criterion = c.id + WHERE + s.branchOrTag IN ($3, $4) AND + p.name = $5 AND + b.name = $6 AND + su.name = $7 AND + exe.name = $8 AND + c.name = 'total' + ::ADDITIONAL-PARAMETERS:: + ORDER BY tr.startTime ASC; + `; + + let additionalParameters = ''; + let storedQueryName = 'get-timeline-data-bpbs-'; + + const parameters = [ + request.baseline, + request.change, + branches.baseBranchName, + branches.changeBranchName, + projectName, + request.b, + request.s, + request.e + ]; + + if (request.v) { + parameters.push(request.v); + additionalParameters += 'AND r.varValue = $' + parameters.length + ' '; + storedQueryName += 'v'; + } + + if (request.c) { + parameters.push(request.c); + additionalParameters += 'AND r.cores = $' + parameters.length + ' '; + storedQueryName += 'c'; + } + + if (request.i) { + parameters.push(request.i); + additionalParameters += 'AND r.inputSize = $' + parameters.length + ' '; + storedQueryName += 'i'; + } + + if (request.ea) { + parameters.push(request.ea); + additionalParameters += 'AND r.extraArgs = $' + parameters.length + ' '; + storedQueryName += 'ea'; + } + + sql = sql.replace('::ADDITIONAL-PARAMETERS::', additionalParameters); + + return { + name: storedQueryName, + text: sql, + values: parameters + }; + } } export class DatabaseWithPool extends Database { diff --git a/src/index.ts b/src/index.ts index 5c2b0d14..0ebc3a48 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,7 @@ import Koa from 'koa'; import koaBody from 'koa-body'; import Router from 'koa-router'; import { DatabaseWithPool } from './db.js'; -import { BenchmarkData, BenchmarkCompletion } from './api.js'; +import { BenchmarkData, BenchmarkCompletion, TimelineRequest } from './api.js'; import { createValidator } from './api-validator.js'; import { ValidateFunction } from 'ajv'; @@ -31,6 +31,7 @@ import { processTemplate } from './templates.js'; import { dbConfig, robustPath, siteConfig } from './util.js'; import { createGitHubClient } from './github.js'; import { getDirname } from './util.js'; +import { log } from './logging.js'; const __dirname = getDirname(import.meta.url); @@ -38,7 +39,7 @@ const packageJson = JSON.parse( readFileSync(robustPath('../package.json'), 'utf-8') ); -console.log('Starting ReBenchDB Version ' + packageJson.version); +log.info('Starting ReBenchDB Version ' + packageJson.version); const port = process.env.PORT || 33333; @@ -88,7 +89,7 @@ router.get('/rebenchdb/get-exp-data/:expId', async (ctx) => { ctx.type = 'html'; ctx.set('Cache-Control', 'no-cache'); } else { - console.log(data.downloadUrl); + log.debug(data.downloadUrl); ctx.redirect(data.downloadUrl); } @@ -213,31 +214,57 @@ router.post( } ); +router.post( + '/rebenchdb/dash/:projectName/timelines', + koaBody(), + async (ctx) => { + const timelineRequest = ctx.request.body; + const result = await db.getTimelineData( + ctx.params.projectName, + timelineRequest + ); + if (result === null) { + ctx.body = { error: 'Requested data was not found' }; + ctx.status = 404; + } else { + ctx.body = result; + ctx.status = 200; + } + ctx.type = 'json'; + } +); + if (DEV) { router.get(`${siteConfig.staticUrl}/:filename*`, async (ctx) => { - console.log(`serve ${ctx.params.filename}`); + const filename = ctx.params.filename; + log.debug(`serve ${filename}`); let path: string; + // TODO: robustPath? - if (ctx.params.filename.endsWith('.css')) { + if (filename.endsWith('.css')) { ctx.type = 'css'; - path = `${__dirname}/../../resources/${ctx.params.filename}`; - } else if (ctx.params.filename.endsWith('.js')) { + path = `${__dirname}/../../resources/${filename}`; + } else if (filename.endsWith('.js')) { ctx.type = 'application/javascript'; - path = `${__dirname}/views/${ctx.params.filename}`; - } else if (ctx.params.filename.endsWith('.map')) { + if (filename.includes('uPlot')) { + path = `${__dirname}/../../resources/${filename}`; + } else { + path = `${__dirname}/views/${filename}`; + } + } else if (filename.endsWith('.map')) { ctx.type = 'application/json'; - path = `${__dirname}/views/${ctx.params.filename}`; - } else if (ctx.params.filename.endsWith('.svg')) { + path = `${__dirname}/views/${filename}`; + } else if (filename.endsWith('.svg')) { ctx.type = 'image/svg+xml'; - path = `${__dirname}/../../resources/${ctx.params.filename}`; + path = `${__dirname}/../../resources/${filename}`; } else { - throw new Error(`Unsupported file type ${ctx.params.filename}`); + throw new Error(`Unsupported file type. Filename: ${filename}`); } ctx.body = readFileSync(path); }); router.get(`/src/views/:filename*`, async (ctx) => { - console.log(`serve ${ctx.params.filename}`); + log.debug(`serve ${ctx.params.filename}`); let path: string; if (ctx.params.filename.endsWith('.ts')) { ctx.type = 'application/typescript'; @@ -249,7 +276,7 @@ if (DEV) { }); router.get(`${siteConfig.staticUrl}/exp-data/:filename`, async (ctx) => { - console.log(`serve ${ctx.params.filename}`); + log.debug(`serve ${ctx.params.filename}`); ctx.body = readFileSync( `${__dirname}/../../resources/exp-data/${ctx.params.filename}` ); @@ -261,7 +288,7 @@ if (DEV) { router.get( `${siteConfig.reportsUrl}/:change/figure-html/:filename`, async (ctx) => { - console.log(`serve ${ctx.params.filename}`); + log.debug(`serve ${ctx.params.filename}`); const reportPath = `${__dirname}/../../resources/reports`; ctx.body = readFileSync( `${reportPath}/${ctx.params.change}/figure-html/${ctx.params.filename}` @@ -288,13 +315,12 @@ const validateFn: ValidateFunction = DEBUG ? createValidator() : undefined; function validateSchema(data: BenchmarkData, ctx: Router.IRouterContext) { const result = validateFn(data); if (!result) { - console.log('Data validation failed.'); - console.error(validateFn.errors); + log.error('Data validation failed.', validateFn.errors); ctx.status = 500; ctx.body = `Request does not validate: ${validateFn.errors}`; } else { - console.log('Data validated successfully.'); + log.debug('Data validated successfully.'); } } @@ -325,16 +351,16 @@ router.put( const recordedRuns = await db.recordMetaDataAndRuns(data); db.recordAllData(data) .then(([recMs, recPs]) => - console.log( + log.info( // eslint-disable-next-line max-len `/rebenchdb/results: stored ${recMs} measurements, ${recPs} profiles` ) ) .catch((e) => { - console.error( - `/rebenchdb/results failed to store measurements: ${e}` + log.error( + '/rebenchdb/results failed to store measurements:', + e.stack ); - console.error(e.stack); }); ctx.body = @@ -344,7 +370,7 @@ router.put( } catch (e: any) { ctx.status = 500; ctx.body = `${e.stack}`; - console.log(e.stack); + log.error(e, e.stack); } await completeRequest(start, db, 'put-results'); @@ -353,7 +379,7 @@ router.put( const github = createGitHubClient(siteConfig); if (github === null) { - console.log( + log.info( 'Reporting to GitHub is not yet enabled.' + ' Make sure GITHUB_APP_ID and GITHUB_PK are set to enable it.' ); @@ -377,7 +403,7 @@ router.put('/rebenchdb/completion', koaBody(), async (ctx) => { try { await reportCompletion(dbConfig, db, github, data); - console.log( + log.debug( `/rebenchdb/completion: ${data.projectName}` + `${data.experimentName} was completed` ); @@ -387,8 +413,7 @@ router.put('/rebenchdb/completion', koaBody(), async (ctx) => { } catch (e: any) { ctx.status = 500; ctx.body = `Failed to record completion: ${e}\n${e.stack}`; - console.error(`/rebenchdb/completion failed to record completion: ${e}`); - console.log(e.stack); + log.error('/rebenchdb/completion failed to record completion:', e); } }); @@ -396,12 +421,12 @@ app.use(router.routes()); app.use(router.allowedMethods()); (async () => { - console.log('Initialize Database'); + log.info('Initialize Database'); try { await db.initializeDatabase(); } catch (e: any) { if (e.code == 'ECONNREFUSED') { - console.log( + log.error( `Unable to connect to database on port ${e.address}:${e.port}\n` + 'ReBenchDB requires a Postgres database to work.' ); @@ -411,6 +436,6 @@ app.use(router.allowedMethods()); initPerfTracker(); - console.log(`Starting server on http://localhost:${port}`); + log.info(`Starting server on http://localhost:${port}`); app.listen(port); })(); diff --git a/src/logging.ts b/src/logging.ts new file mode 100644 index 00000000..52e85160 --- /dev/null +++ b/src/logging.ts @@ -0,0 +1,23 @@ +import { Logger } from 'tslog'; + +export const log = new Logger({ name: 'index' }); + +if ('DEV' in process.env ? process.env.DEV === 'true' : false) { + log.setSettings({ minLevel: 'trace' }); +} else { + log.setSettings({ minLevel: 'info' }); +} + +export function assert( + condition: boolean, + message: string | undefined = undefined +): void { + if (!condition) { + const stack = new Error().stack; + if (message) { + log.error('Assertion failed', message, stack); + } else { + log.error('Assertion failed', stack); + } + } +} diff --git a/src/views/compare.html b/src/views/compare.html index 612d2645..daff69e3 100644 --- a/src/views/compare.html +++ b/src/views/compare.html @@ -2,6 +2,9 @@ ReBenchDB for {{project}}: Comparing {{baselineHash6}} with {{changeHash6}} + + + {{{headerHtml}}} diff --git a/src/views/compare.ts b/src/views/compare.ts index f497e053..255ae343 100644 --- a/src/views/compare.ts +++ b/src/views/compare.ts @@ -1,3 +1,5 @@ +import { renderComparisonTimelinePlot } from './plots.js'; + function determineAndDisplaySignificance() { const val = $('#significance').val(); displaySignificance(val); @@ -73,9 +75,9 @@ function createEntry(e, profId, counter) { return entryHtml; } -function fetchProfile(change, runId, trialId, jqInsert) { +function fetchProfile(projectName: string, change, runId, trialId, jqInsert) { const profileP = fetch( - `/rebenchdb/dash/{{project}}/profiles/${runId}/${trialId}` + `/rebenchdb/dash/${projectName}/profiles/${runId}/${trialId}` ); profileP.then(async (profileResponse) => { const profileData = await profileResponse.json(); @@ -105,6 +107,7 @@ function fetchProfile(change, runId, trialId, jqInsert) { } function insertProfiles(e) { + const projectName = $('#project-name').attr('value'); const jqButton = $(e.target); let profileInsertTarget = jqButton.parent().parent(); jqButton.remove(); @@ -118,7 +121,7 @@ function insertProfiles(e) { ); profileInsertTarget.after(jqInsert); profileInsertTarget = jqInsert; - fetchProfile(change, runId, trialId, jqInsert); + fetchProfile(projectName, change, runId, trialId, jqInsert); } } @@ -196,15 +199,65 @@ function initializeFilters(): void { ); } +async function fetchPost(url: string, data: any): Promise { + const response = await fetch(url, { + method: 'POST', + mode: 'same-origin', + cache: 'no-cache', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + redirect: 'follow', + referrerPolicy: 'no-referrer', + body: JSON.stringify(data) + }); + return response.json(); +} + +async function insertTimeline(e): Promise { + const projectName = $('#project-name').attr('value'); + const jqButton = $(e.target); + + const baseHash = $('#baseHash').attr('value'); + const changeHash = $('#changeHash').attr('value'); + const dataId = jqButton.data('content'); + dataId.baseline = baseHash; + dataId.change = changeHash; + + const insert = + '
'; + const jqInsert = $(insert); + jqInsert.insertAfter(jqButton.parent().parent()); + jqButton.remove(); + + await fetchTimelineData( + projectName, + dataId, + jqInsert.find('.plot-container') + ); +} + +async function fetchTimelineData( + projectName: string, + dataId, + jqInsert +): Promise { + const response = await fetchPost( + `/rebenchdb/dash/${projectName}/timelines`, + dataId + ); + renderComparisonTimelinePlot(response, jqInsert); +} + $(() => { $('#significance').on('input', determineAndDisplaySignificance); determineAndDisplaySignificance(); - $('#show-refresh-form').click(() => $('input[name=password]').show()); + $('#show-refresh-form').on('click', () => $('input[name=password]').show()); ($)('.btn-popover').popover({ html: true, placement: 'top' }); - $('.btn-expand').click(insertWarmupPlot); - $('.btn-profile').click(insertProfiles); + $('.btn-expand').on('click', insertWarmupPlot); + $('.btn-profile').on('click', insertProfiles); + $('.btn-timeline').on('click', insertTimeline); const headlinesForTablesWithWarmupPlots = $('table:has(button.btn-expand)') .prev() @@ -213,7 +266,7 @@ $(() => { `` ); const buttons = headlinesForTablesWithWarmupPlots.find('.btn-expand'); - buttons.click((e) => { + buttons.on('click', (e) => { const expandButtons = $(e.target) .parent() .next() diff --git a/src/views/plots.ts b/src/views/plots.ts index 8c94f589..438be216 100644 --- a/src/views/plots.ts +++ b/src/views/plots.ts @@ -1,5 +1,7 @@ +import type { PlotData, TimelineResponse } from 'api'; import type { Data } from 'plotly.js'; declare const Plotly: any; +import uPlot from '/static/uPlot.esm.min.js'; function simpleSlug(str) { return str.replace(/[\W_]+/g, ''); @@ -149,3 +151,154 @@ export function renderTimelinePlot(key: any, results: any): void { Plotly.newPlot(key, traces, layout); } + +function formatMs(_, val) { + if (val == null) { + return ''; + } + return val.toFixed(0) + 'ms'; +} + +function seriesConfig( + branchName: string, + metric: string, + color: string, + width: number, + largerPoint = false, + largerPointFill: string | undefined = undefined +) { + let label; + if (branchName !== '' && metric !== '') { + label = `${branchName} ${metric}`; + } else { + label = ''; + } + + const cfg: any = { + label, + stroke: color, + value: formatMs, + width: width + }; + + if (largerPoint) { + cfg.points = { + size: 9, + fill: largerPointFill + }; + } + + return cfg; +} + +const baselineColor = '#729fcf'; +const baselineLight = '#97c4f0'; + +const changeColor = '#e9b96e'; +const changeLight = '#efd0a7'; + +function computeAxisLabelSpace(self, axisTickLabels, axisIdx, cycleNum) { + const axis = self.axes[axisIdx]; + + // bail out, force convergence + if (cycleNum > 1) { + return axis._size; + } + + let axisSize = axis.ticks.size + axis.gap; + + // find longest value + const longest = (axisTickLabels ?? []).reduce( + (acc, label) => (label.length > acc.length ? label : acc), + '' + ); + + if (longest != '') { + self.ctx.font = axis.font[0]; + axisSize += self.ctx.measureText(longest).width / devicePixelRatio; + } + + return Math.ceil(axisSize); +} + +function addDataSeriesToHighlightResult( + data: PlotData, + timestampToHighlight: number, + idxOfData: number +): void { + const seriesForCurrentBase: (number | null)[] = []; + data.push(seriesForCurrentBase); + for (const i in data[0]) { + const ts = data[0][i]; + if (ts == timestampToHighlight) { + const currentValue = data[idxOfData][i]; + seriesForCurrentBase.push(currentValue); + } else { + seriesForCurrentBase.push(null); + } + } +} + +export function renderComparisonTimelinePlot( + response: TimelineResponse, + jqInsert: any +): any { + const series = [ + {}, + seriesConfig(response.baseBranchName, 'BCI 95th, low', baselineLight, 1), + seriesConfig(response.baseBranchName, 'Median', baselineColor, 2), + seriesConfig(response.baseBranchName, 'BCI 95th, high', baselineLight, 1), + seriesConfig(response.changeBranchName, 'BCI 95th, low', changeLight, 1), + seriesConfig(response.changeBranchName, 'Median', changeColor, 2), + seriesConfig(response.changeBranchName, 'BCI 95th, high', changeLight, 1) + ]; + + if (response.baseTimestamp !== null && response.changeTimestamp !== null) { + addDataSeriesToHighlightResult(response.data, response.baseTimestamp, 2); + series.push(seriesConfig('', '', baselineColor, 1, true, baselineLight)); + } + let noChangeDataSeries = false; + if (response.changeTimestamp !== null || response.baseTimestamp !== null) { + let ts; + let dataIndex; + if (response.changeTimestamp !== null) { + ts = response.changeTimestamp; + dataIndex = 5; + } else { + ts = response.baseTimestamp; + dataIndex = 2; + noChangeDataSeries = true; + } + addDataSeriesToHighlightResult(response.data, ts, dataIndex); + series.push(seriesConfig('', '', changeColor, 1, true, changeLight)); + } + + const options = { + width: 576, + height: 240, + title: 'Runtime in ms', + tzDate: (ts: number) => uPlot.tzDate(new Date(ts * 1000), 'UTC'), + scales: { x: {}, y: {} }, + series, + bands: [ + { series: [1, 2], fill: baselineLight, dir: 1 }, + { series: [2, 3], fill: baselineLight, dir: 1 }, + { series: [4, 5], fill: changeLight, dir: 1 }, + { series: [5, 6], fill: changeLight, dir: 1 } + ], + axes: [ + {}, + { + values: (_, vals) => vals.map((v) => v + 'ms'), + size: computeAxisLabelSpace + } + ] + }; + + const plot = new uPlot(options, response.data, jqInsert[0]); + + if (noChangeDataSeries) { + jqInsert.find('.u-legend tr:nth-child(6)').addClass('hidden'); + } + return plot; +} diff --git a/src/views/somns.R b/src/views/somns.R index 21e63863..ea47aa76 100644 --- a/src/views/somns.R +++ b/src/views/somns.R @@ -408,9 +408,9 @@ perf_diff_table_es <- function(data_es, stats_es, warmup_es, profiles_es, start_ # data_ea <- data_b for (b in levels(data_es$bench)) { data_b <- data_es |> filter(bench == b) |> droplevels() - for (v in levels(data_b$varvalue)) { data_v <- data_b |> filter(varvalue == v) |> droplevels() - for (c in levels(data_v$cores)) { data_c <- data_v |> filter(cores == c) |> droplevels() - for (i in levels(data_c$inputsize)) { data_i <- data_c |> filter(inputsize == i) |> droplevels() + for (v in levels(data_b$varvalue)) { data_v <- data_b |> filter(varvalue == v) |> droplevels() + for (c in levels(data_v$cores)) { data_c <- data_v |> filter(cores == c) |> droplevels() + for (i in levels(data_c$inputsize)) { data_i <- data_c |> filter(inputsize == i) |> droplevels() for (ea in levels(data_i$extraargs)) { data_ea <- data_i |> filter(extraargs == ea) |> droplevels() for (en in levels(data_ea$envid)) { data_en <- data_ea |> filter(envid == en) |> droplevels() @@ -429,7 +429,7 @@ perf_diff_table_es <- function(data_es, stats_es, warmup_es, profiles_es, start_ # capture the beginning of the path but leave the last element of it # this regex is also used in render.js's renderBenchmark() function - cmdline <- str_replace_all(data_i$cmdline[[1]], "^([^\\s]*)\\/([^\\s]+\\s.*$)", "\\2") + cmdline <- str_replace_all(data_en$cmdline[[1]], "^([^\\s]*)\\/([^\\s]+\\s.*$)", "\\2") # format all environment information into a single string if (!is.null(environments)) { @@ -577,6 +577,20 @@ perf_diff_table_es <- function(data_es, stats_es, warmup_es, profiles_es, start_ } } + benchmark_id_json <- paste0( + '{"b":"', b, + '","e":"', levels(data_en$exe), + '","s":"', levels(data_en$suite), '"') + + if (length(levels(data_b$varvalue)) > 1) { benchmark_id_json <- paste0(benchmark_id_json, ',"v":"', v, '"') } + if (length(levels(data_v$cores)) > 1) { benchmark_id_json <- paste0(benchmark_id_json, ',"c":"', c, '"') } + if (length(levels(data_c$inputsize)) > 1) { benchmark_id_json <- paste0(benchmark_id_json, ',"i":"', i, '"') } + if (length(levels(data_i$extraargs)) > 1) { benchmark_id_json <- paste0(benchmark_id_json, ',"ea":"', ea, '"') } + + benchmark_id_json <- paste0(benchmark_id_json, '}') + + out('\n') + out(''); out('\n') } else { diff --git a/tests/api.test.ts b/tests/api.test.ts index d96e1c8e..418468af 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -2,6 +2,7 @@ import { readFileSync } from 'fs'; import { ValidateFunction } from 'ajv'; import { createValidator } from '../src/api-validator.js'; import { getDirname } from '../src/util.js'; +import { log } from '../src/logging.js'; const __dirname = getDirname(import.meta.url); @@ -23,7 +24,7 @@ describe('Ensure Test Payloads conform to API', () => { const result = validateFn(testData); if (!result) { - console.error(validateFn.errors); + log.error(validateFn.errors); } expect(result).toBeTruthy(); }); @@ -35,7 +36,7 @@ describe('Ensure Test Payloads conform to API', () => { const result = validateFn(testData); if (!result) { - console.error(validateFn.errors); + log.error(validateFn.errors); } expect(result).toBeTruthy(); }); @@ -47,7 +48,7 @@ describe('Ensure Test Payloads conform to API', () => { const result = validateFn(testData); if (!result) { - console.error(validateFn.errors); + log.error(validateFn.errors); } expect(result).toBeTruthy(); }); diff --git a/tests/db-testing.ts b/tests/db-testing.ts index 06cf1faf..053f95c8 100644 --- a/tests/db-testing.ts +++ b/tests/db-testing.ts @@ -19,11 +19,11 @@ export class TestDatabase extends Database { constructor( config: PoolConfig, - numReplicates: number, + numBootstrapSamples: number, timelineEnabled: boolean, useTransactions: boolean ) { - super(config, numReplicates, timelineEnabled); + super(config, numBootstrapSamples, timelineEnabled); this.connectionPool = new pg.Pool(config); this.usesTransactions = useTransactions; this.client = null; @@ -106,13 +106,13 @@ export class TestDatabase extends Database { export async function createAndInitializeDB( testSuite: string, - numReplicates = 1000, + numBootstrapSamples = 1000, timelineEnabled = false, useTransactions = true ): Promise { const testDb = await createDB( testSuite, - numReplicates, + numBootstrapSamples, timelineEnabled, useTransactions ); @@ -122,7 +122,7 @@ export async function createAndInitializeDB( export async function createDB( testSuite: string, - numReplicates = 1000, + numBootstrapSamples = 1000, timelineEnabled = false, useTransactions = true ): Promise { @@ -137,7 +137,12 @@ export async function createDB( cfg.database = dbNameForSuite; - return new TestDatabase(cfg, numReplicates, timelineEnabled, useTransactions); + return new TestDatabase( + cfg, + numBootstrapSamples, + timelineEnabled, + useTransactions + ); } let mainDB: Database | null = null; diff --git a/tests/db.test.ts b/tests/db.test.ts new file mode 100644 index 00000000..c3015481 --- /dev/null +++ b/tests/db.test.ts @@ -0,0 +1,200 @@ +import { + TestDatabase, + createAndInitializeDB, + closeMainDb +} from './db-testing.js'; +import { BenchmarkData, TimelineRequest } from '../src/api.js'; +import { readFileSync } from 'fs'; +import { getDirname } from '../src/util.js'; + +const __dirname = getDirname(import.meta.url); + +describe('Timeline-plot Queries', () => { + let db: TestDatabase; + let projectName: string; + let baseBranch: string; + let changeBranch: string; + + let earlierBaseCommitId: string; + let baseCommitId: string; + let changeCommitId: string; + + let benchmark: string; + let executor: string; + let suite: string; + + beforeAll(async () => { + db = await createAndInitializeDB('db_ts_basic', 25, true, false); + + const data = readFileSync(`${__dirname}/small-payload.json`).toString(); + const basicTestData: BenchmarkData = JSON.parse(data); + projectName = basicTestData.projectName; + + baseBranch = basicTestData.source.branchOrTag = 'base-branch'; + db.setProjectBaseBranch(projectName, basicTestData.source.branchOrTag); + earlierBaseCommitId = basicTestData.source.commitId; + + await db.recordMetaDataAndRuns(basicTestData); + await db.recordAllData(basicTestData); + + // have a second experiment in the database + basicTestData.experimentName += ' 2'; + basicTestData.startTime = '2019-12-14T22:49:56'; + changeBranch = basicTestData.source.branchOrTag = 'change-branch'; + changeCommitId = basicTestData.source.commitId = + '2222222222222222222222222222222222222222'; + + await db.recordMetaDataAndRuns(basicTestData); + await db.recordAllData(basicTestData); + + // have a merge in the database + basicTestData.experimentName += ' 3'; + basicTestData.startTime = '2019-12-15T22:49:56'; + basicTestData.source.branchOrTag = baseBranch; + baseCommitId = basicTestData.source.commitId = + '3333333333333333333333333333333333333333'; + + await db.recordMetaDataAndRuns(basicTestData); + await db.recordAllData(basicTestData); + + benchmark = basicTestData.data[0].runId.benchmark.name; + executor = basicTestData.data[0].runId.benchmark.suite.executor.name; + suite = basicTestData.data[0].runId.benchmark.suite.name; + + await db.awaitQuiescentTimelineUpdater(); + }); + + afterAll(async () => { + await db.close(); + }); + + describe('Retrieving branch names based on commit ids', () => { + it('should return `null` if there is an error', async () => { + const result = await db.getBranchNames( + projectName, + 'non-existing-commit', + 'another-non-existing-commit' + ); + + expect(result).toBeNull(); + }); + + it('should handle both commit ids being on the same branch', async () => { + let result = await db.getBranchNames( + projectName, + earlierBaseCommitId, + baseCommitId + ); + + expect(result?.baseBranchName).toEqual(baseBranch); + expect(result?.changeBranchName).toEqual(baseBranch); + + result = await db.getBranchNames( + projectName, + baseCommitId, + earlierBaseCommitId + ); + + expect(result?.baseBranchName).toEqual(baseBranch); + expect(result?.changeBranchName).toEqual(baseBranch); + + result = await db.getBranchNames(projectName, baseCommitId, baseCommitId); + + expect(result?.baseBranchName).toEqual(baseBranch); + expect(result?.changeBranchName).toEqual(baseBranch); + }); + + it('should match branch names correctly to base and change', async () => { + let result = await db.getBranchNames( + projectName, + baseCommitId, + changeCommitId + ); + + expect(result?.baseBranchName).toEqual(baseBranch); + expect(result?.changeBranchName).toEqual(changeBranch); + + result = await db.getBranchNames( + projectName, + changeCommitId, + baseCommitId + ); + + expect(result?.baseBranchName).toEqual(changeBranch); + expect(result?.changeBranchName).toEqual(baseBranch); + }); + }); + + describe('Retrieving timeline data', () => { + it('should return `null` if there is an error', async () => { + const request: TimelineRequest = { + baseline: 'non-existing-commitid', + change: 'another-non-existing-commitid', + b: benchmark, + e: executor, + s: suite + }; + + const result = await db.getTimelineData(projectName, request); + expect(result).toBeNull(); + }); + + it('should return median and BCIs for each branch', async () => { + const request: TimelineRequest = { + baseline: baseCommitId, + change: changeCommitId, + b: benchmark, + e: executor, + s: suite + }; + + const result = await db.getTimelineData(projectName, request); + expect(result?.baseBranchName).toEqual(baseBranch); + expect(result?.changeBranchName).toEqual(changeBranch); + + expect(result?.baseTimestamp).toEqual(1576450196); + expect(result?.changeTimestamp).toEqual(1576363796); + + expect(result?.data).toEqual([ + [1576277396, 1576363796, 1576450196], + [null, null, null], + [432.783, null, 432.783], + [null, null, null], + [null, null, null], + [null, 432.783, null], + [null, null, null] + ]); + }); + + it('should identify the current data points per branch', async () => { + const request: TimelineRequest = { + baseline: baseCommitId, + change: earlierBaseCommitId, + b: benchmark, + e: executor, + s: suite + }; + + const result = await db.getTimelineData(projectName, request); + expect(result?.baseBranchName).toEqual(baseBranch); + expect(result?.changeBranchName).toEqual(baseBranch); + + expect(result?.baseTimestamp).toEqual(1576450196); + expect(result?.changeTimestamp).toBeNull(); + + expect(result?.data).toEqual([ + [1576277396, 1576450196], + [null, null], + [432.783, 432.783], + [null, null], + [null, null], + [null, null], + [null, null] + ]); + }); + }); +}); + +afterAll(() => { + closeMainDb(); +}); diff --git a/tests/report.test.ts b/tests/report.test.ts index 6a693663..d9f65deb 100644 --- a/tests/report.test.ts +++ b/tests/report.test.ts @@ -57,7 +57,6 @@ describe('Report Generation', () => { it('Should have generated a summary plot', () => { const plotFile = getSummaryPlotFileName(outputFile); const plotPath = `${reportFolder}/${plotFile}`; - console.log(plotPath); expect(existsSync(plotPath)).toBeTruthy(); }); diff --git a/tsconfig.json b/tsconfig.json index 6af9cd96..6c025693 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -38,7 +38,9 @@ /* Module Resolution Options */ "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ "baseUrl": "./src", /* Base directory to resolve non-absolute module names. */ - // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ + "paths": { /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ + "/static/*": ["../resources/*"] + }, // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ // "typeRoots": [], /* List of folders to include type definitions from. */ // "types": [], /* Type declaration files to be included in compilation. */