468 lines
17 KiB
JavaScript
468 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);
|