Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 79 additions & 4 deletions src/webui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -1382,8 +1382,46 @@ function destinationTypeName(id) {
return item ? (OUTPUT_NAMES[item.output_type] || friendlyName(item.output_type)) : "Unavailable destination";
}

const ROUTE_ALL_EVENT_FILTERS = {
severities: new Set([
"debug",
"information",
"warning",
"error",
"critical",
"failure",
]),
statuses: new Set([
"active",
"resolved",
"firing",
"recovered",
"success",
"skipped",
"failure",
]),
};

function routeFilterHasAllEvents(key, values) {
const available = ROUTE_ALL_EVENT_FILTERS[key];
if (!available || !Array.isArray(values) || values.length !== available.size) {
return false;
}

const selected = new Set(
values.map((value) => String(value || "").toLowerCase()),
);

return (
selected.size === available.size
&& [...available].every((value) => selected.has(value))
);
}

function filterSummary(filters) {
const parts = [];
let allEvents = false;

const labels = {
severities: "Include severity",
statuses: "Include status",
Expand All @@ -1394,14 +1432,51 @@ function filterSummary(filters) {
exclude_hosts: "Exclude host",
exclude_events: "Exclude event",
};

for (const key of Object.keys(labels)) {
const values = filters && filters[key];
if (Array.isArray(values) && values.length) {
parts.push(`${labels[key]}: ${values.map(capitalize).join(", ")}`);
if (!Array.isArray(values) || !values.length) continue;

if (
(key === "severities" || key === "statuses")
&& routeFilterHasAllEvents(key, values)
) {
allEvents = true;
continue;
}

if (
key === "exclude_severities"
&& routeFilterHasAllEvents("severities", values)
) {
parts.push(`${labels[key]}: All Events`);
continue;
}

if (
key === "exclude_statuses"
&& routeFilterHasAllEvents("statuses", values)
) {
parts.push(`${labels[key]}: All Events`);
continue;
}

parts.push(`${labels[key]}: ${values.map(capitalize).join(", ")}`);
}
if (parts.length === 1 && filters.severities && filters.severities.length === 1 && filters.severities[0] === "critical") return "Just Critical";
return parts.join(" · ") || "All events";

if (allEvents) parts.unshift("All Events");

if (
!allEvents
&& parts.length === 1
&& filters.severities
&& filters.severities.length === 1
&& filters.severities[0] === "critical"
) {
return "Just Critical";
}

return parts.join(" · ") || "All Events";
}

function renderRoutes() {
Expand Down
128 changes: 123 additions & 5 deletions src/webui/qa_patch.js
Original file line number Diff line number Diff line change
Expand Up @@ -464,15 +464,110 @@ document.addEventListener("DOMContentLoaded", () => {
qaBindBottomShortcuts();
});

function qaSelectedRouteFilterValues(select) {
return new Set(
[...select.selectedOptions].map((option) => option.value),
);
}

function qaSyncRouteFilterPair(includeId, excludeId, preferred = "exclude") {
const include = byId(includeId);
const exclude = byId(excludeId);
if (!include || !exclude) return;

let includeSelected = qaSelectedRouteFilterValues(include);
let excludeSelected = qaSelectedRouteFilterValues(exclude);

const overlap = new Set(
[...includeSelected].filter((value) => excludeSelected.has(value)),
);

if (overlap.size) {
const losingSelect = preferred === "include" ? exclude : include;

for (const option of losingSelect.options) {
if (overlap.has(option.value)) option.selected = false;
}
}

includeSelected = qaSelectedRouteFilterValues(include);
excludeSelected = qaSelectedRouteFilterValues(exclude);

for (const option of include.options) {
option.disabled = excludeSelected.has(option.value);
}

for (const option of exclude.options) {
option.disabled = includeSelected.has(option.value);
}
}

function qaSyncAllRouteFilterPairs(preferred = "exclude") {
qaSyncRouteFilterPair(
"route-severities",
"route-exclude_severities",
preferred,
);
qaSyncRouteFilterPair(
"route-statuses",
"route-exclude_statuses",
preferred,
);
}

function qaBindRouteFilterPair(includeId, excludeId) {
const include = byId(includeId);
const exclude = byId(excludeId);
if (!include || !exclude) return;

const bindingKey = `${includeId}:${excludeId}`;
if (include.dataset.qaExclusivePair === bindingKey) return;

include.dataset.qaExclusivePair = bindingKey;
exclude.dataset.qaExclusivePair = bindingKey;

include.addEventListener("change", () => {
qaSyncRouteFilterPair(includeId, excludeId, "include");
});

exclude.addEventListener("change", () => {
qaSyncRouteFilterPair(includeId, excludeId, "exclude");
});

// Existing legacy conflicts follow the established routing rule:
// Exclude wins.
qaSyncRouteFilterPair(includeId, excludeId, "exclude");
}

function qaAddSelectActions(selectId) {
const select = byId(selectId);
if (!select || select.dataset.qaActions === "1") return;
select.dataset.qaActions = "1";

const actions = element("div", { className: "qa-select-actions" });
const all = element("button", { className: "text-button", text: "Select all", type: "button" });
const clear = element("button", { className: "text-button", text: "Clear", type: "button" });
all.addEventListener("click", () => { for (const option of select.options) option.selected = true; select.dispatchEvent(new Event("change", { bubbles: true })); });
clear.addEventListener("click", () => { for (const option of select.options) option.selected = false; select.dispatchEvent(new Event("change", { bubbles: true })); });
const all = element("button", {
className: "text-button",
text: "Select all",
type: "button",
});
const clear = element("button", {
className: "text-button",
text: "Clear",
type: "button",
});

all.addEventListener("click", () => {
for (const option of select.options) {
if (!option.disabled) option.selected = true;
}
select.dispatchEvent(new Event("change", { bubbles: true }));
});

clear.addEventListener("click", () => {
for (const option of select.options) option.selected = false;
select.dispatchEvent(new Event("change", { bubbles: true }));
});

actions.append(all, clear);
select.parentElement.append(actions);
}
Expand All @@ -493,11 +588,34 @@ function qaAddCounter(inputId, maximum) {
update();
}

const qaOriginalOpenRoute = openRoute;
openRoute = function openRouteWithExclusiveFilters(id = "") {
qaOriginalOpenRoute(id);
qaSyncAllRouteFilterPairs("exclude");
};

document.addEventListener("DOMContentLoaded", () => {
qaAddCounter("preview-event-title", 256);
qaAddCounter("preview-message", 4000);
qaAddCounter("user-name", 64);
for (const id of ["route-severities", "route-statuses", "route-exclude_severities", "route-exclude_statuses"]) qaAddSelectActions(id);

for (const id of [
"route-severities",
"route-statuses",
"route-exclude_severities",
"route-exclude_statuses",
]) {
qaAddSelectActions(id);
}

qaBindRouteFilterPair(
"route-severities",
"route-exclude_severities",
);
qaBindRouteFilterPair(
"route-statuses",
"route-exclude_statuses",
);
});

/* Nowlert 3.1.0 empty-state icon restoration */
Expand Down
41 changes: 41 additions & 0 deletions tests/test_v251_fallback_routing_webui.py
Original file line number Diff line number Diff line change
Expand Up @@ -363,3 +363,44 @@ def test_integration_settings_list_has_heading_spacing():
)
assert "#integration-settings-list {" in styles
assert "margin-top: 1.5rem" in styles



def test_nce21_nce27_route_filter_summary_and_mutual_exclusion():
script = (ROOT / "src/webui/app.js").read_text(encoding="utf-8")
patch = (ROOT / "src/webui/qa_patch.js").read_text(encoding="utf-8")

# NCE-21: complete Status / Severity selections collapse to one
# human-readable All Events entry instead of listing every value.
assert "const ROUTE_ALL_EVENT_FILTERS = {" in script
assert '"debug",' in script
assert '"information",' in script
assert '"active",' in script
assert '"resolved",' in script
assert "function routeFilterHasAllEvents(key, values)" in script
assert 'if (allEvents) parts.unshift("All Events");' in script
assert 'return parts.join(" · ") || "All Events";' in script
assert '${labels[key]}: All Events' in script

# NCE-27: Include and Exclude Status / Severity choices are exclusive.
assert "function qaSyncRouteFilterPair(" in patch
assert "function qaSyncAllRouteFilterPairs(" in patch
assert 'preferred === "include"' in patch
assert "option.disabled = excludeSelected.has(option.value);" in patch
assert "option.disabled = includeSelected.has(option.value);" in patch
assert 'qaSyncAllRouteFilterPairs("exclude");' in patch
assert (
'qaBindRouteFilterPair(\n'
' "route-severities",\n'
' "route-exclude_severities",\n'
' );'
) in patch
assert (
'qaBindRouteFilterPair(\n'
' "route-statuses",\n'
' "route-exclude_statuses",\n'
' );'
) in patch

# Select All must never programmatically re-select a disabled conflict.
assert "if (!option.disabled) option.selected = true;" in patch