zonemaster-gui-preact/static/scripts/result.js

469 lines
17 KiB
JavaScript

import { html, render, useState, useEffect } from 'https://unpkg.com/htm/preact/standalone.module.js'
import { rpcApi } from './api.js';
import { tr, formatDate, Localized } from './i18n.js';
const numericSeverityLevels = {
info: 0,
notice: 1,
warning: 2,
error: 3,
critical: 4,
};
const severityLevelNames = {
all: 'All',
info: 'Info',
notice: 'Notice',
warning: 'Warning',
error: 'Error',
critical: 'Critical',
};
const severityIcons = {
info: 'fa-check',
notice: 'fa-exclamation',
warning: 'fa-exclamation-triangle',
error: 'fa-times-circle',
critical: 'fa-times-circle'
};
function processResult(testResult) {
const testCaseDescription = testResult.testcase_descriptions;
const messages = testResult.results;
const modulesMap = {};
const modules = [];
for (const message of messages) {
const currentModule = message.module;
const currentTestCase = message.testcase;
const currentLevel = message.level.toLowerCase();
if (!(currentModule in modulesMap)) {
modulesMap[currentModule] = {
name: currentModule,
testCaseMap: {},
testCases: [],
counts: [],
}
modules.push(modulesMap[currentModule]);
}
const testCaseMap = modulesMap[currentModule].testCaseMap;
if (!(currentTestCase in testCaseMap)) {
testCaseMap[currentTestCase] = {
description: testCaseDescription[currentTestCase],
testCaseId: currentTestCase,
level: currentLevel,
messages: []
}
modulesMap[currentModule].testCases.push(testCaseMap[currentTestCase]);
}
testCaseMap[currentTestCase].messages.push({ level: currentLevel, message: message.message});
}
const globalSeverityCounts = {
'all': 0,
'info': 0,
'notice': 0,
'warning': 0,
'error': 0,
'critical': 0,
};
for (const testModule of Object.values(modulesMap)) {
for (const testCase of testModule.testCases) {
globalSeverityCounts[testCase.level] ++;
globalSeverityCounts['all'] ++;
testCase.messages.sort((message1, message2) => {
return numericSeverityLevels[message2.level] - numericSeverityLevels[message1.level];
});
// First message is the highest security level
testCase.level = testCase.messages[0].level;
}
testModule.testCases.sort((testcase1, testcase2) => {
// sort testcase by descending severity level, unspecified messages always on top
if (testcase1.testCaseId.toUpperCase() == 'UNSPECIFIED') {
return -1;
}
if (testcase2.testCaseId.toUpperCase() == 'UNSPECIFIED') {
return 1;
}
return numericSeverityLevels[testcase2.level] - numericSeverityLevels[testcase1.level];
})
}
return { modules, globalSeverityCounts };
}
function filterResults(results, severityLevelFilter, textFilter = '') {
const keepLevels = severityLevelFilter.all ?
Object.keys(severityLevelNames) :
Object.entries(severityLevelFilter).filter(([_level, keep]) => keep).map(([level, _keep]) => level);
const needle = textFilter.toLowerCase();
const filterResults = [];
for (const testModule of results) {
const filteredModule = {
name: testModule.name,
testCases: [],
counts: [],
};
const moduleSeverityCounts = {};
for (const testCase of testModule.testCases) {
const filteredMessages = testCase.messages.filter(message => {
const containsSearch = needle.length === 0 || message.message.toLowerCase().includes(needle);
return keepLevels.includes(message.level) && containsSearch;
});
if (filteredMessages.length > 0) {
// Messages are sorted from highest level to lowest level, first message is always the highest
let testCaseLevel = filteredMessages[0].level;
filteredModule.testCases.push({
...testCase,
level: testCaseLevel,
messages: filteredMessages
});
if ( !(testCaseLevel in moduleSeverityCounts) ) {
moduleSeverityCounts[testCaseLevel ] = 0;
}
moduleSeverityCounts[testCaseLevel] ++;
}
}
if (filteredModule.testCases.length > 0) {
filteredModule.counts = Object.entries(moduleSeverityCounts).sort(([severityLevel1, _count1], [severityLevel2, _count2]) => {
return numericSeverityLevels[severityLevel2] - numericSeverityLevels[severityLevel1];
}).map(([severityLevel, count]) => {
return {level: severityLevel, value: count}
});
filterResults.push(filteredModule);
}
}
return filterResults;
}
function ResultPage({ testId, lang }) {
const [testResult, setTestResult] = useState(null);
const [filteredResults, setFilteredResults] = useState({});
const [collapsedModules, setCollapsedModules] = useState({});
useEffect(() => {
rpcApi('get_test_results', { id: testId, language: lang })
.then(data => {
const { modules, globalSeverityCounts } = processResult(data.result);
const newTestResult = {
createdAt: new Date(data.result.created_at),
domain: data.result.params.domain,
results: modules,
globalSeverityCounts: globalSeverityCounts,
};
setFilteredResults(filterResults(newTestResult.results, {'all': true}));
setCollapsedModules(buildCollapsedModule(newTestResult, true));
setTestResult(newTestResult);
});
}, [testId, lang]);
const onToggleCollapsed = (module) => {
const newCollapsedModules = {...collapsedModules};
newCollapsedModules[module] = !newCollapsedModules[module];
setCollapsedModules(newCollapsedModules);
};
const buildCollapsedModule = (testResult, collapsed) => {
return Object.fromEntries(testResult.results.map(({ name }) => [name, collapsed]))
}
const expandAllModules = () => {
setCollapsedModules(buildCollapsedModule(testResult, false));
};
const collapseAllModules = () => {
setCollapsedModules(buildCollapsedModule(testResult, true));
};
const onFilterChange = (severityLevelFilter, textFilter) => {
setFilteredResults(filterResults(testResult.results, severityLevelFilter, textFilter))
};
if (testResult !== null) {
return html`
<${ResultHeader} domain=${testResult.domain} createdAt=${testResult.createdAt} />
<${ResultFilters}
expandAllModules=${expandAllModules}
collapseAllModules=${collapseAllModules}
globalSeverityCounts=${testResult.globalSeverityCounts}
onFilterChange=${onFilterChange}
/>
<${ResultDetails} modules=${filteredResults} collapsedModules=${collapsedModules} onToggleCollapsed=${onToggleCollapsed} />
`;
} else {
return 'loading...';
}
}
function ResultHeader({ domain, createdAt }) {
return html`
<h2>${tr('Test result for <strong>{domain}</strong>', { domain })}</h2>
<div class="result-metadata">
<p class="result-test-datetime">${tr('Created on')} <time datetime="${createdAt.toISOString()}">${formatDate(createdAt)}</time></p>
</div>
`;
}
function ResultDetails({ modules, collapsedModules, onToggleCollapsed}) {
return html`
<section class="mt-3 details">
<!-- Modules -->
${modules.map(module => ResultModule({collapsed: collapsedModules[module.name], onToggleCollapsed,...module}))}
</section>
`;
}
function ResultModule({ name, testCases, collapsed, onToggleCollapsed, counts }) {
return html`
<section class="module ${collapsed ? '' : 'expanded' }">
<h3>
<button
type="button"
tabindex="0"
onClick=${_ => onToggleCollapsed(name)}
aria-expanded="${!collapsed}"
aria-controls="module-${name}"
id="control-module-${name}"
>
<i class="fa fa-caret-right caret" aria-hidden="true"></i>
<span class="module-name">
${name}<span class="sr-only">:</span>
</span>
<!-- Badge pill count -->
${counts.map((el, i, arr) => ResultModuleBadgePillCount({...el, last: i === arr.length - 1}))}
</button>
</h3>
<div id="module-${name}" class="${collapsed ? 'collapsed' : ''}">
${testCases.map(ResultTestCase)}
</div>
</section>
`;
}
function ResultModuleBadgePillCount({ level, value, last }) {
return html`
<span class="badge badge-pill rounded-pill ${level}" title="${tr(severityLevelNames[level])} (${value})">
<span aria-hidden="true">
<i class="fa ${severityIcons[level]}"></i> ${value}
</span>
<span class="sr-only">${tr(severityLevelNames[level])} (${value})${last ? '' : ', '}</span>
</span>
`;
}
function ResultTestCase({ level, description, testCaseId, messages }) {
const [collapsed, setCollapsed] = useState(testCaseId.toUpperCase() !== 'UNSPECIFIED');
const toggleCollapse = (e) => {
setCollapsed(!collapsed);
};
return html`
<section class="testcase">
${ testCaseId.toUpperCase() !== 'UNSPECIFIED' ?
html`<header class="${level}">
<h4>
<button
type="button"
tabindex="0"
onClick=${toggleCollapse}
aria-controls="testcase-entries-${testCaseId} testcase-id-${testCaseId}"
aria-expanded="${!collapsed}"
>
<i class="fa ${collapsed ? 'fa-plus-square-o' : 'fa-minus-square-o'}" aria-hidden="true"></i>
<span class="testcase-name">
<i class="fa ${severityIcons[level]}" aria-hidden="true" title="${tr(severityLevelNames[level])}"></i>
<span class="sr-only">${tr(severityLevelNames[level])}: </span>${description}
</span>
</button>
</h4>
<span class="test-case-id ${collapsed ? 'collapsed' : ''}" id="testcase-id-${testCaseId}">
<i class="fa fa-info-circle" aria-hidden="true"></i> ${testCaseId}
</span>
</header>` : null
}
<div class="${collapsed ? 'collapsed' : ''}" id="testcase-entries-${testCaseId}">
<ul>
${messages.map(entry => html`<li><div><span class="level ${entry.level}">${tr(severityLevelNames[entry.level])}</span></div><p>${entry.message}</p></li>`)}
</ul>
</div>
</section>
`;
}
function ResultSeverityHelper() {
return html`
<aside>
<details id="severity-level-help">
<summary>
<i class="fa fa-chevron-right control" aria-hidden="true"></i>
<i class="fa fa-info-circle me-1" aria-hidden="true"></i>
${tr("What is the meaning of the severity levels?")}
</summary>
<dl>
<dt>${tr("Info")}</dt>
<dd>
${tr("The message is something that may be of interest to the zone's administrator but that definitely does not indicate a problem.")}
</dd>
<dt>${tr("Notice")}</dt>
<dd>
${tr("The message means something that should be known by the zone's administrator but that need not necessarily be a problem at all.")}
</dd>
<dt>${tr("Warning")}</dt>
<dd>
${tr("The message means something that will under some circumstances be a problem, but that is unlikely to be noticed by a casual user.")}
</dd>
<dt>${tr("Error")}</dt>
<dd>
${tr("The message means a problem that is very likely (or possibly certain) to negatively affect the function of the zone being tested, but not so severe that the entire zone becomes unresolvable.")}
</dd>
<dt>${tr("Critical")}</dt>
<dd>
${tr("The message means a very serious error.")}
</dd>
</dl>
</details>
</aside>
`;
}
function ResultLevelFilter({ id, filterLevel, onToggleLevel, count }) {
const toggle = (e) => {
onToggleLevel(id);
};
return html`
<div class="form-check form-check-inline">
<label class="form-check-label" class=${filterLevel ? 'active ' + id : ''}>
<input class="form-check-input" type="checkbox" checked=${filterLevel} onClick=${toggle}/>
${tr(severityLevelNames[id]) + ' '}
<span class="badge rounded-pill bg-secondary">${count}</span>
</label>
</div>
`;
}
function ResultFilters({ expandAllModules, collapseAllModules, onFilterChange, globalSeverityCounts }) {
const levels = Object.keys(severityLevelNames).map(id => {
return {id, activeClass: id, count: globalSeverityCounts[id]}
});
const initLevels = () => {
return Object.fromEntries(Object.keys(severityLevelNames).map(id => [id, id === 'all']));
}
const [filterLevels, setFilterLevels] = useState(initLevels);
const [textFilter, setTextFilter] = useState('');
const setAndEmitFilterLevels = (value) => {
setFilterLevels(value);
onFilterChange(value, textFilter);
}
const onToggleLevel = (level) => {
if (level == 'all') {
setAndEmitFilterLevels(initLevels());
} else {
const newFilterLevels = {...filterLevels};
newFilterLevels[level] = !newFilterLevels[level];
const acitveLevels = Object.values(newFilterLevels).filter(level => level);
if (acitveLevels.length === 0) {
setAndEmitFilterLevels(initLevels());
} else {
newFilterLevels['all'] = false;
setAndEmitFilterLevels(newFilterLevels);
}
}
}
const onSearchChanged = (event) => {
const newTextFilter = event.currentTarget.value;
setTextFilter(newTextFilter);
onFilterChange(filterLevels, newTextFilter);
}
return html`
<div class="filters">
<fieldset class="severity-levels" aria-details="severity-level-help">
<legend>${tr("Filter severity levels")}</legend>
<div class="severity-level-inputs">
${
levels.map(level => ResultLevelFilter({
filterLevel: filterLevels[level.id],
onToggleLevel,
...level
}))
}
</div>
</fieldset>
<${ResultSeverityHelper} />
<div class="row">
<div class="col">
<label class="form-label" for="result-search">${tr("Search text in messages")}</label>
<div class="input-group search">
<input type="text" class="form-control" id="result-search" value=${textFilter} onInput=${onSearchChanged}/>
<div class="input-group-text">
<i class="fa fa-filter" aria-hidden="true"></i>
</div>
</div>
</div>
<div class="col-auto result-control-buttons">
<button class="btn btn-secondary" onClick=${expandAllModules}>${tr("Expand all modules")}</button>
<button class="btn btn-secondary" onClick=${collapseAllModules}>${tr("Collapse all")}</button>
</div>
</div>
</div>
`;
}
function renderResult() {
const currentUrl = new URL(window.location);
const testId = currentUrl.searchParams.get('test');
const lang = document.documentElement.lang;
render(html`
<${Localized} lang="${lang}">
<${ResultPage} lang=${lang} testId=${testId} />
<//>
`, document.getElementById('result-app'));
}
window.addEventListener('load', renderResult);