diff --git a/frontend/src/base.html b/frontend/src/base.html index afa39072a..980e97508 100755 --- a/frontend/src/base.html +++ b/frontend/src/base.html @@ -10,7 +10,7 @@

: {{ total }} files @@ -47,7 +47,7 @@

@@ -73,7 +73,7 @@

: {{ files.length }} files @@ -88,7 +88,7 @@

{{#files}}
- {{ file_name }} + {{ file_name }} {{ children }} {{ coveragePercent }} %
@@ -102,29 +102,39 @@

Revision {{ revision }} from {{ date }} -
- + - -
+ + +
Loading...
diff --git a/frontend/src/common.js b/frontend/src/common.js index f0d0c7e25..4578955ac 100644 --- a/frontend/src/common.js +++ b/frontend/src/common.js @@ -1,39 +1,35 @@ import Mustache from 'mustache'; +import { buildRoute, readRoute, updateRoute } from './route.js'; +import {ZERO_COVERAGE_FILTERS} from './zero_coverage_report.js'; export const REV_LATEST = 'latest'; -function assert(condition, message) { - if (!condition) { - throw new Error(message || "Assertion failed"); - } -} - function domContentLoaded() { return new Promise(resolve => document.addEventListener('DOMContentLoaded', resolve)); } export const DOM_READY = domContentLoaded(); -export async function main(load, display, opts) { - // Immediately listen to DOM event - +export async function main(load, display) { // Load initial data before DOM is available let data = await load(); // Wait for DOM to be ready before displaying await DOM_READY; await display(data); + monitor_options(); // Full workflow, loading then displaying data // used for following updates let full = async function() { let data = await load(); await display(data); + monitor_options(); }; - monitor_options(opts, full); + + // React to url changes window.onhashchange = full; } - // Coverage retrieval. const COVERAGE_BACKEND_HOST = process.env.BACKEND_URL; @@ -64,8 +60,9 @@ function cache_set(cache, key, value) { } let path_coverage_cache = {}; -export async function get_path_coverage(path, changeset) { - let data = cache_get(path_coverage_cache, `${changeset}_${path}`); +export async function get_path_coverage(path, changeset, platform, suite) { + let cache_key = `${changeset}_${path}_${platform}_${suite}`; + let data = cache_get(path_coverage_cache, cache_key); if (data) { return data; } @@ -74,33 +71,47 @@ export async function get_path_coverage(path, changeset) { if (changeset && changeset !== REV_LATEST) { params += `&changeset=${changeset}`; } + if (platform && platform !== 'all') { + params += `&platform=${platform}`; + } + if (suite && suite !== 'all') { + params += `&suite=${suite}`; + } let response = await fetch(`${COVERAGE_BACKEND_HOST}/v2/path?${params}`).catch(alert); if (response.status !== 200) { throw new Error(response.status + ' - ' + response.statusText); } data = await response.json(); - cache_set(path_coverage_cache, `${changeset}_${path}`, data); + cache_set(path_coverage_cache, cache_key, data); return data; } let history_cache = {}; -export async function get_history(path) { +export async function get_history(path, platform, suite) { // Backend needs path without trailing / if (path && path.endsWith('/')) { path = path.substring(0, path.length-1); } - let data = cache_get(history_cache, path); + let cache_key = `${path}_${platform}_${suite}`; + let data = cache_get(history_cache, cache_key); if (data) { return data; } - let response = await fetch(`${COVERAGE_BACKEND_HOST}/v2/history?path=${path}`); + let params = `path=${path}`; + if (platform && platform !== 'all') { + params += `&platform=${platform}`; + } + if (suite && suite !== 'all') { + params += `&suite=${suite}`; + } + let response = await fetch(`${COVERAGE_BACKEND_HOST}/v2/history?${params}`); data = await response.json(); - cache_set(history_cache, path, data); + cache_set(history_cache, cache_key, data); // Check data has coverage values // These values are missing when going above 2 levels right now @@ -131,20 +142,62 @@ export async function get_zero_coverage_data() { } -// Option handling. +let filters_cache = {}; +export async function get_filters() { + let data = cache_get(filters_cache, ''); + if (data) { + return data; + } + + let response = await fetch(`${COVERAGE_BACKEND_HOST}/v2/filters`); + data = await response.json(); + + cache_set(filters_cache, '', data); -function is_enabled(opt) { - let elem = document.getElementById(opt); - return elem.checked; + return data; } -function monitor_options(opts, callback) { - for (let opt of opts) { - let elem = document.getElementById(opt); - elem.onchange = callback; + +// Option handling. + +export function is_enabled(opt) { + let route = readRoute(); + let value = 'off'; + if (route[opt]) { + value = route[opt]; + } else if (ZERO_COVERAGE_FILTERS[opt]) { + value = ZERO_COVERAGE_FILTERS[opt].default_value; } + return value === 'on'; } +function monitor_options() { + // Monitor input & select changes + let fields = document.querySelectorAll('input, select'); + for(let field of fields) { + if (field.type == 'text') { + // React on enter + field.onkeydown = async (evt) => { + if(evt.keyCode === 13) { + let params = {}; + params[evt.target.name] = evt.target.value; + updateRoute(params); + } + } + } else { + // React on change + field.onchange = async (evt) => { + let value = evt.target.value; + if (evt.target.type == 'checkbox') { + value = evt.target.checked ? 'on' : 'off'; + } + let params = {}; + params[evt.target.name] = value; + updateRoute(params); + } + } + } +} // hgmo. @@ -267,14 +320,14 @@ export function build_navbar(path, revision) { let links = [ { 'name': 'mozilla-central', - 'path': '', + 'route': buildRoute({path: '', revision}) } ]; return links.concat(path.split('/').map(file => { base += (base ? '/' : '') + file; return { 'name': file, - 'path': base, + 'route': buildRoute({path: base, revision}) }; })); } diff --git a/frontend/src/index.js b/frontend/src/index.js index 55185ad1b..352ba750f 100644 --- a/frontend/src/index.js +++ b/frontend/src/index.js @@ -1,5 +1,6 @@ -import {REV_LATEST, DOM_READY, main, show, hide, message, get_path_coverage, get_history, get_zero_coverage_data, build_navbar, render, get_source} from './common.js'; -import {zero_coverage_display} from './zero_coverage_report.js'; +import {REV_LATEST, DOM_READY, main, show, hide, message, get_path_coverage, get_history, get_zero_coverage_data, build_navbar, render, get_source, get_filters} from './common.js'; +import {buildRoute, readRoute, updateRoute} from './route.js'; +import {zero_coverage_display, zero_coverage_menu} from './zero_coverage_report.js'; import './style.css'; import Prism from 'prismjs'; import Chartist from 'chartist'; @@ -8,6 +9,26 @@ import 'chartist/dist/chartist.css'; const VIEW_ZERO_COVERAGE = 'zero'; const VIEW_BROWSER = 'browser'; + +function browser_menu(revision, filters, route) { + let context = { + revision, + platforms: filters.platforms.map((p) => { + return { + 'name': p, + 'selected': p == route.platform, + } + }), + suites: filters.suites.map((s) => { + return { + 'name': s, + 'selected': s == route.suite, + } + }), + }; + render('menu_browser', context, 'menu'); +} + async function graphHistory(history, path) { if (history === null) { message('warning', `No history data for ${path}`); @@ -53,7 +74,7 @@ async function graphHistory(history, path) { // Load revision from graph when a point is clicked let revision = history[evt.index].changeset; evt.element._node.onclick = function(){ - updateHash(revision, path); + updateRoute({revision}); }; // Display revision from graph when a point is overed @@ -71,7 +92,12 @@ async function graphHistory(history, path) { async function showDirectory(dir, revision, files) { let context = { navbar: build_navbar(dir, revision), - files: files, + files: files.map(file => { + file.route = buildRoute({ + path: file.path + }); + return file; + }), revision: revision || REV_LATEST, file_name: function(){ // Build filename relative to current dir @@ -125,49 +151,30 @@ async function showFile(file, revision) { Prism.highlightAll(output); } -function readHash() { - // Reads changeset & path from current URL hash - let hash = window.location.hash.substring(1); - let pos = hash.indexOf(':'); - if (pos === -1) { - return ['', '']; - } - return [ - hash.substring(0, pos), - hash.substring(pos+1), - ] -} - -function updateHash(newChangeset, newPath) { - // Set the URL hash with both changeset & path - let [changeset, path] = readHash(); - changeset = newChangeset || changeset || REV_LATEST; - path = newPath || path || ''; - window.location.hash = '#' + changeset + ':' + path; -} - async function load() { - let [revision, path] = readHash(); + let route = readRoute(); // Reset display, dom-safe hide('history'); hide('output'); - message('loading', 'Loading coverage data for ' + (path || 'mozilla-central') + ' @ ' + (revision || REV_LATEST)); + message('loading', 'Loading coverage data for ' + (route.path || 'mozilla-central') + ' @ ' + (route.revision || REV_LATEST)); // Load only zero coverage for that specific view - if (revision === VIEW_ZERO_COVERAGE) { + if (route.view === VIEW_ZERO_COVERAGE) { let zero_coverage = await get_zero_coverage_data(); return { view: VIEW_ZERO_COVERAGE, - path, + path: route.path, zero_coverage, + route, } } try { - var [coverage, history] = await Promise.all([ - get_path_coverage(path, revision), - get_history(path), + var [coverage, history, filters] = await Promise.all([ + get_path_coverage(route.path, route.revision, route.platform, route.suite), + get_history(route.path, route.platform, route.suite), + get_filters(), ]); } catch (err) { console.warn('Failed to load coverage', err); @@ -178,54 +185,39 @@ async function load() { return { view: VIEW_BROWSER, - path, - revision, + path: route.path, + revision: route.revision, + route, coverage, history, + filters, }; } async function display(data) { - // Toggle menu per views - if (data.view === VIEW_BROWSER) { - show('menu_browser'); - hide('menu_zero'); - } else if (data.view === VIEW_ZERO_COVERAGE) { - show('menu_zero'); - hide('menu_browser'); - } else { - message('error', 'Invalid view : ' + data.view); - } - - // Revision input management - const revision = document.getElementById('revision'); - revision.onkeydown = async function(evt){ - if(evt.keyCode === 13) { - updateHash(data.revision.value); - } - }; - - // Also update the revision element - if (data.revision && data.revision != REV_LATEST) { - let input = document.getElementById('revision'); - input.value = data.revision; - } - if (data.view === VIEW_ZERO_COVERAGE ) { + await zero_coverage_menu(data.route); await zero_coverage_display(data.zero_coverage, data.path); - } else if (data.view === VIEW_BROWSER && data.coverage.type === 'directory') { - hide('message'); - await graphHistory(data.history, data.path); - await showDirectory(data.path, data.revision, data.coverage.children); + } else if (data.view === VIEW_BROWSER) { + browser_menu(data.revision, data.filters, data.route); - } else if (data.view === VIEW_BROWSER && data.coverage.type === 'file') { - await showFile(data.coverage, data.revision); + if (data.coverage.type === 'directory') { + hide('message'); + await graphHistory(data.history, data.path); + await showDirectory(data.path, data.revision, data.coverage.children); + + } else if (data.coverage.type === 'file') { + await showFile(data.coverage, data.revision); + + } else { + message('error', 'Invalid file type: ' + data.coverate.type); + } } else { - message('error', 'Invalid file type: ' + data.coverage.type); + message('error', 'Invalid view : ' + data.view); } } -main(load, display, ['third_party', 'headers', 'completely_uncovered', 'cpp', 'js', 'java', 'rust', 'last_push']) +main(load, display); diff --git a/frontend/src/route.js b/frontend/src/route.js new file mode 100644 index 000000000..518645496 --- /dev/null +++ b/frontend/src/route.js @@ -0,0 +1,43 @@ +import {REV_LATEST} from './common.js'; + +export function readRoute() { + // Reads all filters from current URL hash + let hash = window.location.hash.substring(1); + let pairs = hash.split('&'); + let out = {} + pairs.forEach(pair => { + let [key, value] = pair.split('='); + if(!key) { + return + } + out[decodeURIComponent(key)] = decodeURIComponent(value); + }); + + // Default values + if (!out.revision) { + out.revision = REV_LATEST; + } + if (!out.path) { + out.path = ''; + } + + return out; +} + +export function buildRoute(params) { + // Add all params on top of current route + let route = readRoute(); + if (params) { + route = {...route, ...params} + } + + // Build query string from filters + return '#' + Object.keys(route) + .map(k => encodeURIComponent(k) + '=' + encodeURIComponent(route[k])) + .join('&'); +} + +export function updateRoute(params) { + // Update full hash with an updated url + window.location.hash = buildRoute(params); +} diff --git a/frontend/src/style.css b/frontend/src/style.css index 71e33b677..cf8e9c454 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -24,11 +24,6 @@ header #browser input { font-family: monospace; } -header > div { - /* By default do not display any menu : managed by JS */ - display: none; -} - #main { background-color: white; border-top: 1px solid darkgray; diff --git a/frontend/src/zero_coverage_report.js b/frontend/src/zero_coverage_report.js index c935e6e71..dabc908ea 100644 --- a/frontend/src/zero_coverage_report.js +++ b/frontend/src/zero_coverage_report.js @@ -1,4 +1,63 @@ -import {hide, message, build_navbar, render, filter_third_party, filter_languages, filter_headers, filter_completely_uncovered, filter_last_push_date} from './common.js'; +import {hide, message, build_navbar, render, filter_third_party, filter_languages, filter_headers, filter_completely_uncovered, filter_last_push_date, is_enabled} from './common.js'; +import {buildRoute} from './route.js'; + +export const ZERO_COVERAGE_FILTERS = { + 'third_party': { + name: "Show third-party files", + default_value: 'on', + }, + 'headers': { + name: 'Show headers', + default_value: 'off', + }, + 'completely_uncovered': { + name: 'Show completely uncovered files only', + default_value: 'off', + }, + 'cpp': { + name: 'C/C++', + default_value: 'on', + }, + 'js': { + name: 'JavaScript', + default_value: 'on', + }, + 'java': { + name: 'Java', + default_value: 'on', + }, + 'rust': { + name: 'Rust', + default_value: 'on', + }, +}; +const ZERO_COVERAGE_PUSHES = { + 'all': 'All', + 'one_year': '0 < 1 year', + 'two_years': '1 < 2 years', + 'older_than_two_years': 'Older than 2 years', +} + + +export function zero_coverage_menu(route){ + let context = { + filters: Object.entries(ZERO_COVERAGE_FILTERS).map(([key, filter]) => { + return { + key, + message: filter.name, + checked: is_enabled(key), + } + }), + last_pushes: Object.entries(ZERO_COVERAGE_PUSHES).map(([value, message]) => { + return { + value, + message, + selected: route['last_push'] === value, + } + }), + }; + render('menu_zero', context, 'menu'); +} function sort_entries(entries) { @@ -104,9 +163,13 @@ export async function zero_coverage_display(data, dir) { entry_url : function() { let path = dir + this.dir; if (this.stats.children != 0) { - return `#zero:${path}`; + return buildRoute({ + view: 'zero', + path, + }) } else { - return `#${revision}:${path}`; + // Fully reset the url when moving back to browser view + return `#view=browser&revision=${revision}&path=${path}`; } }, navbar: build_navbar(dir),