fix(alerts): an alert on a count says how many of what, and names them (#6400)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m36s
CI / integration (push) Successful in 1m48s
CI / publish (push) Successful in 1m8s

"swarm services short on swarm is 6." becomes "6 swarm services are
short of their replicas.", with a Which line naming each service and how
many of its tasks run. The swarm figures get labels, and the counting
figures (swarm, mounts, drives, updates) read as sentences in the email,
its subject and the Alerts page. A plugin names what its count counts
through core.alerts.names_behind; the Docker plugin does for the three
swarm figures, from the stored swarm. Webhooks get alert.which too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 13:00:56 -04:00
co-authored by Claude Opus 5.5
parent ae789bc0f1
commit 0b787a0676
17 changed files with 308 additions and 24 deletions
+37
View File
@@ -376,8 +376,45 @@ METRICS: dict[str, tuple[str, str]] = {
"drive_life_used_pct": ("Drive life used", "how much of its rated write life this drive has used"),
"drive_bad_sectors": ("Bad sectors", "media errors, or reallocated, pending and uncorrectable sectors, as the drive counts them"),
"drive_temp_c": ("Drive temperature", "the drive's own temperature sensor"),
"swarm_services_short": ("Swarm services short of replicas", "services running fewer tasks than they want; one scaled to 0 is never short"),
"swarm_nodes_down": ("Swarm nodes down", "nodes that aren't ready, drained ones aside"),
"swarm_managers_unreachable": ("Swarm managers unreachable", "managers the swarm can't reach, drained ones aside; with three, losing two stops the swarm changing"),
}
# Figures that count things. An alert on one says how many of what, not
# "<label> is 6": (for one, for several, for none), each read after the
# count ("6 swarm services are short of their replicas") or alone.
COUNTED: dict[str, tuple[str, str, str]] = {
"swarm_services_short": ("swarm service is short of its replicas",
"swarm services are short of their replicas",
"every swarm service has its replicas"),
"swarm_nodes_down": ("swarm node is down", "swarm nodes are down", "every swarm node is up"),
"swarm_managers_unreachable": ("swarm manager can't be reached", "swarm managers can't be reached",
"every swarm manager can be reached"),
"mounts_stalled": ("mount isn't answering", "mounts aren't answering", "every mount is answering"),
"drives_failing": ("drive is failing", "drives are failing", "no drive is failing"),
"updates_pending": ("update is waiting", "updates are waiting", "no updates are waiting"),
"updates_security": ("security update is waiting", "security updates are waiting",
"no security updates are waiting"),
}
# Resources that are the whole of what their figures measure: a sentence
# about the swarm's figures says nothing by adding "on swarm".
WHOLE_RESOURCES = frozenset({"swarm"})
def count_words(name: str, value: float | None) -> str | None:
"""A counting figure's reading in words: "6 swarm services are short of
their replicas", "every swarm node is up"; None for a figure that isn't
a count, or no reading."""
phrases = COUNTED.get(name or "")
if phrases is None or value is None:
return None
n = int(value)
if n <= 0:
return phrases[2]
return f"{n} {phrases[0] if n == 1 else phrases[1]}"
# Where a rule's metric comes from, as the picker and the rules list say it.
SOURCES: dict[str, str] = {
"icmp": "Ping monitors", "tcp": "TCP monitors", "dns": "DNS monitors",