CX Triage: alert diagnosis over the CX-Tools collectors

Read-only triage for the Infrahub error alerts. Pulls the Prometheus alert
queue, re-checks each alert's condition against live state to separate real
work from noise, diagnoses it using the CX runbooks, and drafts the customer
comms with contacts resolved from Infrahub.

Findings from validating against production:
- "Suspected Rogue VM" fires on spare GPU capacity, not rogue VMs: In_Use_Gpus
  equals the physical count on 71 of 75 firing hosts, so the rule reduces to
  "this host has a free GPU". Verified against OpenStack on 10 hosts.
- "Exists in Infrahub but does not exist in OpenStack" matches every VM because
  openstack_nova_server_status returns no series; excluded as a rule defect.
- Prometheus activeAt is reset several times a day by dips in the Resources
  metric, so alert ages are recovered from ALERTS history instead.

Takes ~2,650 firing alerts down to ~20 that need a decision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 06:48:34 +01:00
commit a039e0b5fd
23 changed files with 7192 additions and 0 deletions

4
triagelib/__init__.py Normal file
View File

@@ -0,0 +1,4 @@
"""CX Triage: read-only alert diagnosis on top of the CX-Tools (vmc) collectors."""
from __future__ import annotations
VERSION = "cx-triage 0.1"

421
triagelib/alerts.py Normal file
View File

@@ -0,0 +1,421 @@
"""Normalizes Prometheus alerts into the alert kinds the CX runbooks cover."""
from __future__ import annotations
import datetime as dt
import hashlib
import re
from dataclasses import dataclass, field
from typing import Any, Optional
EMOJI_RE = re.compile(r":[a-z0-9_+\-]+:")
THRESHOLD_RE = re.compile(r"greater than\s*(\d+)\s*min", re.I)
ORG_RE = re.compile(r"^\s*(\d+)\s*-\s*(.*)$")
NONE_VALUES = {"", "none", "null", "unknown", "n/a"}
# Alerts excluded outright. "Exists in Infrahub but does not exist in OpenStack"
# is built as `Resources unless on(openstack_id) openstack_nova_server_status`,
# and that right-hand metric is currently empty - so every Infrahub VM matches
# and the alert fires thousands of times. It is a monitoring fault, not a queue
# of work, so it never reaches the UI.
EXCLUDED_ALERTNAMES = frozenset({
"Exists in Infrahub but does not exist in OpenStack",
})
# Rule files whose alerts belong to CX. Everything else is infrastructure.
CX_RULE_FILES = ("infrahub-rules",)
NODE_RULE_FILE = "node-exporter-rules.yml"
# The order CX wants to work the queue in.
FOCUS_ORDER = (
"rogue_vm", "duplicate_ip", "total_gpus", "hibernating",
"creating", "shutoff", "deleting", "error",
"restoring", "rebooting", "build", "orphan_vm", "status_mismatch",
)
# Priority and estimated time to resolve, from the "Infrahub Errors
# Remediation" alert-conditions and runbook tables.
KIND_META: dict[str, dict[str, str]] = {
"error": {"title": "Instance in ERROR state", "priority": "LOW-HIGH", "ettr": "5-30 min", "delay": "none"},
"deleting": {"title": "Instance in DELETING state", "priority": "LOW", "ettr": "5-15 min", "delay": "30 min"},
"shutoff": {"title": "Instance in SHUTOFF state", "priority": "LOW", "ettr": "5-10 min", "delay": "30 min"},
"hibernating": {"title": "Instance in HIBERNATING state", "priority": "HIGH", "ettr": "5-30 min", "delay": "30 min"},
"creating": {"title": "Instance in CREATING state", "priority": "MEDIUM", "ettr": "5-15 min", "delay": "30 min"},
"restoring": {"title": "Instance in RESTORING state", "priority": "HIGH", "ettr": "5-15 min", "delay": "30 min"},
"rebooting": {"title": "Instance in REBOOTING state", "priority": "HIGH", "ettr": "5-15 min", "delay": "30 min"},
"build": {"title": "Instance in BUILD state", "priority": "MEDIUM", "ettr": "5-15 min", "delay": "30 min"},
"rogue_vm": {"title": "Suspected Rogue VM", "priority": "HIGH", "ettr": "5-30 min", "delay": "4 hours"},
"duplicate_ip": {"title": "Duplicated IPs", "priority": "HIGH", "ettr": "5-15 min", "delay": "10 min"},
"total_gpus": {"title": "Problem with Total GPUs in a System", "priority": "HIGH", "ettr": "5-15 min", "delay": "5 min"},
"orphan_vm": {"title": "Suspected Orphan VM", "priority": "HIGH", "ettr": "5-30 min", "delay": "4 hours"},
"status_mismatch": {"title": "Infrahub/OpenStack status mismatch", "priority": "HIGH", "ettr": "5-30 min", "delay": "30 min"},
}
STATE_KINDS = ("error", "deleting", "shutoff", "hibernating", "creating", "restoring", "rebooting", "build")
def clean_alertname(name: str) -> str:
"""Strip the Slack emoji shortcodes Prometheus embeds in alert names."""
return EMOJI_RE.sub("", str(name or "")).strip()
def classify(alertname: str) -> str:
name = clean_alertname(alertname).lower()
if alertname in EXCLUDED_ALERTNAMES or clean_alertname(alertname) in EXCLUDED_ALERTNAMES:
return "excluded"
if "rogue vm" in name:
return "rogue_vm"
if "orphan vm" in name:
return "orphan_vm"
if "duplicated ip" in name or "duplicate ip" in name:
return "duplicate_ip"
if "total gpus" in name:
return "total_gpus"
match = re.search(r"instance in (\w+) state", name)
if match:
state = match.group(1).lower()
if state in STATE_KINDS:
return state
# The per-region cross-check rules, e.g.
# "Openstack status=SHUTOFF and Infrahub status!=SHUTOFF in CANADA-1".
if "failure of hibernation" in name:
return "hibernating"
if "openstack status=" in name and "infrahub status" in name:
return "status_mismatch"
return "other"
def category(alertname: str, rule_file: str = "") -> str:
"""Which tab an alert belongs in: 'cx', 'node', or 'infra'."""
if any(token in rule_file for token in CX_RULE_FILES):
return "cx"
if rule_file == NODE_RULE_FILE:
return "node"
if rule_file:
return "infra"
# No rule metadata (e.g. a pasted alert): fall back to the classifier.
return "cx" if classify(alertname) not in ("other", "excluded") else "infra"
def _clean(value: Any) -> str:
text = str(value or "").strip()
return "" if text.lower() in NONE_VALUES else text
def split_organization(value: str) -> tuple[str, str]:
"""Split the `organization` label ("8463 - Some Org") into id and name."""
match = ORG_RE.match(str(value or ""))
if match:
return match.group(1), match.group(2).strip()
return "", _clean(value)
def _parse_active_at(value: Any) -> Optional[dt.datetime]:
text = str(value or "").strip()
if not text:
return None
text = re.sub(r"(\.\d{1,6})\d*Z?$", r"\1", text.replace("Z", "+00:00"))
if text.endswith("+00:00") is False and "+" not in text[10:]:
text += "+00:00"
try:
return dt.datetime.fromisoformat(text)
except ValueError:
return None
@dataclass
class Alert:
"""One normalized Prometheus alert."""
kind: str
alertname: str
labels: dict[str, str] = field(default_factory=dict)
annotations: dict[str, str] = field(default_factory=dict)
state: str = "firing"
active_at: Optional[dt.datetime] = None
# Fields the runbooks key off.
openstack_id: str = ""
instance_name: str = ""
host: str = ""
region_label: str = ""
region: str = ""
status: str = ""
floating_ip: str = ""
flavor_name: str = ""
flavor_gpu: str = ""
org_id: str = ""
org_name: str = ""
contract_id: str = ""
gpu_name: str = ""
threshold_min: Optional[int] = None
# Where the rule came from (from RuleIndex), and the screening result.
rule_file: str = ""
rule_group: str = ""
for_seconds: int = 0
category: str = "cx"
screen: dict[str, Any] = field(default_factory=dict)
# How long the condition has actually held, recovered from ALERTS history.
# activeAt alone is unreliable: a metric-pipeline dip resets it on every
# live alert at once, which is why raw ages cluster on one timestamp.
true_age_minutes: Optional[int] = None
true_age_capped: bool = False
@property
def title(self) -> str:
return KIND_META.get(self.kind, {}).get("title", clean_alertname(self.alertname))
@property
def priority(self) -> str:
return KIND_META.get(self.kind, {}).get("priority", "UNKNOWN")
@property
def ettr(self) -> str:
return KIND_META.get(self.kind, {}).get("ettr", "unknown")
@property
def is_kubernetes(self) -> bool:
"""Per the general process: kube* instance names are likely K8s nodes."""
return self.instance_name.lower().startswith("kube") or "-minion-" in self.instance_name.lower()
@property
def age_minutes(self) -> Optional[int]:
if not self.active_at:
return None
now = dt.datetime.now(dt.timezone.utc)
return max(0, int((now - self.active_at).total_seconds() // 60))
@staticmethod
def _duration_text(minutes: Optional[int]) -> str:
if minutes is None:
return "unknown"
if minutes < 60:
return f"{minutes}m"
hours, mins = divmod(minutes, 60)
if hours < 24:
return f"{hours}h {mins}m" if mins else f"{hours}h"
days, hours = divmod(hours, 24)
return f"{days}d {hours}h" if hours else f"{days}d"
@property
def age_text(self) -> str:
"""Raw Prometheus activeAt duration."""
return self._duration_text(self.age_minutes)
@property
def effective_age_minutes(self) -> Optional[int]:
"""True condition duration where known, else the raw activeAt age."""
return self.true_age_minutes if self.true_age_minutes is not None else self.age_minutes
@property
def effective_age_text(self) -> str:
text = self._duration_text(self.effective_age_minutes)
if self.true_age_minutes is not None and self.true_age_capped:
return f"{text}+"
return text
@property
def age_is_reset(self) -> bool:
"""True when activeAt materially understates how long this has held."""
if self.true_age_minutes is None or self.age_minutes is None:
return False
return self.true_age_minutes - self.age_minutes > 60
@property
def is_internal_org(self) -> bool:
"""Internal/test organizations are not customer-impacting."""
return "nexgencloud.com" in self.org_name.lower()
@property
def is_infra_owned(self) -> bool:
"""Platform-owned nodes (storage etc.) name themselves after their host."""
return bool(self.instance_name) and self.instance_name.lower() == self.host.lower()
def fingerprint(self) -> str:
basis = "|".join([
self.kind,
self.openstack_id or self.instance_name or "",
self.host,
self.floating_ip,
self.region,
])
return hashlib.sha1(basis.encode()).hexdigest()[:16]
def to_json(self) -> dict[str, Any]:
return {
"id": self.fingerprint(),
"kind": self.kind,
"title": self.title,
"alertname": clean_alertname(self.alertname),
"raw_alertname": self.alertname,
"state": self.state,
"priority": self.priority,
"ettr": self.ettr,
"active_at": self.active_at.isoformat() if self.active_at else "",
"age_minutes": self.age_minutes,
"age_text": self.age_text,
"true_age_minutes": self.true_age_minutes,
"true_age_capped": self.true_age_capped,
"effective_age_minutes": self.effective_age_minutes,
"effective_age_text": self.effective_age_text,
"age_is_reset": self.age_is_reset,
"threshold_min": self.threshold_min,
"rule_file": self.rule_file,
"rule_group": self.rule_group,
"for_seconds": self.for_seconds,
"category": self.category,
"screen": self.screen,
"is_internal_org": self.is_internal_org,
"is_infra_owned": self.is_infra_owned,
"openstack_id": self.openstack_id,
"instance_name": self.instance_name,
"host": self.host,
"region": self.region,
"region_label": self.region_label,
"status": self.status,
"floating_ip": self.floating_ip,
"flavor_name": self.flavor_name,
"flavor_gpu": self.flavor_gpu,
"gpu_name": self.gpu_name,
"org_id": self.org_id,
"org_name": self.org_name,
"contract_id": self.contract_id,
"is_kubernetes": self.is_kubernetes,
"labels": self.labels,
"annotations": self.annotations,
}
def map_region(region_label: str) -> str:
"""CANADA-1 -> ca1.
Deliberately a local table rather than a call into CX-Tools: building the
alert queue must not touch cxlib, because constructing a CX-Tools Config
loads credentials and would pop a 1Password prompt just to list alerts.
Kept in sync with SUPPORTED_REGIONS in cxlib/constants.py.
"""
fallback = {
"canada-1": "ca1", "canada-2": "ca2", "us-1": "us1", "norway-1": "no1",
"ca-1": "ca1", "ca-2": "ca2", "no-1": "no1",
"ca1": "ca1", "ca2": "ca2", "us1": "us1", "no1": "no1",
}
return fallback.get(str(region_label or "").strip().lower(), "")
def from_labels(labels: dict[str, str], annotations: Optional[dict[str, str]] = None,
state: str = "firing", active_at: Any = None) -> Alert:
labels = {str(k): str(v) for k, v in (labels or {}).items()}
alertname = labels.get("alertname", "")
kind = classify(alertname)
region_label = _clean(labels.get("region"))
org_id, org_name = split_organization(labels.get("organization", ""))
threshold = THRESHOLD_RE.search(alertname)
# For host-scoped alerts the `instance` label is the hypervisor; for
# VM-scoped alerts it is the hypervisor too, or "Unknown" when the VM never
# landed on a host.
host = _clean(labels.get("instance"))
alert = Alert(
kind=kind,
alertname=alertname,
labels=labels,
annotations={str(k): str(v) for k, v in (annotations or {}).items()},
state=str(state or "firing"),
active_at=_parse_active_at(active_at),
openstack_id=_clean(labels.get("openstack_id")),
instance_name=_clean(labels.get("instance_name")),
host=host,
region_label=region_label,
region=map_region(region_label) or _infer_region_from_host(host),
status=_clean(labels.get("status")),
floating_ip=_clean(labels.get("floating_ip")),
flavor_name=_clean(labels.get("flavor_name")),
flavor_gpu=_clean(labels.get("flavor_gpu")),
gpu_name=_clean(labels.get("gpu_name")),
org_id=org_id,
org_name=org_name,
contract_id=_clean(labels.get("contract_id")),
threshold_min=int(threshold.group(1)) if threshold else None,
)
return alert
def _infer_region_from_host(host: str) -> str:
match = re.match(r"^(ca1|ca2|no1|us1)-", str(host or "").strip(), re.I)
return match.group(1).lower() if match else ""
def from_prometheus(raw: dict[str, Any], rule_index: Any = None, true_age: Any = None) -> Alert:
alert = from_labels(
raw.get("labels") or {},
raw.get("annotations") or {},
state=str(raw.get("state") or "firing"),
active_at=raw.get("activeAt"),
)
meta = rule_index.get(alert.alertname) if rule_index is not None else {}
if meta:
alert.rule_file = str(meta.get("file") or "")
alert.rule_group = str(meta.get("group") or "")
alert.for_seconds = int(meta.get("for_seconds") or 0)
alert.category = category(alert.alertname, alert.rule_file)
if true_age is not None:
alert.true_age_minutes, alert.true_age_capped = true_age.lookup(alert.labels)
return alert
def cx_relevant(alert: Alert) -> bool:
"""True for alert kinds the CX runbooks cover."""
return alert.kind in KIND_META and alert.kind != "excluded"
def is_excluded(alert: Alert) -> bool:
return alert.kind == "excluded" or alert.alertname in EXCLUDED_ALERTNAMES
def sort_key(alert: Alert) -> tuple:
"""Newest first: a fresh alert is the one that still needs a decision.
Long-running alerts sink to the bottom - they are either chronic and
already ticketed, or noise nobody has silenced. Alerts with no known start
time sort last rather than jumping to the top.
Sorted on the true condition duration, not activeAt: a pipeline dip resets
activeAt on every live alert at once, which would otherwise flatten the
ordering into a single tie.
"""
age = alert.effective_age_minutes
return (age if age is not None else 10**9, alert.title)
def focus_rank(kind: str) -> int:
try:
return FOCUS_ORDER.index(kind)
except ValueError:
return len(FOCUS_ORDER)
def group_alerts(items: list[Alert]) -> list[dict[str, Any]]:
"""Group alerts into collapsible sections, in CX's working order."""
buckets: dict[str, list[Alert]] = {}
for alert in items:
buckets.setdefault(alert.kind, []).append(alert)
groups: list[dict[str, Any]] = []
for kind, members in buckets.items():
members.sort(key=sort_key)
actionable = [a for a in members if a.screen.get("actionable", True)]
groups.append({
"kind": kind,
"title": KIND_META.get(kind, {}).get("title", kind),
"priority": KIND_META.get(kind, {}).get("priority", "UNKNOWN"),
"ettr": KIND_META.get(kind, {}).get("ettr", "unknown"),
"total": len(members),
"actionable": len(actionable),
"noise": len(members) - len(actionable),
"alerts": [a.to_json() for a in members],
})
groups.sort(key=lambda g: (focus_rank(g["kind"]), -g["actionable"]))
return groups

383
triagelib/comms.py Normal file
View File

@@ -0,0 +1,383 @@
"""Customer comms templates, transcribed from the CX runbooks.
Wording is kept verbatim from Confluence so what CX sends stays consistent with
the approved snippets; only the named placeholders are substituted. Nothing here
sends anything - the app renders the draft for a human to review and send from
HubSpot.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Optional
INSTANCE_PLACEHOLDER = "INFRAHUB_INSTANCE_NAME"
FIP_PLACEHOLDER = "NEW_INFRAHUB_FLOATING_IP"
ID_PLACEHOLDER = "INFRAHUB_ID"
OSID_PLACEHOLDER = "OPENSTACK_ID"
NAME_PLACEHOLDER = "GREETING_NAME"
AGENT_PLACEHOLDER = "AGENT_NAME"
def first_name(owner: str) -> str:
"""'Bojan Jovanovic <bojan@polycam.ai>' -> 'Bojan'.
Falls back to an empty greeting rather than guessing: the examples show
'Hello,' is acceptable, but 'Hello shettyatulya@gmail.com,' is not.
"""
text = str(owner or "").split("<", 1)[0].strip()
if not text or "@" in text:
return ""
first = text.split()[0]
return first if first[:1].isalpha() else ""
@dataclass
class Draft:
template_id: str
label: str
subject: str
body: str
channel: str = "HubSpot ticket"
when: str = ""
unfilled: list[str] = field(default_factory=list)
source: str = ""
def to_json(self) -> dict[str, Any]:
return {
"template_id": self.template_id,
"label": self.label,
"subject": self.subject,
"body": self.body,
"channel": self.channel,
"when": self.when,
"unfilled": self.unfilled,
"source": self.source,
}
# Wording follows the house style CX actually sends: first-name greeting, the VM
# named with its Infrahub ID, an explicit "you will not be charged" line, the
# billing-states link, and a personal sign-off. Placeholders are substituted;
# everything else is left alone so what goes out stays consistent.
BILLING_DOC = ("Here is our documentation on VM states and their cost:\n"
"Which virtual machine states incur billing costs?")
STOCK_DOC = ("You can use our Stock API to check availability at the time of deploying a VM here - "
"Stock Availability")
_TEMPLATES: dict[str, dict[str, str]] = {
"error_never_active": {
"label": "ERROR - never deployed (transient stock issue)",
"subject": "VM in Error state",
"when": "The instance never reached a host, so nothing was built. Recommend delete and retry.",
"source": "Instance in ERROR state",
"body": f"""Hello GREETING_NAME,
We hope you are well.
We are emailing you in regards to VM: INFRAHUB_INSTANCE_NAME (INFRAHUB_ID)
Due to a transient stock issue this VM never fully deployed and is currently showing in Error.
Our recommendation would be to delete the VM and try recreating a new one. Please be aware that whilst the VM is in an Error state you will not be charged for its usage.
{BILLING_DOC}
{STOCK_DOC}
Just so you are aware, if the VM is not deleted after 14 calendar days we will proceed with deleting the VM on your behalf.
Kind Regards,
AGENT_NAME""",
},
"error_was_active": {
"label": "ERROR - VM had been running, escalated",
"subject": "VM in Error state",
"when": "The instance had reached ACTIVE, so customer data may be involved. Escalate first, then send.",
"source": "Instance in ERROR state",
"body": f"""Hello GREETING_NAME,
We hope you are well.
We are emailing you in regards to VM: INFRAHUB_INSTANCE_NAME (INFRAHUB_ID)
We can see this VM has gone into an Error state. We have escalated this to the appropriate team on your behalf and will come back to you as soon as we have more information.
Please be assured that whilst a VM is in an Error state you will not be charged for its usage.
{BILLING_DOC}
Kind Regards,
AGENT_NAME""",
},
"creating": {
"label": "CREATING - stuck on deploy, VM deleted for the customer",
"subject": "VM stuck in creating state",
"when": "Send after the stuck instance has been deleted.",
"source": "Instance in CREATING state",
"body": f"""Hello GREETING_NAME,
We hope you are well.
We are emailing you in regards to VM INFRAHUB_INSTANCE_NAME (INFRAHUB_ID).
We can see you tried to deploy this VM but due to a transient error the VM got stuck in a creating state.
Unfortunately as the VM was not able to fully deploy, the safest option was to delete the VM which we have actioned for you.
Please be assured that whilst a VM is in a creating state you will not be charged for it's usage.
{BILLING_DOC}
If you have any queries please let us know.
Kind Regards,
AGENT_NAME""",
},
"deleting": {
"label": "DELETING - stuck delete finalised for the customer",
"subject": "VM stuck in deleting state",
"when": "Send once the delete has actually been finalised.",
"source": "Instance in DELETING state",
"body": f"""Hello GREETING_NAME,
We hope you are well.
We are emailing you in regards to VM: INFRAHUB_INSTANCE_NAME (INFRAHUB_ID)
We can see one of your users requested the deletion and that due to a transient error the VM got stuck in a deleting state. I wanted to make sure you are aware that we have gone and finalised the deletion for you.
Please be assured that whilst a VM is in a deleting state you will not be charged for it's usage.
{BILLING_DOC}
Kind Regards,
AGENT_NAME""",
},
"deleting_resolved": {
"label": "DELETING - confirming resolution and closing the ticket",
"subject": "VM deleted - closing this ticket",
"when": "Use when replying on an existing ticket that can now be closed.",
"source": "Instance in DELETING state",
"body": """Hello GREETING_NAME,
Upon reviewing this ticket, we found that the VM below, which was previously stuck in a DELETING state, has now been deleted:
* OPENSTACK_ID (INFRAHUB_ID)
As the VM has been deleted, we are marking the issue as resolved and closing this ticket.
If you require further assistance, please feel free to contact us at support@hyperstack.cloud or open a new Live Chat via the Hyperstack Console.
Have a great rest of your day and thank you for using Hyperstack.
Kind regards,
AGENT_NAME""",
},
"build": {
"label": "BUILD - failed to build, escalated",
"subject": "VM stuck in build state",
"when": "Send once escalated to Infrastructure. The instance must be recreated.",
"source": "Instance in BUILD state",
"body": f"""Hello GREETING_NAME,
We hope you are well.
We are emailing you in regards to VM INFRAHUB_INSTANCE_NAME (INFRAHUB_ID).
We can see you tried to deploy this VM but due to a transient error it did not finish building. We have escalated this on your behalf and ask that you retry creating the instance at your convenience.
Please be assured that whilst a VM is in this state you will not be charged for it's usage.
{BILLING_DOC}
If you have any queries please let us know.
Kind Regards,
AGENT_NAME""",
},
"rebooting": {
"label": "REBOOTING - reboot failed, now resolved",
"subject": "VM stuck rebooting",
"when": "Send only once the instance is confirmed ACTIVE in both Infrahub and OpenStack.",
"source": "Instance in REBOOTING state",
"body": f"""Hello GREETING_NAME,
We hope you are well.
We are emailing you in regards to VM INFRAHUB_INSTANCE_NAME (INFRAHUB_ID).
We can see a reboot was requested but due to a transient error the VM got stuck. This has now been resolved and you may retry rebooting at your convenience.
{BILLING_DOC}
If you have any queries please let us know.
Kind Regards,
AGENT_NAME""",
},
"restoring": {
"label": "RESTORING - restore failed, now resolved",
"subject": "VM stuck restoring",
"when": "Send after the instance is back to SHELVED_OFFLOADED in OpenStack and HIBERNATED in Infrahub.",
"source": "Instance in RESTORING state",
"body": f"""Hello GREETING_NAME,
We hope you are well.
We are emailing you in regards to VM INFRAHUB_INSTANCE_NAME (INFRAHUB_ID).
We can see you tried to restore this VM but due to a transient error it got stuck. This has now been resolved and you may retry restoring at your convenience.
{BILLING_DOC}
If you have any queries please let us know.
Kind Regards,
AGENT_NAME""",
},
"shutoff": {
"label": "SHUTOFF - billing awareness notice",
"subject": "VM in SHUT-OFF state is still accruing costs",
"when": "Send as-is. A HubSpot snippet also exists: type #shutoff.",
"source": "Instance in SHUTOFF state",
"body": f"""Hello GREETING_NAME,
We hope you are well.
We are emailing you in regards to VM: INFRAHUB_INSTANCE_NAME (INFRAHUB_ID)
We can see that this is in a SHUT-OFF state and wanted to make sure you are aware that in this state the VM is still accruing full costs.
{BILLING_DOC}
Kind Regards,
AGENT_NAME""",
},
"dupip_removed": {
"label": "Duplicated IP - incorrect IP removed, customer must attach a new one",
"subject": "Instance assigned an incorrect public IP",
"when": "The VM has no floating IP in OpenStack and the stale IP was removed in InfraInsight.",
"source": "Duplicated IPs",
"body": """Hello GREETING_NAME,
We hope you are well.
We are emailing you in regards to VM: INFRAHUB_INSTANCE_NAME (INFRAHUB_ID)
Due to a transient synchronisation issue this instance was showing an incorrect public IP. This has now been corrected. To restore external connectivity the instance will need a new public IP attached, which you can do at your convenience using either the Hyperstack UI or API.
We apologise for any inconvenience this may have caused.
Kind Regards,
AGENT_NAME""",
},
"dupip_corrected": {
"label": "Duplicated IP - Infrahub corrected to match OpenStack",
"subject": "Instance assigned an incorrect public IP",
"when": "The VM does have a floating IP in OpenStack and Infrahub was corrected to match.",
"source": "Duplicated IPs",
"body": """Hello GREETING_NAME,
We hope you are well.
We are emailing you in regards to VM: INFRAHUB_INSTANCE_NAME (INFRAHUB_ID)
Due to a transient synchronisation issue this instance was showing an incorrect public IP. This has now been resolved and your instance is reachable at NEW_INFRAHUB_FLOATING_IP.
We apologise for any inconvenience this may have caused.
Kind Regards,
AGENT_NAME""",
},
"sync_state": {
"label": "Rogue VM - instance was in the incorrect state",
"subject": "VM was showing an incorrect state",
"when": "Send after the state mismatch has been remediated.",
"source": "Suspected Rogue VM",
"body": """Hello GREETING_NAME,
We hope you are well.
We are emailing you in regards to VM: INFRAHUB_INSTANCE_NAME (INFRAHUB_ID)
Due to a transient sync error this instance was showing an incorrect state. This has since been resolved, and we ask that you retry any operations that failed as a result.
We apologise for any delay this may have caused.
Kind Regards,
AGENT_NAME""",
},
}
def draft(template_id: str, *, instance_name: str = "", floating_ip: str = "",
infrahub_id: str = "", openstack_id: str = "", greeting_name: str = "",
agent_name: str = "", note: Optional[str] = None) -> Optional[Draft]:
spec = _TEMPLATES.get(template_id)
if not spec:
return None
body = spec["body"]
unfilled: list[str] = []
# "Hello Bojan," when we know the name, "Hello," when we do not - never
# "Hello <placeholder>,".
body = body.replace(f"Hello {NAME_PLACEHOLDER},", f"Hello {greeting_name}," if greeting_name else "Hello,")
substitutions = {
INSTANCE_PLACEHOLDER: instance_name,
ID_PLACEHOLDER: infrahub_id,
OSID_PLACEHOLDER: openstack_id,
FIP_PLACEHOLDER: floating_ip,
AGENT_PLACEHOLDER: agent_name,
}
for placeholder, value in substitutions.items():
if placeholder not in body:
continue
if value and value not in ("N/A", "None"):
body = body.replace(placeholder, str(value))
else:
unfilled.append(placeholder)
# "(INFRAHUB_ID)" with nothing to put in it reads worse than no bracket.
if ID_PLACEHOLDER in unfilled:
body = body.replace(f" ({ID_PLACEHOLDER})", "").replace(f"({ID_PLACEHOLDER})", "")
unfilled.remove(ID_PLACEHOLDER)
when = spec["when"]
if note:
when = f"{when} {note}".strip()
return Draft(
template_id=template_id,
label=spec["label"],
subject=spec["subject"],
body=body,
when=when,
unfilled=unfilled,
source=spec["source"],
)
def contacts_from_result(result: dict[str, Any]) -> dict[str, Any]:
"""Pull the organization and owner contacts CX-Tools resolved for a VM."""
if not isinstance(result, dict):
return {"organization": "", "owners": [], "resolved": False}
org = str(result.get("org_value") or "").strip()
owners = [str(x) for x in (result.get("owners") or []) if str(x).strip()]
return {
"organization": org if org and org != "N/A" else "",
"owners": owners,
"resolved": bool(owners),
}

286
triagelib/cxbridge.py Normal file
View File

@@ -0,0 +1,286 @@
"""Read-only adapter over the CX-Tools (vmc) collectors.
CX-Tools is imported as an unmodified library: this module never writes to the
CX-Tools tree and only calls collectors and query helpers that read. Every
OpenStack subcommand this module can reach is checked against READ_ONLY_VERBS
before it runs, so a bug here cannot mutate a live instance.
"""
from __future__ import annotations
import os
import sys
import threading
from typing import Any, Optional
DEFAULT_CX_TOOLS_PATHS = (
os.environ.get("CX_TOOLS_PATH", ""),
os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "CX-Tools"),
os.path.expanduser("~/scripts/CX-Tools"),
os.path.expanduser("~/scripts/cx-tooling"),
)
# OpenStack verbs the diagnosis path is allowed to reach. Anything that could
# change state (create/delete/set/unset/shelve/reboot/...) is absent on purpose.
READ_ONLY_VERBS = frozenset({"show", "list"})
class BridgeError(RuntimeError):
"""Raised when CX-Tools cannot be located, imported, or authenticated."""
def locate_cx_tools() -> str:
for candidate in DEFAULT_CX_TOOLS_PATHS:
if not candidate:
continue
path = os.path.abspath(os.path.expanduser(candidate))
if os.path.isfile(os.path.join(path, "cxlib", "__init__.py")):
return path
raise BridgeError(
"Could not find the CX-Tools checkout. Set CX_TOOLS_PATH to the directory "
"that contains cxlib/ and the vmc entry point."
)
_lock = threading.Lock()
_state: dict[str, Any] = {"path": "", "cx": None, "config": None}
def _import_cxlib(path: str):
if path not in sys.path:
sys.path.insert(0, path)
try:
import cxlib # noqa: PLC0415 (import is intentionally deferred)
except Exception as exc: # pragma: no cover - depends on the local checkout
raise BridgeError(f"Failed to import cxlib from {path}: {exc}") from exc
return cxlib
def bootstrap() -> tuple[Any, Any]:
"""Import cxlib and build the shared Config, loading secrets exactly once.
Call this from the foreground at startup: constructing Config triggers the
CX-Tools 1Password loader, which may need an interactive sign-in.
"""
with _lock:
if _state["config"] is not None:
return _state["cx"], _state["config"]
path = locate_cx_tools()
cx = _import_cxlib(path)
config = cx.Config(no_color=True, debug=bool(os.environ.get("CX_DEBUG")))
if not config.api_key or config.api_key in {"REDACT", "REPLACE_WITH_API_KEY"}:
raise BridgeError(
"CX-Tools could not load the Infrahub API key from 1Password. "
"Run `op signin` in this shell, then restart cx-triage."
)
_state.update({"path": path, "cx": cx, "config": config})
return cx, config
def cx() -> Any:
return bootstrap()[0]
def config() -> Any:
return bootstrap()[1]
def cx_tools_path() -> str:
bootstrap()
return str(_state["path"])
def quiet_progress() -> Any:
"""A Progress object that renders nothing, for use off the terminal."""
c = cx()
return c.Progress(c.C(False), 1, enabled=False)
def _guard_openstack(args: list[str]) -> None:
verbs = [a for a in args if not str(a).startswith("-")]
if not any(v in READ_ONLY_VERBS for v in verbs):
raise BridgeError(f"Refusing to run a non-read-only OpenStack command: {' '.join(args)}")
def os_json(region: str, args: list[str], timeout: int = 90) -> tuple[bool, Any, str]:
"""Run a read-only `openstack ... -f json` command through CX-Tools."""
_guard_openstack(args)
return cx().os_json(config(), region, args, timeout=timeout)
# --- collectors -------------------------------------------------------------
def collect_vm(target: str, *, region: str = "", org_id: Optional[str] = None, ssh_timeout: int = 3) -> dict[str, Any]:
"""Full VM reconciliation: the same payload `vmc --json <target>` emits."""
return cx().collect_vm(
config(),
target,
region_arg=region or "",
org_id=org_id,
ssh_timeout=ssh_timeout,
include_ih_events=True,
include_volumes=True,
include_all_ih_events=True,
progress=quiet_progress(),
)
def collect_host(host: str, *, ssh_timeout: int = 3) -> dict[str, Any]:
"""Host reconciliation: the same payload `vmc --json --host <host>` emits."""
return cx().collect_host(
config(),
host,
ssh_timeout=ssh_timeout,
include_ih_events=True,
include_volumes=True,
progress=quiet_progress(),
)
def collect_vm_contacts(target: str, *, region: str = "", org_id: Optional[str] = None) -> dict[str, Any]:
return cx().collect_vm_contacts(
config(),
target,
region_arg=region or "",
org_id=org_id,
progress=quiet_progress(),
)
# --- targeted queries used by individual runbooks --------------------------
def openstack_events(region: str, openstack_id: str, limit: Optional[int] = 5) -> list[dict[str, Any]]:
ok, events, _raw = cx().server_event_list(config(), region, openstack_id)
if not ok:
return []
return events[:limit] if limit else events
def openstack_event_detail(region: str, openstack_id: str, request_id: str) -> dict[str, Any]:
ok, detail, _raw = cx().server_event_show(config(), region, openstack_id, request_id)
return detail if ok else {}
def failed_openstack_event(region: str, openstack_id: str, scan: int = 5) -> dict[str, Any]:
"""Return the most recent OpenStack event whose detail reports a failure.
The state runbooks all say "the most recent failed event is the thing to
escalate", so this walks recent events newest-first and returns the first
one whose result is not Success, together with its detail rows.
"""
c = cx()
for event in openstack_events(region, openstack_id, limit=scan):
request_id = c.event_request_id(event)
if not request_id:
continue
detail = openstack_event_detail(region, openstack_id, request_id)
if not detail:
continue
rows = dict((str(k), str(v)) for k, v in c.event_detail_rows(detail))
result = rows.get("Result", "")
if result and result.lower() != "success":
return {"request_id": request_id, "action": rows.get("Action", ""), "rows": rows}
return {}
def infrahub_events(infrahub_id: str, limit: Optional[int] = None) -> list[list[str]]:
c = cx()
ok, data, _raw = c.query_vm_events(config(), str(infrahub_id))
if not ok:
return []
return c.infrahub_event_rows(data, limit)
def host_health(region: str, host: str) -> dict[str, Any]:
"""Hypervisor, Nova service and OVS agent signals for one host.
This is the cheap subset of `vmc --host` - the runbooks' "Host Health
Checks" entry point - without collecting every instance on the host.
"""
c = cx()
cfg = config()
ok_hv, hv, raw_hv, hv_name = c.hypervisor_show_host(cfg, region, host)
if not ok_hv:
return {"ok": False, "error": raw_hv, "host": host, "region": region}
state = c.normalize_empty(c.first_present(hv, "state", "State", default=""))
status = c.normalize_empty(c.first_present(hv, "status", "Status", default=""))
disabled_reason = ""
if status.lower() == "disabled":
for candidate in dict.fromkeys([x for x in (c.normalize_empty(hv_name), host) if x]):
ok_svc, services, _raw = c.compute_service_list_host(cfg, region, candidate)
if ok_svc:
disabled_reason = c.disabled_reason_from_services(services)
if disabled_reason:
break
ovs: dict[str, Any] = {}
ok_agents, agents, _raw_agents = c.network_agent_list_host(cfg, region, host)
if ok_agents:
ovs = c.ovs_agent_summary(agents)
if ovs.get("agent_id"):
ok_show, detail, _raw_show = c.network_agent_show(cfg, region, str(ovs["agent_id"]))
if ok_show:
ovs["last_heartbeat_at"] = c.normalize_empty(
detail.get("last_heartbeat_at") or detail.get("Last Heartbeat At") or ovs.get("last_heartbeat_at")
)
return {
"ok": True,
"error": "",
"host": host,
"hypervisor_name": hv_name,
"region": region,
"nova_state": state or "N/A",
"nova_status": status or "N/A",
"disabled_reason": disabled_reason,
"uptime": c.host_uptime_summary(hv),
"aggregates": c.host_aggregates_summary(hv),
"ovs_alive": ovs.get("alive"),
"ovs_state": ovs.get("state"),
"ovs_last_heartbeat": ovs.get("last_heartbeat_at") or "",
"running_vms": c.normalize_empty(c.first_present(hv, "running_vms", "Running VMs", default="")) or "N/A",
"free_disk_gb": c.normalize_empty(c.first_present(hv, "free_disk_gb", "Free Disk GB", default="")) or "N/A",
"local_disk_free": c.normalize_empty(c.first_present(hv, "disk_available_least", "Disk Available Least", default="")) or "N/A",
}
def host_gpu_census(region: str, host: str) -> dict[str, Any]:
"""Sum GPU counts of every instance on a host.
Implements the ERROR-runbook check for the NUMA/PCI fault: "check if the
host is full prior to escalation - add the values after the x, if it = 8
then it is FULL".
"""
c = cx()
ok, rows, raw = c.server_list_on_host(config(), region, host)
if not ok:
return {"ok": False, "error": raw, "total_gpus": None, "instances": []}
total = 0
unknown = 0
instances: list[dict[str, str]] = []
for row in rows:
flavor = c.get_row_field(row, "Flavor", "flavor") or c.flavor_name_from_any(row)
count = c.gpu_count_from_flavor_name(flavor)
if count.isdigit():
total += int(count)
else:
unknown += 1
instances.append({
"name": c.get_row_field(row, "Name", "name") or "N/A",
"openstack_id": c.openstack_id_from_row(row) or "N/A",
"status": c.get_row_field(row, "Status", "status") or "N/A",
"flavor": flavor or "N/A",
"gpus": count,
})
return {
"ok": True,
"error": "",
"total_gpus": total,
"unknown_flavors": unknown,
"instances": instances,
}
def json_safe(obj: Any) -> Any:
return cx().json_safe(obj)

196
triagelib/integrations.py Normal file
View File

@@ -0,0 +1,196 @@
"""Outbound action payloads: Zendesk tickets and Jira issues.
This module *builds* payloads and never sends them. Delivery is a separate,
explicitly configured step - see `outbox.py` - so that a diagnosis can never
contact a customer as a side effect of being viewed.
Every payload carries the evidence that justified it, so the ticket a customer
or the Infrastructure team receives is self-contained.
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from typing import Any, Optional
# Set these to enable the Send buttons. Absent = preview only.
ZENDESK_SUBDOMAIN = os.environ.get("CX_ZENDESK_SUBDOMAIN", "")
ZENDESK_EMAIL = os.environ.get("CX_ZENDESK_EMAIL", "")
ZENDESK_TOKEN = os.environ.get("CX_ZENDESK_TOKEN", "")
JIRA_BASE = os.environ.get("CX_JIRA_BASE", "https://nexgencloud.atlassian.net")
JIRA_EMAIL = os.environ.get("CX_JIRA_EMAIL", "")
JIRA_TOKEN = os.environ.get("CX_JIRA_TOKEN", "")
JIRA_PROJECT = os.environ.get("CX_JIRA_PROJECT", "INFRA")
PRIORITY_BY_VERDICT = {"overdue": "high", "real": "normal", "unverified": "low"}
def zendesk_configured() -> bool:
return bool(ZENDESK_SUBDOMAIN and ZENDESK_EMAIL and ZENDESK_TOKEN)
def jira_configured() -> bool:
return bool(JIRA_BASE and JIRA_EMAIL and JIRA_TOKEN)
@dataclass
class Action:
"""One proposed outbound action, ready to send once a human confirms."""
id: str
kind: str # zendesk | jira | manual
label: str
summary: str # one line: what this does
payload: dict[str, Any] = field(default_factory=dict)
recipients: list[str] = field(default_factory=list)
enabled: bool = False # is the integration configured?
blocked_reason: str = ""
requires_confirmation: bool = True
def to_json(self) -> dict[str, Any]:
return {
"id": self.id, "kind": self.kind, "label": self.label, "summary": self.summary,
"payload": self.payload, "recipients": self.recipients, "enabled": self.enabled,
"blocked_reason": self.blocked_reason, "requires_confirmation": self.requires_confirmation,
}
def _parse_owner(owner: str) -> tuple[str, str]:
"""'Name <email@x>' -> ('Name', 'email@x')."""
text = str(owner or "").strip()
if "<" in text and ">" in text:
name = text.split("<", 1)[0].strip()
email = text.split("<", 1)[1].split(">", 1)[0].strip()
return name, email
return ("", text) if "@" in text else (text, "")
def _evidence_block(diagnosis: Any) -> str:
lines = [f"Alert: {diagnosis.alert.title}", f"Verdict: {diagnosis.verdict}", ""]
for finding in diagnosis.findings[:16]:
lines.append(f"- {finding.label}: {finding.value}")
return "\n".join(lines)
def build_zendesk(diagnosis: Any) -> Optional[Action]:
"""A customer ticket, only when the runbook actually calls for contact."""
if not diagnosis.drafts:
return None
draft = diagnosis.drafts[0]
alert = diagnosis.alert
contacts = diagnosis.contacts or {}
owners = contacts.get("owners") or []
if not owners:
return Action(
id="zendesk", kind="zendesk", label="Contact customer (Zendesk)",
summary="No owner contact resolved from Infrahub - look the organization up first.",
enabled=False, blocked_reason="No customer contact could be resolved.",
)
name, email = _parse_owner(owners[0])
verdict = (alert.screen or {}).get("verdict", "real")
payload = {
"ticket": {
"subject": draft.subject,
"comment": {"body": draft.body, "public": True},
"requester": {"name": name or email, "email": email},
"priority": PRIORITY_BY_VERDICT.get(verdict, "normal"),
"type": "incident",
"tags": ["cx-triage", f"alert-{alert.kind}", f"region-{alert.region or 'unknown'}"],
"external_id": f"cx-triage-{alert.fingerprint()}",
"custom_fields_note": {
"instance_name": alert.instance_name,
"openstack_id": alert.openstack_id,
"organization": alert.org_name,
"infrahub_org_id": alert.org_id,
},
},
"_template": draft.template_id,
"_when": draft.when,
"_unfilled": draft.unfilled,
}
blocked = ""
if draft.unfilled:
blocked = f"Template still has placeholders: {', '.join(draft.unfilled)}"
return Action(
id="zendesk", kind="zendesk",
label="Contact customer (Zendesk)",
summary=f"Public reply to {name or email} - {draft.label}",
payload=payload,
recipients=[o for o in owners],
enabled=zendesk_configured() and not blocked,
blocked_reason=blocked or ("" if zendesk_configured() else "Zendesk is not configured."),
)
def build_jira(diagnosis: Any) -> Optional[Action]:
"""An Infrastructure escalation, only when a step is owned by Infra."""
infra_steps = [a for a in diagnosis.actions if a.owner != "CX" and a.kind == "escalate"]
if not infra_steps:
return None
alert = diagnosis.alert
subject = alert.host or alert.instance_name or alert.floating_ip or "unknown"
description = "\n".join([
_evidence_block(diagnosis),
"",
"Requested of Infrastructure:",
*[f"- {s.text}" for s in infra_steps],
"",
f"Raised from CX Triage. Alert has held for {alert.effective_age_text}.",
])
payload = {
"fields": {
"project": {"key": JIRA_PROJECT},
"summary": f"{subject}: {diagnosis.verdict}"[:250],
"description": description,
"issuetype": {"name": "Task"},
"labels": ["cx-triage", f"alert-{alert.kind}", f"region-{alert.region or 'unknown'}"],
}
}
return Action(
id="jira", kind="jira",
label="Escalate to Infrastructure (Jira)",
summary=f"Create a {JIRA_PROJECT} issue for {subject}",
payload=payload,
enabled=jira_configured(),
blocked_reason="" if jira_configured() else "Jira is not configured.",
)
def build_manual(diagnosis: Any) -> list[Action]:
"""Steps a human must perform; surfaced as copyable commands, not buttons."""
out: list[Action] = []
alert = diagnosis.alert
osid = alert.openstack_id
region = alert.region
for step in diagnosis.actions:
if step.kind != "remediate" or step.status == "done":
continue
command = ""
low = step.text.lower()
if "delete the server in openstack" in low and osid and region:
command = f"{region} server delete {osid}"
elif "shelve" in low and osid and region:
command = f"{region} server shelve {osid}"
out.append(Action(
id=f"manual-{len(out)}", kind="manual", label=step.text,
summary=step.guide or "", payload={"command": command} if command else {},
enabled=False, blocked_reason="Perform manually - this tool is read-only.",
requires_confirmation=False,
))
return out
def build_all(diagnosis: Any) -> dict[str, Any]:
actions: list[Action] = []
for builder in (build_zendesk, build_jira):
action = builder(diagnosis)
if action:
actions.append(action)
actions.extend(build_manual(diagnosis))
return {
"actions": [a.to_json() for a in actions],
"zendesk_configured": zendesk_configured(),
"jira_configured": jira_configured(),
}

233
triagelib/linkage.py Normal file
View File

@@ -0,0 +1,233 @@
"""Linkage analysis: Infrahub records that lost their OpenStack server, and
OpenStack servers that no Infrahub record claims.
A VM in ERROR is not always a failed build. Sometimes the build succeeded and
only the *link* between Infrahub and OpenStack was never written - so Infrahub
reports ERROR (or CREATING) with no usable openstack_id while a perfectly good
server of the same name is running. Those look identical on an alert dashboard
and are opposite problems: one needs a rebuild, the other needs a record fixed
and is quietly billing nobody.
This scans both sides in bulk and pairs them up by name.
It also finds the reverse - OpenStack servers with no Infrahub record at all -
which is what `Suspected Orphan VM` was meant to catch before its input metric
went empty.
"""
from __future__ import annotations
import difflib
import threading
import time
from typing import Any, Optional
from . import cxbridge
REGIONS = ("ca1", "ca2", "us1", "no1")
# Infrahub states where a missing OpenStack link is suspicious rather than normal.
# HIBERNATED is excluded: shelved instances legitimately have no running server.
UNLINKED_SUSPECT_STATES = {"ERROR", "CREATING", "BUILD", "ACTIVE", "REBOOTING", "RESTORING"}
# Projects to ignore, matching the CX-Tools tempest suppression.
def _is_ignorable(name: str) -> bool:
low = str(name or "").lower()
return "tempest" in low
_REGION_ALIAS = {"canada-1": "ca1", "canada-2": "ca2", "us-1": "us1", "norway-1": "no1",
"ca1": "ca1", "ca2": "ca2", "us1": "us1", "no1": "no1"}
def _region_alias(region: str) -> str:
return _REGION_ALIAS.get(str(region or "").strip().lower(), "")
def _norm(name: str) -> str:
return str(name or "").strip().lower()
class Scan:
"""One full cross-region scan. Slow (a server list per region), so cached."""
def __init__(self):
self.started = 0.0
self.finished = 0.0
self.state = "idle" # idle | running | done | error
self.error = ""
self.progress = ""
self.result: dict[str, Any] = {}
self._lock = threading.Lock()
def to_json(self) -> dict[str, Any]:
return {
"state": self.state, "error": self.error, "progress": self.progress,
"started": self.started, "finished": self.finished,
"age_seconds": int(time.time() - self.finished) if self.finished else None,
"result": self.result,
}
def run(self, snapshot: Any, regions: tuple[str, ...] = REGIONS) -> None:
with self._lock:
if self.state == "running":
return
self.state = "running"
self.started = time.time()
self.error = ""
self.progress = "starting"
try:
self.result = self._scan(snapshot, regions)
self.state = "done"
except Exception as exc: # surfaced in the UI rather than crashing the server
self.state = "error"
self.error = f"{type(exc).__name__}: {exc}"
finally:
self.finished = time.time()
self.progress = ""
# --- the analysis ------------------------------------------------------
def _scan(self, snapshot: Any, regions: tuple[str, ...]) -> dict[str, Any]:
# 1. Everything OpenStack has, per region.
os_by_id: dict[str, dict[str, str]] = {}
os_by_name: dict[str, list[dict[str, str]]] = {}
region_counts: dict[str, int] = {}
failures: dict[str, str] = {}
for region in regions:
self.progress = f"listing OpenStack servers in {region}"
ok, rows, raw = _server_list(region)
if not ok:
failures[region] = raw[:200]
continue
region_counts[region] = len(rows)
for row in rows:
sid = str(row.get("ID") or "")
if not sid:
continue
rec = {
"id": sid,
"name": str(row.get("Name") or ""),
"status": str(row.get("Status") or ""),
"task": str(row.get("Task State") or ""),
"host": str(row.get("Host") or ""),
"project_id": str(row.get("Project ID") or ""),
"flavor": str(row.get("Flavor") or ""),
"region": region,
}
os_by_id[sid] = rec
os_by_name.setdefault(_norm(rec["name"]), []).append(rec)
# 2. Everything Infrahub has, from the bulk metric snapshot.
self.progress = "comparing against Infrahub"
infrahub = list(snapshot.by_openstack_id.values()) + list(snapshot.by_instance_name.values())
seen: set[str] = set()
ih_records: list[dict[str, str]] = []
for row in infrahub:
key = f"{row.get('openstack_id','')}|{row.get('instance_name','')}"
if key in seen:
continue
seen.add(key)
ih_records.append(row)
ih_osids = {str(r.get("openstack_id") or "") for r in ih_records if r.get("openstack_id")}
ih_osids.discard("")
ih_osids.discard("None")
# 3a. Infrahub records whose OpenStack server is missing or never linked.
#
# Only records in a region that was actually listed can be judged: if a
# region failed, every VM in it would look "missing from OpenStack".
scanned = set(region_counts)
skipped_unscanned = 0
broken_links: list[dict[str, Any]] = []
for row in ih_records:
status = str(row.get("status") or "").upper()
if status not in UNLINKED_SUSPECT_STATES:
continue
if _region_alias(str(row.get("region") or "")) not in scanned:
skipped_unscanned += 1
continue
osid = str(row.get("openstack_id") or "")
has_link = bool(osid) and osid != "None"
if has_link and osid in os_by_id:
continue # properly linked, nothing to see
name = str(row.get("instance_name") or "")
if _is_ignorable(name):
continue
candidates = os_by_name.get(_norm(name), [])
# An exact-name server that nothing else claims is a very strong
# candidate for the link that was never written.
unclaimed = [c for c in candidates if c["id"] not in ih_osids]
match = unclaimed[0] if unclaimed else (candidates[0] if candidates else None)
broken_links.append({
"instance_name": name,
"infrahub_status": status,
"infrahub_openstack_id": osid or "(none)",
"organization": str(row.get("organization") or ""),
"region": str(row.get("region") or ""),
"flavor": str(row.get("flavor_name") or ""),
"gpus": str(row.get("_gpus") or ""),
"reason": (
"Infrahub holds an OpenStack ID that OpenStack does not have"
if has_link else "Infrahub never recorded an OpenStack ID"
),
"candidate": match,
"candidate_claimed_by_other": bool(match and match["id"] in ih_osids),
"confidence": (
"high" if match and not match["id"] in ih_osids and match["status"] not in ("", "ERROR")
else "medium" if match else "none"
),
})
# 3b. OpenStack servers no Infrahub record claims.
orphans: list[dict[str, Any]] = []
ih_names = {_norm(str(r.get("instance_name") or "")) for r in ih_records}
for sid, rec in os_by_id.items():
if sid in ih_osids or _is_ignorable(rec["name"]):
continue
orphans.append({**rec, "name_known_to_infrahub": _norm(rec["name"]) in ih_names})
linkable = [b for b in broken_links if b["candidate"] and not b["candidate_claimed_by_other"]]
return {
"scanned_regions": region_counts,
"region_failures": failures,
"openstack_servers": len(os_by_id),
"infrahub_records": len(ih_records),
"broken_links": sorted(broken_links, key=lambda b: (b["confidence"] != "high", b["instance_name"])),
"likely_linkage_failures": len(linkable),
"skipped_unscanned_regions": skipped_unscanned,
"orphans": sorted(orphans, key=lambda o: (not o["name_known_to_infrahub"], o["name"]))[:400],
"orphan_total": len(orphans),
}
def _server_list(region: str) -> tuple[bool, list[dict[str, Any]], str]:
ok, data, raw = cxbridge.os_json(
region, ["server", "list", "--all-projects", "--long", "-f", "json"], timeout=180
)
if ok and isinstance(data, list):
return True, [x for x in data if isinstance(x, dict)], ""
return False, [], str(raw)
def enrich(region: str, openstack_id: str) -> dict[str, Any]:
"""Fetch created time and fault for one candidate, on demand."""
c = cxbridge.cx()
ok, srv, raw = c.server_show(cxbridge.config(), region, openstack_id)
if not ok:
return {"ok": False, "error": str(raw)[:200]}
fault = srv.get("fault")
return {
"ok": True,
"created": str(srv.get("created") or srv.get("Created") or ""),
"launched": str(srv.get("OS-SRV-USG:launched_at") or ""),
"status": str(srv.get("status") or ""),
"host": str(srv.get("OS-EXT-SRV-ATTR:host") or ""),
"project_id": str(srv.get("project_id") or ""),
"fault": (fault.get("message") if isinstance(fault, dict) else str(fault or "")) or "None",
}

545
triagelib/prometheus.py Normal file
View File

@@ -0,0 +1,545 @@
"""Prometheus access.
The alert Prometheus lives on the internal 10.11/8 network, which is reachable
only from inside the CX-Tools VPN containers - the laptop itself routes 10.11.*
out of its default gateway. So queries go the same way CX-Tools reaches
OpenStack: `docker exec <region>-osc curl ...`. A direct HTTP transport is tried
first so this still works from a host that does have a route.
"""
from __future__ import annotations
import json
import os
import re
import shlex
import subprocess
import threading
import time
import urllib.parse
import urllib.request
from typing import Any, Optional
DEFAULT_BASE = os.environ.get("CX_PROMETHEUS_BASE", "http://10.11.254.250:9090")
# Containers to try as an HTTP relay, in order. These are the CX-Tools
# OpenStack client containers, which share the regional VPN network namespace.
RELAY_CONTAINERS = ("ca1-osc", "us1-osc", "no1-osc", "ca2-osc")
class PrometheusError(RuntimeError):
pass
class PrometheusClient:
def __init__(self, base: str = DEFAULT_BASE, timeout: int = 20):
self.base = base.rstrip("/")
self.timeout = timeout
self._transport: Optional[tuple[str, str]] = None
self._lock = threading.Lock()
# --- transport selection ------------------------------------------------
def _try_direct(self) -> bool:
try:
req = urllib.request.Request(f"{self.base}/api/v1/status/buildinfo", headers={"User-Agent": "cx-triage"})
with urllib.request.urlopen(req, timeout=5) as resp:
return 200 <= getattr(resp, "status", 200) < 300
except Exception:
return False
def _try_relay(self, container: str) -> bool:
rc, out, _err = _run(
["docker", "exec", "-i", container, "curl", "-sS", "-m", "6", f"{self.base}/api/v1/status/buildinfo"],
timeout=15,
)
return rc == 0 and '"status":"success"' in out
def transport(self) -> tuple[str, str]:
"""Return (kind, detail) where kind is 'direct' or 'relay'."""
with self._lock:
if self._transport is not None:
return self._transport
forced = os.environ.get("CX_PROMETHEUS_RELAY", "").strip()
if forced:
self._transport = ("relay", forced)
return self._transport
if self._try_direct():
self._transport = ("direct", "host")
return self._transport
for container in RELAY_CONTAINERS:
if self._try_relay(container):
self._transport = ("relay", container)
return self._transport
raise PrometheusError(
f"Cannot reach Prometheus at {self.base}. The host has no route to the internal "
f"network and none of {', '.join(RELAY_CONTAINERS)} answered. Start the CX-Tools "
"VPN/OSC containers, or set CX_PROMETHEUS_RELAY to a container that has a route."
)
def describe_transport(self) -> str:
try:
kind, detail = self.transport()
except PrometheusError as exc:
return f"unavailable ({exc})"
return "direct from host" if kind == "direct" else f"relayed through {detail}"
# --- requests -----------------------------------------------------------
def _get(self, path: str, params: Optional[dict[str, str]] = None) -> Any:
url = f"{self.base}{path}"
if params:
url = f"{url}?{urllib.parse.urlencode(params)}"
kind, detail = self.transport()
if kind == "direct":
req = urllib.request.Request(url, headers={"User-Agent": "cx-triage"})
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
body = resp.read().decode("utf-8", errors="replace")
else:
rc, out, err = _run(
["docker", "exec", "-i", detail, "curl", "-sS", "-m", str(self.timeout), url],
timeout=self.timeout + 10,
)
if rc != 0:
raise PrometheusError(f"Prometheus relay via {detail} failed: {(err or out).strip()}")
body = out
try:
data = json.loads(body)
except json.JSONDecodeError as exc:
raise PrometheusError(f"Prometheus returned non-JSON for {path}: {body[:200]}") from exc
if data.get("status") != "success":
raise PrometheusError(f"Prometheus error for {path}: {data.get('error') or data}")
return data.get("data")
def alerts(self) -> list[dict[str, Any]]:
data = self._get("/api/v1/alerts") or {}
alerts = data.get("alerts")
return [a for a in alerts if isinstance(a, dict)] if isinstance(alerts, list) else []
def query(self, expr: str) -> list[dict[str, Any]]:
data = self._get("/api/v1/query", {"query": expr}) or {}
result = data.get("result")
return [r for r in result if isinstance(r, dict)] if isinstance(result, list) else []
def resources_by_floating_ip(self, floating_ip: str) -> list[dict[str, Any]]:
"""The `Resources{floating_ip="..."}` query the Duplicated IPs runbook uses.
Unlike CX-Tools (production Infrahub only), this series covers every
environment, so it is how a PreProd/Staging claimant gets found.
"""
expr = 'Resources{floating_ip="%s"}' % floating_ip.replace('"', "")
return [dict(r.get("metric") or {}) for r in self.query(expr)]
def query_range(self, expr: str, start: int, end: int, step: int) -> list[dict[str, Any]]:
data = self._get("/api/v1/query_range", {
"query": expr, "start": str(start), "end": str(end), "step": str(step),
}) or {}
result = data.get("result")
return [r for r in result if isinstance(r, dict)] if isinstance(result, list) else []
def rules(self) -> list[dict[str, Any]]:
data = self._get("/api/v1/rules") or {}
return [g for g in (data.get("groups") or []) if isinstance(g, dict)]
def series_count(self, metric: str) -> int:
rows = self.query(f"count({metric})")
if not rows:
return 0
try:
return int(float(rows[0]["value"][1]))
except (KeyError, IndexError, ValueError, TypeError):
return 0
class RuleIndex:
"""Maps alertname -> which rule file it came from and its `for` duration.
Keying off the rule file (not the alert name) is what lets node-exporter
alerts be separated reliably: two different files both use the group name
"Imported Rules".
"""
def __init__(self, client: PrometheusClient, ttl: float = 600.0):
self.client = client
self.ttl = ttl
self._at = 0.0
self._by_name: dict[str, dict[str, Any]] = {}
self._error = ""
self._lock = threading.Lock()
def refresh(self) -> None:
groups = self.client.rules()
index: dict[str, dict[str, Any]] = {}
for group in groups:
source = str(group.get("file") or "").rsplit("/", 1)[-1]
for rule in group.get("rules") or []:
if rule.get("type") != "alerting":
continue
index[str(rule.get("name") or "")] = {
"group": str(group.get("name") or ""),
"file": source,
"for_seconds": int(rule.get("duration") or 0),
"query": str(rule.get("query") or ""),
}
self._by_name = index
def ensure(self) -> None:
"""Refresh if the cache is empty or stale."""
with self._lock:
if not self._by_name or time.monotonic() - self._at > self.ttl:
try:
self.refresh()
self._error = ""
except PrometheusError as exc:
self._error = str(exc)
self._at = time.monotonic()
def get(self, alertname: str) -> dict[str, Any]:
self.ensure()
return self._by_name.get(alertname, {})
@property
def count(self) -> int:
return len(self._by_name)
@property
def error(self) -> str:
return self._error
# Metrics the alert rules are built on. If one of these is empty, rules that
# depend on it are broken rather than quiet - see StateSnapshot.broken_inputs.
RULE_INPUT_METRICS = ("Resources", "In_Use_Gpus", "Total_Gpus", "openstack_nova_server_status")
ROGUE_DELTA_EXPR = (
'sum by (instance) (In_Use_Gpus) - sum by (instance) '
'(Resources{organization!="3491 - luis.sarabando+runpod@nexgencloud.coms-Organization",'
'status=~"ACTIVE|SHUTOFF|PRE_ACTIVE"})'
)
def _episode(points: list[tuple[int, float]], median: float) -> dict[str, Any]:
return {
"start": points[0][0],
"end": points[-1][0],
"minutes": max(1, (points[-1][0] - points[0][0]) // 60 + 1),
"low": int(min(v for _, v in points)),
"normal": int(median),
}
# Labels that identify one alert across time, for true-age lookup.
TRUE_AGE_KEY_LABELS = ("alertname", "instance_name", "floating_ip", "instance", "openstack_id")
def true_age_key(labels: dict[str, Any]) -> tuple:
"""Identity of an alert, built from raw label values on both sides."""
return tuple(str((labels or {}).get(k, "")) for k in TRUE_AGE_KEY_LABELS)
class TrueAgeIndex:
"""How long each alert's condition has *actually* held.
Prometheus resets an alert's activeAt whenever the alert resolves, and the
Infrahub metric pipeline drops most of the `Resources` series for a few
minutes several times a day. Every alert alive during such a dip resolves and
re-fires, so activeAt collapses to "time since the last dip" and every alert
reports the same age.
This walks the `ALERTS` series backwards instead, bridging gaps shorter than
GAP_TOLERANCE, which recovers the real duration. Both pending and firing are
counted, so rules with a long `for:` are not reported as young.
"""
WINDOW_DAYS = 7
STEP_SECONDS = 900
GAP_TOLERANCE = 2700 # 45 min: bridges pipeline dips, not genuine recoveries
def __init__(self, client: PrometheusClient, severity: str = "infrahub-critical", ttl: float = 300.0):
self.client = client
self.severity = severity
self.ttl = ttl
self._at = 0.0
self._lock = threading.Lock()
self._starts: dict[tuple, tuple[int, bool]] = {}
self.error = ""
self.window_start = 0
def refresh(self) -> None:
end = int(time.time())
start = end - self.WINDOW_DAYS * 86400
self.window_start = start
expr = (
"count by (%s) (ALERTS{severity=\"%s\"})"
% (", ".join(TRUE_AGE_KEY_LABELS), self.severity)
)
starts: dict[tuple, tuple[int, bool]] = {}
for series in self.client.query_range(expr, start, end, self.STEP_SECONDS):
stamps = []
for point in series.get("values") or []:
try:
stamps.append(int(float(point[0])))
except (ValueError, TypeError, IndexError):
continue
if not stamps:
continue
run_start = stamps[-1]
for earlier, later in list(zip(stamps, stamps[1:]))[::-1]:
if later - earlier > self.GAP_TOLERANCE:
break
run_start = earlier
# A run that reaches the window edge is only a lower bound.
capped = run_start <= start + self.STEP_SECONDS
key = true_age_key(series.get("metric") or {})
existing = starts.get(key)
if existing is None or run_start < existing[0]:
starts[key] = (run_start, capped)
self._starts = starts
def get(self) -> "TrueAgeIndex":
with self._lock:
if not self._at or time.monotonic() - self._at > self.ttl:
try:
self.refresh()
self.error = ""
except PrometheusError as exc:
self.error = str(exc)
self._at = time.monotonic()
return self
def lookup(self, labels: dict[str, Any]) -> tuple[Optional[int], bool]:
"""Return (minutes the condition has held, whether that is a floor)."""
entry = self._starts.get(true_age_key(labels))
if not entry:
return None, False
start, capped = entry
return max(0, int((time.time() - start) // 60)), capped
@property
def loaded(self) -> bool:
return bool(self._starts)
@property
def count(self) -> int:
return len(self._starts)
class StateSnapshot:
"""A bulk read of current platform state, used to screen alerts cheaply.
Re-checking whether an alert's condition still holds is what separates a
real alert from one that already self-resolved. Doing it from these few
aggregate queries costs one Prometheus round trip for the whole queue,
instead of an Infrahub and OpenStack call per alert.
"""
def __init__(self, client: PrometheusClient, ttl: float = 60.0):
self.client = client
self.ttl = ttl
self._at = 0.0
self._lock = threading.Lock()
self.error = ""
self.by_openstack_id: dict[str, dict[str, str]] = {}
self.by_instance_name: dict[str, dict[str, str]] = {}
self.fip_counts: dict[str, int] = {}
self.rogue_delta: dict[str, float] = {}
self.total_gpus: dict[str, float] = {}
self.in_use_gpus: dict[str, float] = {}
self.resources_by_host: dict[str, list[dict[str, str]]] = {}
self.broken_inputs: list[str] = []
# Infrahub VMs in a GPU-counted state with no host recorded. These are
# invisible to the per-host GPU sum the Rogue VM rule uses, so they can
# manufacture a gap on whichever host is actually running them.
self.unattributed_active: int = 0
self.unattributed_active_gpus: int = 0
# Episodes where the Resources series partially collapsed. Each one
# resets activeAt on every alert that was live at the time.
self.pipeline_dips: list[dict[str, Any]] = []
def refresh(self) -> None:
by_osid: dict[str, dict[str, str]] = {}
by_name: dict[str, dict[str, str]] = {}
fips: dict[str, int] = {}
by_host: dict[str, list[dict[str, str]]] = {}
counted_states = {"ACTIVE", "SHUTOFF", "PRE_ACTIVE"}
unattributed = 0
unattributed_gpus = 0
for row in self.client.query("Resources"):
metric = {str(k): str(v) for k, v in (row.get("metric") or {}).items()}
try:
metric["_gpus"] = str(int(float(row.get("value", [0, "0"])[1])))
except (ValueError, TypeError, IndexError):
metric["_gpus"] = "0"
osid = metric.get("openstack_id", "")
if osid and osid not in ("None", ""):
by_osid[osid] = metric
name = metric.get("instance_name", "")
if name:
by_name[name] = metric
fip = metric.get("floating_ip", "")
if fip and fip not in ("None", "NULL", ""):
fips[fip] = fips.get(fip, 0) + 1
host = metric.get("instance", "")
if host and host not in ("Unknown", "None"):
by_host.setdefault(host, []).append(metric)
elif metric.get("status", "").upper() in counted_states:
unattributed += 1
unattributed_gpus += int(metric["_gpus"] or 0)
self.by_openstack_id = by_osid
self.by_instance_name = by_name
self.fip_counts = fips
self.resources_by_host = by_host
self.unattributed_active = unattributed
self.unattributed_active_gpus = unattributed_gpus
# Summed by instance: these metrics are per (instance, gpu_name), so a
# host with two GPU models carries two series. Reading them unsummed
# would silently keep only one.
self.rogue_delta = self._scalar_by_instance(ROGUE_DELTA_EXPR)
self.total_gpus = self._scalar_by_instance("sum by (instance) (Total_Gpus)")
self.in_use_gpus = self._scalar_by_instance("sum by (instance) (In_Use_Gpus)")
self.broken_inputs = [m for m in RULE_INPUT_METRICS if self.client.series_count(m) == 0]
self.pipeline_dips = self._find_pipeline_dips()
def _find_pipeline_dips(self, hours: int = 24, drop_ratio: float = 0.8) -> list[dict[str, Any]]:
"""Find episodes where most of the `Resources` series went missing."""
end = int(time.time())
start = end - hours * 3600
series = self.client.query_range("count(Resources)", start, end, 60)
if not series:
return []
points: list[tuple[int, float]] = []
for point in series[0].get("values") or []:
try:
points.append((int(float(point[0])), float(point[1])))
except (ValueError, TypeError, IndexError):
continue
if len(points) < 10:
return []
ordered = sorted(v for _, v in points)
median = ordered[len(ordered) // 2]
if median <= 0:
return []
episodes: list[dict[str, Any]] = []
current: list[tuple[int, float]] = []
for stamp, value in points:
if value < median * drop_ratio:
if current and stamp - current[-1][0] > 180:
episodes.append(_episode(current, median))
current = []
current.append((stamp, value))
elif current:
episodes.append(_episode(current, median))
current = []
if current:
episodes.append(_episode(current, median))
return episodes
def _scalar_by_instance(self, expr: str) -> dict[str, float]:
out: dict[str, float] = {}
for row in self.client.query(expr):
host = str((row.get("metric") or {}).get("instance") or "")
if not host:
continue
try:
out[host] = float(row.get("value", [0, "0"])[1])
except (ValueError, TypeError, IndexError):
continue
return out
def get(self) -> "StateSnapshot":
with self._lock:
if not self._at or time.monotonic() - self._at > self.ttl:
try:
self.refresh()
self.error = ""
except PrometheusError as exc:
self.error = str(exc)
self._at = time.monotonic()
return self
@property
def loaded(self) -> bool:
return bool(self.by_openstack_id) or bool(self.total_gpus)
def _run(cmd: list[str], timeout: int) -> tuple[int, str, str]:
try:
p = subprocess.run(cmd, text=True, capture_output=True, timeout=timeout)
return p.returncode, p.stdout, p.stderr
except subprocess.TimeoutExpired as exc:
return 124, exc.stdout or "", exc.stderr or f"timed out after {timeout}s"
except FileNotFoundError as exc:
return 127, "", str(exc)
except Exception as exc: # pragma: no cover
return 1, "", str(exc)
# --- parsing pasted alert text / URLs --------------------------------------
_LABEL_RE = re.compile(r'(\w+)\s*=\s*"((?:[^"\\]|\\.)*)"')
def parse_alert_text(text: str) -> list[dict[str, str]]:
"""Parse pasted `ALERTS{...}` lines, or a Prometheus graph URL, into labels.
Accepts what a CX engineer actually copies: a raw series line from the
Prometheus console, or the `g0.expr=` URL from the Slack alert.
"""
text = (text or "").strip()
if not text:
return []
found: list[dict[str, str]] = []
# A pasted graph URL: pull the expression out and parse its label matchers.
if text.startswith("http://") or text.startswith("https://"):
parsed = urllib.parse.urlparse(text)
params = urllib.parse.parse_qs(parsed.query)
exprs = [v for k, vs in params.items() if k.endswith("expr") for v in vs]
for expr in exprs:
labels = {k: _unescape(v) for k, v in _LABEL_RE.findall(expr)}
if labels:
found.append(labels)
return found
for block in re.findall(r"\{[^{}]*\}", text):
labels = {k: _unescape(v) for k, v in _LABEL_RE.findall(block)}
if labels:
found.append(labels)
if not found:
labels = {k: _unescape(v) for k, v in _LABEL_RE.findall(text)}
if labels:
found.append(labels)
return found
def _unescape(value: str) -> str:
return value.replace('\\"', '"').replace("\\\\", "\\").replace("\\n", "\n")
class AlertCache:
"""Short-lived cache so the UI can poll without hammering Prometheus."""
def __init__(self, client: PrometheusClient, ttl: float = 30.0):
self.client = client
self.ttl = ttl
self._at = 0.0
self._alerts: list[dict[str, Any]] = []
self._error = ""
self._lock = threading.Lock()
def get(self, force: bool = False) -> tuple[list[dict[str, Any]], str, float]:
with self._lock:
age = time.monotonic() - self._at
if not force and self._at and age < self.ttl:
return self._alerts, self._error, age
try:
self._alerts = self.client.alerts()
self._error = ""
except PrometheusError as exc:
self._error = str(exc)
self._at = time.monotonic()
return self._alerts, self._error, 0.0

1497
triagelib/runbooks.py Normal file

File diff suppressed because it is too large Load Diff

313
triagelib/screening.py Normal file
View File

@@ -0,0 +1,313 @@
"""Noise-vs-real screening.
An alert firing is not the same as work existing. Prometheus keeps an alert up
until its expression stops matching on the next evaluation, and the CX rules sit
on top of a metric pipeline that can go stale or empty. So before anything gets
diagnosed, each alert's condition is re-checked against current state.
The re-check is deliberately cheap: it reads the same Prometheus series the rules
are built from (one bulk snapshot for the whole queue) rather than making an
Infrahub or OpenStack call per alert. Anything it cannot settle is treated as
real - screening only ever demotes an alert on positive evidence.
"""
from __future__ import annotations
import time
from typing import Any, Optional
from .alerts import Alert
# Beyond this, an alert is chronic: it is either already ticketed or nobody has
# silenced it. Either way it is not today's queue.
CHRONIC_DAYS = 3
# Verdicts, most to least urgent.
REAL = "real"
OVERDUE = "overdue"
UNVERIFIED = "unverified"
CHRONIC = "chronic"
LOW_IMPACT = "low_impact"
PENDING = "pending"
RESOLVED = "resolved"
RULE_DEFECT = "rule_defect"
SUPPRESSED = "suppressed"
VERDICT_LABELS = {
REAL: "needs action",
OVERDUE: "overdue",
UNVERIFIED: "needs action (unverified)",
CHRONIC: "chronic",
LOW_IMPACT: "low impact",
PENDING: "not yet firing",
RESOLVED: "already resolved",
RULE_DEFECT: "invalid - alert rule defect",
SUPPRESSED: "hidden by a rule",
}
# Runbook commitments: past this age the customer contact is late, not chronic.
# From "Instance in ERROR state": if a stock-failure instance is not deleted
# within a day, contact the customer. SHUTOFF is here for a different reason -
# the VM accrues full cost the entire time it is stopped, so an old one is a
# customer who has been paying for nothing for longer, not a stale alert.
SLA_HOURS = {"error": 24, "creating": 24, "restoring": 24, "rebooting": 24,
"build": 24, "shutoff": 48}
SLA_REASON = {
"shutoff": "a SHUTOFF VM accrues full cost the whole time, so this customer has been paying "
"for a stopped instance that long and may never have been told",
}
# Verdicts that stay in the working queue by default.
ACTIONABLE = {REAL, OVERDUE, UNVERIFIED}
# Kept for the SLA check: an internal owner should not make an alert *overdue*.
def _is_internal(alert) -> bool:
return bool(getattr(alert, 'is_internal_org', False))
def _result(verdict: str, reason: str, *, current: str = "", detail: str = "") -> dict[str, Any]:
return {
"verdict": verdict,
"label": VERDICT_LABELS[verdict],
"reason": reason,
"actionable": verdict in ACTIONABLE,
"current_state": current,
"detail": detail,
}
def _resources_row(alert: Alert, snap: Any) -> Optional[dict[str, str]]:
"""Find the VM's current Infrahub row in the snapshot."""
if alert.openstack_id and alert.openstack_id in snap.by_openstack_id:
return snap.by_openstack_id[alert.openstack_id]
if alert.instance_name and alert.instance_name in snap.by_instance_name:
return snap.by_instance_name[alert.instance_name]
return None
def _screen_state_alert(alert: Alert, snap: Any) -> dict[str, Any]:
"""State alerts: does Infrahub still report the state that fired?"""
expected = alert.status.upper()
row = _resources_row(alert, snap)
if row is None:
if alert.kind == "creating" and not alert.openstack_id:
# A CREATING VM that never reached OpenStack has no Resources row to
# find; the alert stands on its own.
return _result(REAL, "Instance never got an OpenStack ID, so it cannot have recovered.")
return _result(
RESOLVED,
"No longer present in Infrahub's Resources series - the record has been deleted or cleaned up.",
)
current = row.get("status", "").upper()
if not expected:
return _result(UNVERIFIED, "Alert carried no status label, so its condition could not be re-checked.",
current=current)
if current == expected:
return _result(REAL, f"Infrahub still reports {current}.", current=current)
return _result(
RESOLVED,
f"Fired on {expected} but Infrahub now reports {current} - it resolved on its own.",
current=current,
)
def _screen_duplicate_ip(alert: Alert, snap: Any) -> dict[str, Any]:
count = snap.fip_counts.get(alert.floating_ip)
if count is None:
return _result(RESOLVED, f"No Infrahub VM currently holds {alert.floating_ip}.")
if count > 1:
return _result(REAL, f"{count} VMs still hold {alert.floating_ip}.", current=f"{count} claimants")
return _result(
RESOLVED,
f"Only 1 VM holds {alert.floating_ip} now - the duplicate is gone.",
current="1 claimant",
)
def _screen_rogue_vm(alert: Alert, snap: Any) -> dict[str, Any]:
"""Rogue VM fires on a per-host GPU accounting gap; re-evaluate the gap.
The rule subtracts Infrahub's allocated GPUs from `In_Use_Gpus`. On almost
every host `In_Use_Gpus` equals `Total_Gpus` - the physical GPU count - so
the expression reduces to "this host has at least one unallocated GPU" and
fires on ordinary spare capacity. Validated against OpenStack on 10 firing
hosts: Infrahub and OpenStack agreed exactly on all of them.
"""
host = alert.host or alert.instance_name
delta = snap.rogue_delta.get(host)
if delta is None:
return _result(UNVERIFIED, f"No current GPU accounting data for {host}.")
if delta >= 1:
known = len(snap.resources_by_host.get(host, []))
in_use = snap.in_use_gpus.get(host)
total = snap.total_gpus.get(host)
if in_use is not None and total is not None and in_use == total:
return _result(
RULE_DEFECT,
f"Not a rogue VM: on {host} the rule's 'GPUs in use' reading ({int(in_use)}) is just the "
f"physical GPU count, so it is reporting {int(delta)} free GPU(s) as a discrepancy.",
current=f"{int(delta)} GPU(s) spare capacity",
detail=(
"In_Use_Gpus == Total_Gpus on this host, so the rule expression reduces to "
"'physical GPUs minus allocated GPUs', which is spare capacity rather than an "
"Infrahub/OpenStack mismatch. The rule needs fixing at source."
),
)
if total is None:
detail = (f"Infrahub records {known} VM(s) on this host. No Total_Gpus reading is available, so the "
"spare-capacity explanation cannot be confirmed or ruled out from metrics alone.")
else:
detail = (f"Infrahub records {known} VM(s) on this host. In_Use_Gpus ({int(in_use)}) differs from "
f"Total_Gpus ({int(total)}), so this is not simply spare capacity.")
return _result(
REAL,
f"{int(delta)} GPU(s) allocated on {host} are still unaccounted for in Infrahub.",
current=f"gap {int(delta)}",
detail=detail,
)
return _result(
RESOLVED,
f"GPU accounting for {host} now balances (delta {int(delta)}).",
current=f"delta {int(delta)}",
)
def _screen_total_gpus(alert: Alert, snap: Any) -> dict[str, Any]:
host = alert.host or alert.instance_name
total = snap.total_gpus.get(host)
if total is None:
return _result(UNVERIFIED, f"No current Total_Gpus reading for {host}.")
# The rule fires on any count in 1,2,3,5,6,7,9 - i.e. not a full complement.
if int(total) in (0, 4, 8):
return _result(
RESOLVED,
f"{host} now reports {int(total)} GPUs, a valid complement.",
current=f"{int(total)} GPUs",
)
in_use = snap.in_use_gpus.get(host)
detail = f"{int(in_use)} GPU(s) currently allocated to instances." if in_use is not None else ""
return _result(
REAL,
f"{host} still reports {int(total)} GPUs - hardware is missing.",
current=f"{int(total)} GPUs",
detail=detail,
)
_KIND_SCREENS = {
"duplicate_ip": _screen_duplicate_ip,
"rogue_vm": _screen_rogue_vm,
"orphan_vm": _screen_rogue_vm,
"total_gpus": _screen_total_gpus,
}
def screen(alert: Alert, snap: Any, user_settings: Any = None) -> dict[str, Any]:
"""Decide whether an alert is worth a human's attention right now."""
# A rule the team wrote wins over anything inferred here.
if user_settings is not None:
from . import settings as settings_mod
rule = settings_mod.first_match(user_settings, alert)
if rule:
reason = rule.get("reason") or "Matched a suppression rule."
return _result(
SUPPRESSED,
f"Hidden by \u201c{rule.get('name')}\u201d - {reason}",
detail="Edit or disable this in Settings.",
)
# Prometheus has not committed to this alert yet.
if alert.state == "pending":
remaining = ""
if alert.for_seconds and alert.age_minutes is not None:
remaining = f" It needs {alert.for_seconds // 60} min of continuous firing; it has {alert.age_minutes} min."
return _result(PENDING, f"Prometheus still has this pending, not firing.{remaining}")
if snap is None or not getattr(snap, "loaded", False):
return _result(UNVERIFIED, "Current-state snapshot unavailable, so the condition could not be re-checked.")
screener = _KIND_SCREENS.get(alert.kind)
result = screener(alert, snap) if screener else _screen_state_alert(alert, snap)
# A still-valid alert can still be the wrong thing to spend time on.
if result["actionable"]:
# Uses the recovered duration: activeAt is reset by pipeline dips, which
# would make every chronic alert look hours old.
age = alert.effective_age_minutes
sla = SLA_HOURS.get(alert.kind)
if sla and age is not None and age > sla * 60 and not alert.is_internal_org:
# The runbook commits to contacting the customer inside this window,
# so age makes it more urgent, not less. Never demote these to chronic.
why = SLA_REASON.get(
alert.kind,
f"past the {sla}h point where the runbook says to contact the customer",
)
return _result(
OVERDUE,
f"Condition has held for {alert.effective_age_text} - {why}. Overdue, not chronic.",
current=result["current_state"],
detail=result["reason"],
)
if age is not None and age > CHRONIC_DAYS * 24 * 60:
note = result["reason"]
if alert.age_is_reset:
note += (f" Prometheus reports only {alert.age_text} because a metric-pipeline dip reset "
"activeAt; the condition itself has held far longer.")
return _result(
CHRONIC,
f"Condition still holds, but it has held for {alert.effective_age_text} - "
"chronic, so it is likely already ticketed rather than new work.",
current=result["current_state"],
detail=note,
)
return result
def screen_all(items: list[Alert], snap: Any, user_settings: Any = None) -> None:
for alert in items:
alert.screen = screen(alert, snap, user_settings)
def summarize(items: list[Alert]) -> dict[str, Any]:
counts: dict[str, int] = {}
for alert in items:
verdict = alert.screen.get("verdict", UNVERIFIED)
counts[verdict] = counts.get(verdict, 0) + 1
return {
"counts": counts,
"actionable": sum(1 for a in items if a.screen.get("actionable")),
"screened_out": sum(1 for a in items if not a.screen.get("actionable")),
"labels": VERDICT_LABELS,
}
def health_warnings(snap: Any) -> list[str]:
"""Rules whose input metrics are empty are broken, not quiet."""
warnings: list[str] = []
for metric in getattr(snap, "broken_inputs", []) or []:
if metric == "openstack_nova_server_status":
warnings.append(
"The OpenStack server metric (openstack_nova_server_status) is currently empty. Any rule built on "
"it is unreliable: 'Exists in Infrahub but does not exist in OpenStack' matches every VM (which is "
"why it is excluded here), and 'Suspected Orphan VM' cannot fire at all. Worth raising with whoever "
"owns the exporter."
)
else:
warnings.append(
f"The metric '{metric}' is currently empty, so alert rules that depend on it are unreliable."
)
dips = getattr(snap, "pipeline_dips", []) or []
if dips:
latest = max(dips, key=lambda d: d["end"])
mins_ago = max(0, int((time.time() - latest["end"]) // 60))
warnings.append(
f"The Infrahub 'Resources' metric dropped most of its series {len(dips)} time(s) in the last 24h "
f"(most recently {mins_ago} min ago: {latest['low']} of ~{latest['normal']} series for "
f"{latest['minutes']} min). Every alert live during a dip resolves and re-fires, so Prometheus' own "
"alert ages all reset together. Ages shown here are recovered from ALERTS history instead."
)
return warnings

437
triagelib/server.py Normal file
View File

@@ -0,0 +1,437 @@
"""Localhost HTTP server: alert queue, background triage jobs, JSON API."""
from __future__ import annotations
import json
import subprocess
import threading
import time
import traceback
import urllib.parse
import uuid
from concurrent.futures import ThreadPoolExecutor
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any, Optional
from . import (VERSION, alerts as alertlib, comms, cxbridge, integrations, linkage,
runbooks, screening, settings as settings_mod, ui, ui_linkage,
ui_settings, ui_v2)
from .prometheus import (
AlertCache, PrometheusClient, PrometheusError, RuleIndex, StateSnapshot, TrueAgeIndex,
parse_alert_text,
)
TRIAGE_WORKERS = 3
JOB_TTL_SECONDS = 30 * 60
class Jobs:
"""In-memory triage jobs. Diagnosis takes tens of seconds, so the UI polls."""
def __init__(self, workers: int = TRIAGE_WORKERS):
self._pool = ThreadPoolExecutor(max_workers=workers, thread_name_prefix="triage")
self._lock = threading.Lock()
self._jobs: dict[str, dict[str, Any]] = {}
def submit(self, alert: alertlib.Alert, prom: PrometheusClient,
snap: Any = None, force: bool = False, user_settings: Any = None) -> str:
job_id = uuid.uuid4().hex[:12]
with self._lock:
self._reap()
self._jobs[job_id] = {
"id": job_id,
"state": "running",
"created": time.time(),
"alert": alert.to_json(),
"result": None,
"error": "",
}
self._pool.submit(self._run, job_id, alert, prom, snap, force, user_settings)
return job_id
def _run(self, job_id: str, alert: alertlib.Alert, prom: PrometheusClient,
snap: Any = None, force: bool = False, user_settings: Any = None) -> None:
started = time.monotonic()
try:
diagnosis = runbooks.diagnose(alert, prom, snap, force, user_settings)
payload = diagnosis.to_json()
payload["elapsed_seconds"] = round(time.monotonic() - started, 1)
with self._lock:
job = self._jobs.get(job_id)
if job is not None:
job.update({"state": "done", "result": payload})
except Exception:
with self._lock:
job = self._jobs.get(job_id)
if job is not None:
job.update({"state": "error", "error": traceback.format_exc(limit=4)})
def get(self, job_id: str) -> Optional[dict[str, Any]]:
with self._lock:
job = self._jobs.get(job_id)
return dict(job) if job else None
def _reap(self) -> None:
cutoff = time.time() - JOB_TTL_SECONDS
for key in [k for k, v in self._jobs.items() if v["created"] < cutoff]:
self._jobs.pop(key, None)
class App:
def __init__(self, prom: PrometheusClient):
self.prom = prom
self.cache = AlertCache(prom)
self.rules = RuleIndex(prom)
self.snapshot = StateSnapshot(prom)
self.true_age = TrueAgeIndex(prom)
self.jobs = Jobs()
self.scan = linkage.Scan()
self.settings = settings_mod.Settings()
def warm(self, log=print) -> None:
"""Populate the caches before serving.
Recovering true alert ages reads a week of ALERTS history, so doing it
lazily would make the first page load take ~20 seconds.
"""
try:
self.rules.ensure()
log(f" rule index: {self.rules.count} alerting rule(s)")
snap = self.snapshot.get()
log(f" state snapshot: {len(snap.by_openstack_id)} VMs, {len(snap.total_gpus)} hosts")
if snap.pipeline_dips:
log(f" {len(snap.pipeline_dips)} Resources metric dip(s) in the last 24h "
"- alert ages will be recovered from history")
ages = self.true_age.get()
log(f" alert history: {ages.count} alert(s) indexed over {ages.WINDOW_DAYS} days")
except PrometheusError as exc:
log(f" WARN: could not warm Prometheus caches: {exc}")
# --- endpoints ---------------------------------------------------------
def health(self) -> dict[str, Any]:
checks: list[dict[str, Any]] = []
def add(name: str, ok: bool, detail: str) -> None:
checks.append({"name": name, "ok": ok, "detail": detail})
try:
path = cxbridge.cx_tools_path()
add("CX-Tools", True, path)
except cxbridge.BridgeError as exc:
add("CX-Tools", False, str(exc))
try:
cfg = cxbridge.config()
add("API credentials", True, "Infrahub and InfraInsight keys loaded from 1Password")
add("Infrahub endpoint", True, cfg.infrahub_base)
except cxbridge.BridgeError as exc:
add("API credentials", False, str(exc))
try:
rc, out, _err = _run(["docker", "ps", "--format", "{{.Names}}"], 10)
running = {x.strip() for x in out.splitlines() if x.strip()} if rc == 0 else set()
expected = {"ca1-osc", "ca2-osc", "us1-osc", "no1-osc"}
missing = sorted(expected - running)
add("OpenStack CLI containers", not missing,
"all present" if not missing else f"not running: {', '.join(missing)}")
except Exception as exc:
add("OpenStack CLI containers", False, str(exc))
try:
add("Prometheus", True, f"{self.prom.base} ({self.prom.describe_transport()})")
except PrometheusError as exc:
add("Prometheus", False, str(exc))
return {"version": VERSION, "ok": all(c["ok"] for c in checks), "checks": checks}
def alert_queue(self, force: bool = False) -> dict[str, Any]:
raw, error, age = self.cache.get(force=force)
snap = self.snapshot.get()
ages = self.true_age.get()
parsed = [alertlib.from_prometheus(a, self.rules, ages) for a in raw]
excluded = [a for a in parsed if alertlib.is_excluded(a)]
candidates = [a for a in parsed if not alertlib.is_excluded(a)]
cx = [a for a in candidates if a.category == "cx" and alertlib.cx_relevant(a)]
screening.screen_all(cx, snap, self.settings)
# Everything that is not a CX runbook alert: node-exporter in its own
# section, then the rest of the platform rules. Split by identity, since
# two distinct alerts can compare equal field-for-field.
in_cx = {id(a) for a in cx}
infra = [a for a in candidates if id(a) not in in_cx]
infra.sort(key=alertlib.sort_key)
return {
"error": error or snap.error or ages.error,
"cache_age_seconds": round(age, 1),
"warnings": screening.health_warnings(snap),
"totals": {
"prometheus": len(parsed),
"cx": len(cx),
"infrastructure": len(infra),
"excluded": len(excluded),
},
"excluded_note": (
f"{len(excluded)} '{', '.join(sorted({a.alertname for a in excluded}))}' alerts hidden"
if excluded else ""
),
"integrations": {"zendesk": integrations.zendesk_configured(),
"jira": integrations.jira_configured()},
"summary": screening.summarize(cx),
"groups": alertlib.group_alerts(cx),
"infrastructure": _infra_sections(infra),
}
def triage(self, labels: dict[str, str], annotations: Optional[dict[str, str]] = None,
state: str = "firing", active_at: Any = None, force: bool = False) -> dict[str, Any]:
alert = alertlib.from_labels(labels, annotations, state=state, active_at=active_at)
if alert.kind == "excluded":
return {"error": f"'{alertlib.clean_alertname(alert.alertname)}' is excluded as a monitoring fault."}
if alert.kind == "other":
return {"error": f"'{alertlib.clean_alertname(alert.alertname)}' is not an alert type the CX runbooks cover."}
meta = self.rules.get(alert.alertname)
if meta:
alert.rule_file = str(meta.get("file") or "")
alert.rule_group = str(meta.get("group") or "")
alert.for_seconds = int(meta.get("for_seconds") or 0)
snap = self.snapshot.get()
alert.true_age_minutes, alert.true_age_capped = self.true_age.get().lookup(alert.labels)
alert.screen = screening.screen(alert, snap, self.settings)
job_id = self.jobs.submit(alert, self.prom, snap, force, self.settings)
return {"job_id": job_id, "alert": alert.to_json()}
def triage_by_id(self, alert_id: str, force: bool = False) -> dict[str, Any]:
raw, error, _age = self.cache.get()
if error and not raw:
return {"error": error}
for item in raw:
alert = alertlib.from_prometheus(item, self.rules, self.true_age.get())
if alert.fingerprint() == alert_id:
return self.triage(alert.labels, alert.annotations, alert.state,
item.get("activeAt"), force=force)
return {"error": "That alert is no longer firing. Refresh the queue."}
def parse(self, text: str) -> dict[str, Any]:
found = parse_alert_text(text)
if not found:
return {"error": "Could not find any label set in that text. Paste an ALERTS{...} line or a Prometheus graph URL."}
out = []
for labels in found:
alert = alertlib.from_labels(labels)
out.append({"alert": alert.to_json(), "supported": alertlib.cx_relevant(alert)})
return {"parsed": out}
def settings_payload(self) -> dict[str, Any]:
return self.settings.to_json()
def _current_alerts(self) -> list[Any]:
raw, _error, _age = self.cache.get()
return [a for a in (alertlib.from_prometheus(x, self.rules) for x in raw)
if not alertlib.is_excluded(a) and alertlib.cx_relevant(a)]
def settings_action(self, body: dict[str, Any]) -> dict[str, Any]:
action = str(body.get("action") or "")
if action == "save_rule":
rule = self.settings.upsert_rule(body.get("rule") or {})
return {"ok": True, "rule": rule, "settings": self.settings.to_json()}
if action == "delete_rule":
self.settings.delete_rule(str(body.get("id") or ""))
return {"ok": True, "settings": self.settings.to_json()}
if action == "toggle_rule":
self.settings.toggle_rule(str(body.get("id") or ""), bool(body.get("enabled")))
return {"ok": True, "settings": self.settings.to_json()}
if action == "general":
self.settings.set_general(body.get("agent_name"), body.get("chronic_days"))
return {"ok": True, "settings": self.settings.to_json()}
if action == "preview":
hits = settings_mod.preview(self.settings, body.get("rule") or {}, self._current_alerts())
return {"ok": True, "matches": hits, "count": len(hits)}
return {"ok": False, "error": f"Unknown action: {action}"}
def start_scan(self) -> dict[str, Any]:
if self.scan.state == "running":
return {"started": False, "reason": "already running"}
snap = self.snapshot.get()
threading.Thread(target=self.scan.run, args=(snap,), daemon=True).start()
return {"started": True}
def send_zendesk(self, body: dict[str, Any]) -> dict[str, Any]:
"""Deliberately refuses until Zendesk is configured AND enabled.
Contacting a customer is the one thing this tool must never do as a
side effect, so delivery stays behind explicit configuration rather
than being reachable from the UI by default.
"""
if not integrations.zendesk_configured():
return {"ok": False, "error": "Zendesk is not configured. Set CX_ZENDESK_SUBDOMAIN, "
"CX_ZENDESK_EMAIL and CX_ZENDESK_TOKEN, then restart."}
return {"ok": False, "error": "Sending is not enabled in this build. The payload is ready; "
"wiring delivery is a deliberate, separate step."}
def templates(self) -> dict[str, Any]:
return {"templates": [d.to_json() for d in (comms.draft(k) for k in comms._TEMPLATES) if d]}
SOURCE_LABELS = {
"node-exporter-rules.yml": "Node exporter (hosts)",
"ceph-rules.yml": "Ceph",
"mysql-rules.yml": "MySQL",
"mysql-performance-rules.yml": "MySQL performance",
"galera-rules.yml": "Galera",
"openstack-rules.yml": "OpenStack services",
"blackbox.yml": "Blackbox / OOB",
"infrahub-rules.yml": "Infrahub (no CX runbook)",
}
def _infra_sections(items: list[alertlib.Alert]) -> list[dict[str, Any]]:
"""Group infrastructure alerts by their rule file, node-exporter first."""
buckets: dict[str, list[alertlib.Alert]] = {}
for alert in items:
buckets.setdefault(alert.rule_file or "unknown", []).append(alert)
sections = []
for source, members in buckets.items():
by_name: dict[str, int] = {}
for alert in members:
name = alertlib.clean_alertname(alert.alertname)
by_name[name] = by_name.get(name, 0) + 1
sections.append({
"source": source,
"label": SOURCE_LABELS.get(source, source),
"total": len(members),
"by_alertname": sorted(({"name": k, "count": v} for k, v in by_name.items()),
key=lambda x: (-x["count"], x["name"])),
"alerts": [a.to_json() for a in members],
})
sections.sort(key=lambda s: (s["source"] != alertlib.NODE_RULE_FILE, -s["total"]))
return sections
def _run(cmd: list[str], timeout: int) -> tuple[int, str, str]:
try:
p = subprocess.run(cmd, text=True, capture_output=True, timeout=timeout)
return p.returncode, p.stdout, p.stderr
except Exception as exc:
return 1, "", str(exc)
class Handler(BaseHTTPRequestHandler):
server_version = f"cx-triage/{VERSION}"
app: App
def log_message(self, fmt: str, *args: Any) -> None:
if self.path.startswith("/api/jobs/"):
return
print(f" {self.command} {self.path}")
# --- helpers ----------------------------------------------------------
def _send_json(self, payload: Any, code: int = 200) -> None:
body = json.dumps(payload, default=str).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
def _send_html(self, html: str) -> None:
body = html.encode()
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _body_json(self) -> dict[str, Any]:
length = int(self.headers.get("Content-Length") or 0)
if not length:
return {}
try:
return json.loads(self.rfile.read(length).decode("utf-8", errors="replace")) or {}
except json.JSONDecodeError:
return {}
# --- routes -----------------------------------------------------------
def do_GET(self) -> None: # noqa: N802
parsed = urllib.parse.urlparse(self.path)
path = parsed.path
query = urllib.parse.parse_qs(parsed.query)
try:
if path in ("/", "/index.html"):
self._send_html(ui_v2.PAGE)
elif path == "/classic":
self._send_html(ui.PAGE)
elif path == "/settings":
self._send_html(ui_settings.PAGE)
elif path == "/api/settings":
self._send_json(self.app.settings_payload())
elif path == "/linkage":
self._send_html(ui_linkage.PAGE)
elif path == "/api/linkage":
self._send_json(self.app.scan.to_json())
elif path == "/api/health":
self._send_json(self.app.health())
elif path == "/api/alerts":
self._send_json(self.app.alert_queue(force=query.get("force", ["0"])[0] == "1"))
elif path == "/api/templates":
self._send_json(self.app.templates())
elif path.startswith("/api/jobs/"):
job = self.app.jobs.get(path.rsplit("/", 1)[-1])
self._send_json(job or {"error": "Unknown job."}, 200 if job else 404)
else:
self._send_json({"error": "Not found."}, 404)
except Exception as exc:
self._send_json({"error": f"{type(exc).__name__}: {exc}"}, 500)
def do_POST(self) -> None: # noqa: N802
path = urllib.parse.urlparse(self.path).path
body = self._body_json()
try:
force = bool(body.get("force"))
if path == "/api/triage":
if body.get("alert_id"):
self._send_json(self.app.triage_by_id(str(body["alert_id"]), force=force))
elif isinstance(body.get("labels"), dict):
self._send_json(self.app.triage(body["labels"], body.get("annotations"), force=force))
else:
self._send_json({"error": "Provide alert_id or labels."}, 400)
elif path == "/api/parse":
self._send_json(self.app.parse(str(body.get("text") or "")))
elif path == "/api/actions/zendesk":
self._send_json(self.app.send_zendesk(body))
elif path == "/api/settings":
self._send_json(self.app.settings_action(body))
elif path == "/api/linkage/scan":
self._send_json(self.app.start_scan())
elif path == "/api/linkage/enrich":
self._send_json(linkage.enrich(str(body.get("region") or ""),
str(body.get("openstack_id") or "")))
else:
self._send_json({"error": "Not found."}, 404)
except Exception as exc:
self._send_json({"error": f"{type(exc).__name__}: {exc}"}, 500)
def serve(host: str = "127.0.0.1", port: int = 8765, prometheus_base: Optional[str] = None) -> None:
from .prometheus import DEFAULT_BASE
prom = PrometheusClient(prometheus_base or DEFAULT_BASE)
app = App(prom)
print("Warming caches...")
app.warm()
Handler.app = app
httpd = ThreadingHTTPServer((host, port), Handler)
httpd.daemon_threads = True
print(f"cx-triage listening on http://{host}:{port}")
try:
httpd.serve_forever()
except KeyboardInterrupt:
print("\nshutting down")
finally:
httpd.server_close()

245
triagelib/settings.py Normal file
View File

@@ -0,0 +1,245 @@
"""User settings: suppression rules and comms identity.
Suppression rules replace hardcoded judgement calls. The internal-organisation
check used to be baked into the screening code, which meant the one person who
knew about it was whoever read the source. Now it ships as an editable default
rule that says what it does and why, and anyone can add their own.
A rule matches when *every* condition it sets matches (AND); within one
condition, any listed value matches (OR). So "type is error AND organisation
contains modal" is one rule with two conditions.
"""
from __future__ import annotations
import json
import os
import re
import threading
import time
import uuid
from typing import Any, Optional
SETTINGS_DIR = os.path.expanduser(os.environ.get("CX_TRIAGE_HOME", "~/.cx-triage"))
SETTINGS_PATH = os.path.join(SETTINGS_DIR, "settings.json")
# Conditions a rule can set. Every one is a substring match, case-insensitive,
# except `kind` and `region` which are exact.
CONDITIONS = {
"kind": "Alert type",
"organization": "Organisation contains",
"instance_name": "VM name contains",
"host": "Host contains",
"region": "Region",
"status": "Status is",
}
DEFAULT_RULES: list[dict[str, Any]] = [
{
"id": "builtin-internal-orgs",
"name": "Internal NexGen organisations",
"enabled": True,
"reason": "Owned by an internal test or platform organisation, not a customer.",
"conditions": {"organization": ["nexgencloud.com"]},
},
{
"id": "builtin-runpod-storage",
"name": "Runpod storage nodes (Luis)",
"enabled": True,
"reason": "Platform-owned storage nodes; SHUTOFF on these is expected and not customer-impacting.",
"conditions": {"kind": ["shutoff"], "instance_name": ["stor-runpod"]},
},
]
DEFAULTS: dict[str, Any] = {
"rules": DEFAULT_RULES,
"agent_name": "",
"chronic_days": 3,
}
def _blank(value: Any) -> bool:
return value is None or str(value).strip() == ""
class Settings:
"""Loaded once, written through on every change."""
def __init__(self, path: str = SETTINGS_PATH):
self.path = path
self._lock = threading.Lock()
self._data: dict[str, Any] = {}
self.load()
# --- persistence -------------------------------------------------------
def load(self) -> None:
data = dict(DEFAULTS)
try:
with open(self.path, encoding="utf-8") as handle:
stored = json.load(handle)
if isinstance(stored, dict):
data.update(stored)
except (OSError, json.JSONDecodeError):
pass
data["rules"] = [r for r in (data.get("rules") or []) if isinstance(r, dict)]
with self._lock:
self._data = data
def save(self) -> None:
with self._lock:
payload = json.dumps(self._data, indent=2, sort_keys=True)
try:
os.makedirs(os.path.dirname(self.path), exist_ok=True)
tmp = f"{self.path}.tmp"
with open(tmp, "w", encoding="utf-8") as handle:
handle.write(payload)
os.replace(tmp, self.path)
except OSError:
pass
# --- accessors ---------------------------------------------------------
@property
def rules(self) -> list[dict[str, Any]]:
with self._lock:
return [dict(r) for r in self._data.get("rules", [])]
@property
def agent_name(self) -> str:
with self._lock:
return str(self._data.get("agent_name") or "")
@property
def chronic_days(self) -> int:
with self._lock:
try:
return max(1, int(self._data.get("chronic_days") or 3))
except (TypeError, ValueError):
return 3
def to_json(self) -> dict[str, Any]:
with self._lock:
return {
"rules": [dict(r) for r in self._data.get("rules", [])],
"agent_name": self._data.get("agent_name") or "",
"chronic_days": self._data.get("chronic_days", 3),
"conditions": CONDITIONS,
"path": self.path,
}
# --- mutations ---------------------------------------------------------
def set_general(self, agent_name: Optional[str] = None, chronic_days: Optional[Any] = None) -> None:
with self._lock:
if agent_name is not None:
self._data["agent_name"] = str(agent_name).strip()
if chronic_days is not None:
try:
self._data["chronic_days"] = max(1, int(chronic_days))
except (TypeError, ValueError):
pass
self.save()
def upsert_rule(self, rule: dict[str, Any]) -> dict[str, Any]:
clean = _normalize_rule(rule)
with self._lock:
rules = self._data.setdefault("rules", [])
for idx, existing in enumerate(rules):
if existing.get("id") == clean["id"]:
rules[idx] = clean
break
else:
rules.append(clean)
self.save()
return clean
def delete_rule(self, rule_id: str) -> None:
with self._lock:
self._data["rules"] = [r for r in self._data.get("rules", []) if r.get("id") != rule_id]
self.save()
def toggle_rule(self, rule_id: str, enabled: bool) -> None:
with self._lock:
for rule in self._data.get("rules", []):
if rule.get("id") == rule_id:
rule["enabled"] = bool(enabled)
self.save()
def _normalize_rule(rule: dict[str, Any]) -> dict[str, Any]:
conditions: dict[str, list[str]] = {}
for field, values in (rule.get("conditions") or {}).items():
if field not in CONDITIONS:
continue
if isinstance(values, str):
values = [v.strip() for v in values.split(",")]
cleaned = [str(v).strip() for v in (values or []) if str(v).strip()]
if cleaned:
conditions[field] = cleaned
return {
"id": str(rule.get("id") or f"rule-{uuid.uuid4().hex[:8]}"),
"name": str(rule.get("name") or "Untitled rule").strip(),
"enabled": bool(rule.get("enabled", True)),
"reason": str(rule.get("reason") or "").strip(),
"conditions": conditions,
"created": rule.get("created") or time.strftime("%Y-%m-%d"),
}
# --- matching ---------------------------------------------------------------
def _alert_field(alert: Any, field: str) -> str:
if field == "kind":
return str(getattr(alert, "kind", ""))
if field == "organization":
return f"{getattr(alert, 'org_id', '')} {getattr(alert, 'org_name', '')}"
if field == "instance_name":
return str(getattr(alert, "instance_name", ""))
if field == "host":
return str(getattr(alert, "host", ""))
if field == "region":
return f"{getattr(alert, 'region', '')} {getattr(alert, 'region_label', '')}"
if field == "status":
return str(getattr(alert, "status", ""))
return ""
def _condition_matches(field: str, values: list[str], alert: Any) -> bool:
actual = _alert_field(alert, field).lower()
if field in ("kind", "status"):
return any(actual == str(v).strip().lower() for v in values)
if field == "region":
return any(str(v).strip().lower() in actual for v in values)
return any(str(v).strip().lower() in actual for v in values)
def rule_matches(rule: dict[str, Any], alert: Any) -> bool:
"""Every condition in the rule must match (AND)."""
conditions = rule.get("conditions") or {}
if not conditions:
return False # an empty rule would swallow the whole queue
return all(_condition_matches(f, v, alert) for f, v in conditions.items())
def first_match(settings: Settings, alert: Any) -> Optional[dict[str, Any]]:
for rule in settings.rules:
if rule.get("enabled") and rule_matches(rule, alert):
return rule
return None
def preview(settings: Settings, rule: dict[str, Any], alerts: list[Any]) -> list[dict[str, Any]]:
"""Which currently-firing alerts a rule would hide - shown before saving."""
clean = _normalize_rule(rule)
hits = []
for alert in alerts:
if rule_matches(clean, alert):
hits.append({
"kind": alert.kind,
"title": alert.title,
"instance_name": alert.instance_name,
"host": alert.host,
"org_name": alert.org_name,
"region": alert.region,
})
return hits

429
triagelib/ui.py Normal file
View File

@@ -0,0 +1,429 @@
"""The single-page UI, served inline so the app needs no assets or CDN."""
from __future__ import annotations
PAGE = r"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CX Triage</title>
<style>
:root {
--bg: #0f1115; --panel: #171a21; --panel2: #1d2129; --line: #2a2f3a;
--fg: #e6e9ef; --muted: #8b93a3; --accent: #5b9cf8;
--ok: #3fb950; --warn: #d29922; --bad: #f85149; --info: #58a6ff;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: light) {
:root {
--bg: #f6f7f9; --panel: #ffffff; --panel2: #f0f2f5; --line: #dfe3e8;
--fg: #1a1d23; --muted: #5c6472; --accent: #1f6feb;
--ok: #1a7f37; --warn: #9a6700; --bad: #cf222e; --info: #0969da;
}
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--fg);
font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
header {
display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
padding: 10px 20px; border-bottom: 1px solid var(--line); background: var(--panel);
position: sticky; top: 0; z-index: 10;
}
h1 { font-size: 15px; margin: 0; font-weight: 650; }
h1 span { color: var(--muted); font-weight: 400; }
.pills { display: flex; gap: 6px; flex-wrap: wrap; margin-left: auto; }
.pill { font-size: 11px; padding: 3px 9px; border-radius: 999px; border: 1px solid var(--line);
background: var(--panel2); color: var(--muted); white-space: nowrap; }
.pill.ok { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 40%, transparent); }
.pill.bad { color: var(--bad); border-color: color-mix(in srgb, var(--bad) 40%, transparent); }
button { font: inherit; font-size: 13px; padding: 5px 12px; border-radius: 6px;
border: 1px solid var(--line); background: var(--panel2); color: var(--fg); cursor: pointer; }
button:hover { border-color: var(--accent); }
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
main { display: grid; grid-template-columns: minmax(340px, 32%) 1fr; min-height: calc(100vh - 52px); }
@media (max-width: 900px) { main { grid-template-columns: 1fr; } }
#queue { border-right: 1px solid var(--line); background: var(--panel);
overflow-y: auto; max-height: calc(100vh - 52px); }
#detail { padding: 20px 24px; overflow-y: auto; max-height: calc(100vh - 52px); }
.tabs { display: flex; border-bottom: 1px solid var(--line); }
.tab { flex: 1; padding: 10px 8px; text-align: center; cursor: pointer; font-size: 13px;
color: var(--muted); border-bottom: 2px solid transparent; }
.tab.on { color: var(--fg); border-bottom-color: var(--accent); font-weight: 600; }
.tab b { font-weight: 650; }
.qhead { padding: 10px 14px; border-bottom: 1px solid var(--line);
display: flex; flex-direction: column; gap: 8px; }
.qhead input, .qhead select, textarea {
font: inherit; width: 100%; padding: 6px 9px; border-radius: 6px;
border: 1px solid var(--line); background: var(--bg); color: var(--fg);
}
textarea { font-family: var(--mono); font-size: 12px; min-height: 62px; resize: vertical; }
.row { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--muted); }
.banner { margin: 10px 14px; padding: 9px 11px; border-radius: 7px; font-size: 12px;
border: 1px solid color-mix(in srgb, var(--warn) 45%, transparent);
background: color-mix(in srgb, var(--warn) 10%, transparent); color: var(--fg); }
.group { border-bottom: 1px solid var(--line); }
.ghead { padding: 9px 14px; display: flex; align-items: center; gap: 8px;
cursor: pointer; user-select: none; background: var(--panel2); }
.ghead:hover { background: color-mix(in srgb, var(--accent) 8%, var(--panel2)); }
.caret { color: var(--muted); font-size: 10px; width: 10px; transition: transform .12s; }
.group.closed .caret { transform: rotate(-90deg); }
.group.closed .glist { display: none; }
.gtitle { font-weight: 620; font-size: 13px; flex: 1; }
.gcount { font-size: 11px; padding: 1px 7px; border-radius: 999px; background: var(--bg); color: var(--muted); }
.gcount.live { color: #fff; background: var(--bad); }
.alert { padding: 9px 14px 9px 26px; border-top: 1px solid var(--line); cursor: pointer; }
.alert:hover { background: var(--panel2); }
.alert.sel { background: color-mix(in srgb, var(--accent) 14%, transparent); box-shadow: inset 3px 0 var(--accent); }
.alert.muted { opacity: .55; }
.alert .t { font-weight: 600; font-size: 13px; word-break: break-all; }
.alert .m { color: var(--muted); font-size: 12px; }
.alert .why { font-size: 11.5px; margin-top: 3px; }
.age { font-family: var(--mono); font-size: 11.5px; color: var(--muted); }
.tag { font-size: 10px; padding: 1px 6px; border-radius: 4px; border: 1px solid var(--line); color: var(--muted); }
.v-real { color: var(--bad); border-color: color-mix(in srgb, var(--bad) 45%, transparent); }
.v-unverified { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 45%, transparent); }
.v-chronic, .v-low_impact, .v-pending { color: var(--muted); }
.v-resolved { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 40%, transparent); }
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
padding: 16px 18px; margin-bottom: 16px; }
.card h2 { font-size: 12px; text-transform: uppercase; letter-spacing: .6px; color: var(--muted);
margin: 0 0 12px; font-weight: 600; }
.verdict { font-size: 17px; font-weight: 650; line-height: 1.35; margin-bottom: 8px; }
.assessment { color: var(--muted); }
.meta { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; align-items: center; }
table { width: 100%; border-collapse: collapse; }
td { padding: 5px 8px; border-bottom: 1px solid var(--line); vertical-align: top; font-size: 13px; }
tr:last-child td { border-bottom: none; }
td.k { color: var(--muted); width: 190px; white-space: nowrap; }
td.v { font-family: var(--mono); font-size: 12.5px; word-break: break-word; }
.tone-ok { color: var(--ok); } .tone-warn { color: var(--warn); }
.tone-bad { color: var(--bad); } .tone-info { color: var(--fg); }
.det { display: block; color: var(--muted); font-family: inherit; font-size: 12px; margin-top: 3px; }
ol.actions { margin: 0; padding-left: 0; list-style: none; counter-reset: a; }
ol.actions li { counter-increment: a; padding: 9px 0 9px 30px; border-bottom: 1px solid var(--line); position: relative; }
ol.actions li:last-child { border-bottom: none; }
ol.actions li::before {
content: counter(a); position: absolute; left: 0; top: 9px; width: 20px; height: 20px;
border-radius: 50%; border: 1px solid var(--line); font-size: 11px; color: var(--muted);
display: grid; place-items: center;
}
ol.actions li.done::before { content: "\2713"; color: var(--ok); border-color: var(--ok); }
.owner { font-size: 11px; padding: 1px 6px; border-radius: 4px; background: var(--panel2);
color: var(--muted); margin-left: 6px; }
.owner.infra { color: var(--warn); }
.kind { font-size: 10px; text-transform: uppercase; letter-spacing: .5px; color: var(--muted); margin-left: 6px; }
.guide { display: block; font-size: 12px; color: var(--muted); margin-top: 2px; }
.draft { border: 1px solid var(--line); border-radius: 8px; margin-bottom: 12px; overflow: hidden; }
.draft .dh { padding: 9px 12px; background: var(--panel2); display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.draft .dl { font-weight: 600; font-size: 13px; flex: 1; min-width: 180px; }
.draft .dw { padding: 8px 12px; color: var(--warn); font-size: 12px; border-bottom: 1px solid var(--line); }
.draft pre { margin: 0; padding: 12px; white-space: pre-wrap; font-family: var(--mono); font-size: 12.5px; }
.contact { font-family: var(--mono); font-size: 12.5px; }
.notes li { color: var(--warn); margin-bottom: 5px; font-size: 13px; }
.empty { color: var(--muted); padding: 40px 20px; text-align: center; }
.spinner { width: 14px; height: 14px; border: 2px solid var(--line); border-top-color: var(--accent);
border-radius: 50%; display: inline-block; animation: spin .7s linear infinite; vertical-align: -2px; }
@keyframes spin { to { transform: rotate(360deg); } }
details.raw summary { cursor: pointer; color: var(--muted); font-size: 12px; }
details.raw pre { background: var(--panel2); padding: 10px; border-radius: 6px; overflow-x: auto; font-size: 11.5px; }
.ro { font-size: 11px; color: var(--muted); border: 1px dashed var(--line); padding: 2px 8px; border-radius: 999px; }
.infsec { border-bottom: 1px solid var(--line); }
.infsec .ghead { background: var(--panel2); }
.infrow { padding: 6px 14px 6px 26px; border-top: 1px solid var(--line); display: flex; gap: 8px; font-size: 12.5px; }
.infrow .n { flex: 1; }
</style>
</head>
<body>
<header>
<h1>CX Triage <span>alert diagnosis over CX-Tools</span></h1>
<span class="ro">read-only &middot; suggests, never acts</span>
<div class="pills" id="pills"></div>
<button id="refresh">Refresh</button>
</header>
<main>
<section id="queue">
<div class="tabs">
<div class="tab on" data-tab="cx" id="tabCx">CX runbooks</div>
<div class="tab" data-tab="infra" id="tabInfra">Infrastructure</div>
</div>
<div id="warnings"></div>
<div class="qhead">
<input id="search" type="search" placeholder="Filter by name, host, IP, org, ID...">
<label class="row"><input type="checkbox" id="showNoise" style="width:auto">
Show screened-out (chronic / pending / resolved) <span id="noiseCount"></span></label>
<div class="row" id="excludedNote"></div>
<details>
<summary style="cursor:pointer;color:var(--muted);font-size:12px">Paste an alert manually</summary>
<textarea id="paste" placeholder='Paste an ALERTS{...} line or a Prometheus graph URL'></textarea>
<button id="parseBtn" style="margin-top:6px">Diagnose pasted alert</button>
</details>
</div>
<div id="list"></div>
</section>
<section id="detail"><div class="empty">Pick an alert to diagnose.</div></section>
</main>
<script>
const $ = (s) => document.querySelector(s);
const esc = (s) => String(s == null ? "" : s).replace(/[&<>"']/g, c => (
{"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
let STATE = { data: null, tab: "cx", selected: null, polling: null, closed: {}, lastBody: null };
const api = (p, o) => fetch(p, o).then(r => r.json());
async function loadHealth() {
const h = await api("/api/health");
$("#pills").innerHTML = (h.checks || []).map(c =>
`<span class="pill ${c.ok ? "ok" : "bad"}" title="${esc(c.detail)}">${esc(c.name)}</span>`).join("");
}
async function loadAlerts(force) {
if (!STATE.data) $("#list").innerHTML = '<div class="empty"><span class="spinner"></span> Loading alerts…</div>';
const d = await api("/api/alerts" + (force ? "?force=1" : ""));
STATE.data = d;
if (d.error) $("#list").innerHTML = `<div class="empty tone-bad">${esc(d.error)}</div>`;
const t = d.totals || {};
const s = d.summary || {};
$("#tabCx").innerHTML = `CX runbooks <b>${s.actionable || 0}</b>`;
$("#tabInfra").innerHTML = `Infrastructure <b>${t.infrastructure || 0}</b>`;
$("#noiseCount").innerHTML = `<span class="tag">${s.screened_out || 0} screened out</span>`;
$("#excludedNote").textContent = d.excluded_note || "";
$("#warnings").innerHTML = (d.warnings || []).map(w => `<div class="banner">${esc(w)}</div>`).join("");
render();
}
function matches(a, q) {
if (!q) return true;
return [a.instance_name, a.host, a.floating_ip, a.openstack_id, a.org_name, a.org_id, a.title, a.alertname]
.join(" ").toLowerCase().includes(q);
}
function render() {
document.querySelectorAll(".tab").forEach(t => t.classList.toggle("on", t.dataset.tab === STATE.tab));
STATE.tab === "cx" ? renderCx() : renderInfra();
}
function renderCx() {
const d = STATE.data || {};
const q = $("#search").value.trim().toLowerCase();
const showNoise = $("#showNoise").checked;
const out = [];
for (const g of d.groups || []) {
let rows = (g.alerts || []).filter(a => matches(a, q));
if (!showNoise) rows = rows.filter(a => a.screen && a.screen.actionable);
if (!rows.length) continue;
const live = rows.filter(a => a.screen && a.screen.actionable).length;
const closed = STATE.closed[g.kind] === true;
out.push(`<div class="group ${closed ? "closed" : ""}" data-kind="${esc(g.kind)}">
<div class="ghead">
<span class="caret">&#9660;</span>
<span class="gtitle">${esc(g.title)}</span>
<span class="gcount ${live ? "live" : ""}">${live ? live + " to action" : "0 to action"}</span>
${g.noise ? `<span class="gcount" title="screened out: chronic, pending, low impact or already resolved">${g.noise} not new</span>` : ""}
</div>
<div class="glist">${rows.map(a => alertRow(a)).join("")}</div>
</div>`);
}
$("#list").innerHTML = out.length ? out.join("") :
`<div class="empty">${(d.summary || {}).actionable === 0
? "Nothing new needs action. " + ((d.summary || {}).screened_out || 0) + " alert(s) screened out \u2014 tick the box above to see them."
: "No matching alerts."}</div>`;
document.querySelectorAll(".group .ghead").forEach(el => el.onclick = () => {
const kind = el.parentElement.dataset.kind;
STATE.closed[kind] = !STATE.closed[kind];
render();
});
document.querySelectorAll(".alert").forEach(el =>
el.onclick = () => triage({ alert_id: el.dataset.id }, el.dataset.id));
}
function alertRow(a) {
const subject = a.kind === "duplicate_ip" ? a.floating_ip
: (a.kind === "rogue_vm" || a.kind === "total_gpus" || a.kind === "orphan_vm") ? a.host
: (a.instance_name || a.openstack_id || "unknown");
const bits = [a.region_label || a.region, a.status, a.org_name].filter(Boolean);
const sc = a.screen || {};
return `<div class="alert ${STATE.selected === a.id ? "sel" : ""} ${sc.actionable ? "" : "muted"}" data-id="${esc(a.id)}">
<div style="display:flex;gap:8px;align-items:baseline">
<div class="t" style="flex:1">${esc(subject)}</div>
<div class="age" title="${a.age_is_reset ? "condition has held this long; Prometheus says only " + esc(a.age_text) + " because a pipeline dip reset it" : "how long the condition has held"}">${
esc(a.effective_age_text || "")}${a.age_is_reset ? "*" : ""}</div>
</div>
<div class="m">${esc(bits.join(" · "))}</div>
<div class="why"><span class="tag v-${esc(sc.verdict || "unverified")}">${esc(sc.label || "")}</span>
<span style="color:var(--muted)"> ${esc(sc.reason || "")}</span></div>
</div>`;
}
function renderInfra() {
const d = STATE.data || {};
const q = $("#search").value.trim().toLowerCase();
const out = [`<div class="banner">These are not CX runbook alerts &mdash; they are host and platform
rules, shown here so they stay out of the triage queue.</div>`];
for (const s of d.infrastructure || []) {
const names = (s.by_alertname || []).filter(n => !q || n.name.toLowerCase().includes(q));
if (!names.length) continue;
const closed = STATE.closed["inf:" + s.source] === true;
out.push(`<div class="infsec group ${closed ? "closed" : ""}" data-kind="inf:${esc(s.source)}">
<div class="ghead">
<span class="caret">&#9660;</span>
<span class="gtitle">${esc(s.label)}</span>
<span class="gcount">${s.total} firing</span>
</div>
<div class="glist">${names.map(n =>
`<div class="infrow"><span class="n">${esc(n.name)}</span><span class="tag">${n.count}</span></div>`
).join("")}</div>
</div>`);
}
$("#list").innerHTML = out.join("");
document.querySelectorAll(".infsec .ghead").forEach(el => el.onclick = () => {
const k = el.parentElement.dataset.kind;
STATE.closed[k] = !STATE.closed[k];
render();
});
}
async function triage(body, id) {
STATE.selected = id || null;
STATE.lastBody = body;
render();
if (STATE.polling) { clearInterval(STATE.polling); STATE.polling = null; }
$("#detail").innerHTML = '<div class="empty"><span class="spinner"></span> Checking Infrahub, OpenStack and InfraInsight…<br><small>Host-wide checks can take up to a minute.</small></div>';
const started = await api("/api/triage", {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
if (started.error) { $("#detail").innerHTML = `<div class="empty tone-bad">${esc(started.error)}</div>`; return; }
STATE.polling = setInterval(async () => {
const job = await api("/api/jobs/" + started.job_id);
if (job.state === "running") return;
clearInterval(STATE.polling); STATE.polling = null;
if (job.state === "error") {
$("#detail").innerHTML = `<div class="card"><h2>Triage failed</h2><pre>${esc(job.error)}</pre></div>`;
return;
}
renderDiagnosis(job.result);
}, 900);
}
function diagnoseAnyway() {
triage({ ...(STATE.lastBody || {}), force: true }, STATE.selected);
}
function renderDiagnosis(d) {
const a = d.alert || {};
const sc = a.screen || {};
const out = [];
out.push(`<div class="card">
<h2>${esc(a.title)} &middot; ${esc(a.region_label || a.region || "region unknown")}</h2>
<div class="verdict">${esc(d.verdict || d.error || "No verdict")}</div>
${d.assessment ? `<div class="assessment">${esc(d.assessment)}</div>` : ""}
<div class="meta">
<span class="tag v-${esc(sc.verdict || "unverified")}">${esc(sc.label || "")}</span>
<span class="tag">priority ${esc(a.priority)}</span>
<span class="tag">confidence ${esc(d.confidence)}</span>
<span class="tag">ETTR ${esc(a.ettr)}</span>
<span class="tag">held ${esc(a.effective_age_text || "?")}</span>
${a.age_is_reset ? `<span class="tag" title="a metric-pipeline dip reset Prometheus' activeAt">prometheus says ${esc(a.age_text)}</span>` : ""}
${d.elapsed_seconds ? `<span class="tag">diagnosed in ${d.elapsed_seconds}s</span>` : ""}
${a.is_kubernetes ? '<span class="tag">likely K8s node</span>' : ""}
${!sc.actionable ? '<button onclick="diagnoseAnyway()">Diagnose anyway</button>' : ""}
</div>
</div>`);
if (d.error) out.push(`<div class="card"><h2>Lookup problem</h2><div class="tone-bad">${esc(d.error)}</div></div>`);
if ((d.notes || []).length)
out.push(`<div class="card"><h2>Caveats</h2><ul class="notes">${
d.notes.map(n => `<li>${esc(n)}</li>`).join("")}</ul></div>`);
if ((d.findings || []).length)
out.push(`<div class="card"><h2>What the platforms say</h2><table>${
d.findings.map(f => `<tr><td class="k">${esc(f.label)}</td><td class="v tone-${esc(f.tone)}">${
esc(f.value)}${f.detail ? `<span class="det">${esc(f.detail)}</span>` : ""}</td></tr>`).join("")
}</table></div>`);
if ((d.actions || []).length)
out.push(`<div class="card"><h2>Next steps</h2><ol class="actions">${
d.actions.map(x => `<li class="${x.status === "done" ? "done" : ""}">${esc(x.text)}
<span class="owner ${x.owner !== "CX" ? "infra" : ""}">${esc(x.owner)}</span>
<span class="kind">${esc(x.kind)}</span>
${x.guide ? `<span class="guide">Guide: ${esc(x.guide)}</span>` : ""}
${x.detail ? `<span class="guide">${esc(x.detail)}</span>` : ""}</li>`).join("")
}</ol></div>`);
const c = d.contacts || {};
if ((d.drafts || []).length)
out.push(`<div class="card">
<h2>Suggested customer comms</h2>
<div style="margin-bottom:12px">
${c.organization ? `<div class="contact">Org: ${esc(c.organization)}</div>` : ""}
${(c.owners || []).length ? c.owners.map(o => `<div class="contact">${esc(o)}</div>`).join("")
: '<div class="tone-warn">No owner contacts resolved — look the org up in the Admin Portal.</div>'}
</div>
${d.drafts.map((x, i) => `<div class="draft">
<div class="dh"><span class="dl">${esc(x.label)}</span>
<span class="tag">${esc(x.channel)}</span>
<button onclick="copyDraft(${i})" id="cp${i}">Copy</button></div>
${x.when ? `<div class="dw">When: ${esc(x.when)}</div>` : ""}
${x.unfilled.length ? `<div class="dw">Still to fill in: ${esc(x.unfilled.join(", "))}</div>` : ""}
<pre>${esc(x.body)}</pre></div>`).join("")}
<div style="color:var(--muted);font-size:12px">Send from HubSpot. This app never contacts anyone.</div>
</div>`);
out.push(`<div class="card"><h2>Evidence</h2>
<details class="raw"><summary>Alert labels, annotations and screening</summary><pre>${
esc(JSON.stringify({ rule: { file: a.rule_file, group: a.rule_group, for_seconds: a.for_seconds },
screen: a.screen, labels: a.labels, annotations: a.annotations }, null, 2))}</pre></details>
<details class="raw" style="margin-top:8px"><summary>Raw CX-Tools output</summary><pre>${
esc(JSON.stringify(d.evidence, null, 2))}</pre></details></div>`);
$("#detail").innerHTML = out.join("");
window.__drafts = d.drafts || [];
}
function copyDraft(i) {
navigator.clipboard.writeText((window.__drafts[i] || {}).body || "").then(() => {
const b = $("#cp" + i), old = b.textContent;
b.textContent = "Copied"; setTimeout(() => b.textContent = old, 1200);
});
}
$("#parseBtn").onclick = async () => {
const text = $("#paste").value.trim();
if (!text) return;
const res = await api("/api/parse", {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text }) });
if (res.error) { $("#detail").innerHTML = `<div class="empty tone-bad">${esc(res.error)}</div>`; return; }
const first = (res.parsed || [])[0];
if (!first) return;
if (!first.supported) {
$("#detail").innerHTML = `<div class="empty tone-warn">Parsed "${esc(first.alert.alertname)}" but no CX runbook covers it.</div>`;
return;
}
triage({ labels: first.alert.labels, annotations: first.alert.annotations }, null);
};
document.querySelectorAll(".tab").forEach(t => t.onclick = () => { STATE.tab = t.dataset.tab; render(); });
$("#refresh").onclick = () => { loadHealth(); loadAlerts(true); };
$("#search").oninput = render;
$("#showNoise").onchange = render;
loadHealth();
loadAlerts(false);
setInterval(() => loadAlerts(false), 60000);
</script>
</body>
</html>
"""

181
triagelib/ui_linkage.py Normal file
View File

@@ -0,0 +1,181 @@
"""Linkage scan page: Infrahub records and OpenStack servers that lost each other."""
from __future__ import annotations
PAGE = r"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CX Triage — Linkage</title>
<style>
:root{--bg:#0d1017;--panel:#141821;--panel2:#1b202b;--line:#262d3a;--line2:#333b4a;
--fg:#e8ebf2;--dim:#8a93a5;--faint:#5d6675;--accent:#4c8dff;
--ok:#35c46a;--warn:#e0a336;--bad:#f2545b;--violet:#a97bf0;
--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
@media(prefers-color-scheme:light){:root{--bg:#f4f6f9;--panel:#fff;--panel2:#f1f4f8;--line:#e0e5ec;
--line2:#cfd6e0;--fg:#151a22;--dim:#5b6473;--faint:#8e97a5;--accent:#1f6feb;
--ok:#12864a;--warn:#96650a;--bad:#cf2530;--violet:#7a44d6}}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);
font:14px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif}
header{display:flex;align-items:center;gap:14px;padding:9px 18px;background:var(--panel);
border-bottom:1px solid var(--line);position:sticky;top:0;z-index:10;flex-wrap:wrap}
.brand{font-weight:680;font-size:14px}
.brand em{font-style:normal;color:var(--faint);font-weight:400;font-size:12.5px}
a.navlink{font-size:12.5px;color:var(--accent);text-decoration:none;border:1px solid var(--line2);
padding:4px 10px;border-radius:7px}
.spacer{margin-left:auto}
button{font:inherit;font-size:13px;padding:7px 13px;border-radius:8px;border:1px solid var(--line2);
background:var(--panel2);color:var(--fg);cursor:pointer}
button.pri{background:var(--accent);border-color:var(--accent);color:#fff;font-weight:600}
button:disabled{opacity:.45;cursor:not-allowed}
button.sm{padding:3px 8px;font-size:11.5px}
main{padding:22px 26px 70px;max-width:1500px}
.lede{color:var(--dim);max-width:88ch;margin-bottom:18px}
.cards{display:flex;gap:12px;flex-wrap:wrap;margin-bottom:20px}
.stat{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:13px 17px;min-width:150px}
.stat .n{font-size:23px;font-weight:680;font-family:var(--mono)}
.stat .l{font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--faint);margin-top:2px}
.stat.hot .n{color:var(--bad)}
.stat.warn .n{color:var(--warn)}
h2{font-size:13px;text-transform:uppercase;letter-spacing:.6px;color:var(--dim);
margin:26px 0 10px;font-weight:600}
.tbl{background:var(--panel);border:1px solid var(--line);border-radius:10px;overflow:hidden}
.tr{display:grid;grid-template-columns:1.5fr 110px 1.4fr 110px 96px 90px;gap:10px;padding:9px 14px;
border-top:1px solid var(--line);align-items:center;font-size:12.5px}
.tr:first-child{border-top:none;background:var(--panel2);font-size:10.5px;text-transform:uppercase;
letter-spacing:.5px;color:var(--faint)}
.tr .nm{font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.tr .mono{font-family:var(--mono);font-size:11.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.tag{font-size:10px;padding:1px 7px;border-radius:99px;border:1px solid var(--line2);color:var(--dim)}
.tag.high{color:var(--bad);border-color:color-mix(in srgb,var(--bad) 50%,transparent)}
.tag.medium{color:var(--warn);border-color:color-mix(in srgb,var(--warn) 50%,transparent)}
.tag.none{color:var(--faint)}
.arrow{color:var(--ok);text-align:center}
.sub{color:var(--faint);font-size:11px}
.empty{color:var(--faint);padding:40px;text-align:center}
.spin{width:15px;height:15px;border:2px solid var(--line2);border-top-color:var(--accent);
border-radius:50%;display:inline-block;animation:sp .7s linear infinite;vertical-align:-3px}
@keyframes sp{to{transform:rotate(360deg)}}
.note{border:1px solid color-mix(in srgb,var(--warn) 45%,transparent);
background:color-mix(in srgb,var(--warn) 9%,transparent);border-radius:8px;padding:11px 14px;
font-size:12.5px;margin-bottom:16px}
.t-bad{color:var(--bad)} .t-ok{color:var(--ok)} .t-warn{color:var(--warn)}
</style>
</head>
<body>
<header>
<div class="brand">CX Triage <em>— linkage scan</em></div>
<a class="navlink" href="/">&larr; Alert queue</a>
<div class="spacer"></div>
<span class="sub" id="meta"></span>
<button class="pri" id="run">Run scan</button>
</header>
<main>
<p class="lede">A VM in ERROR is not always a failed build. If the server was created but the link back to
Infrahub was never written, Infrahub shows ERROR or CREATING with no usable <code>openstack_id</code> while a
perfectly good server of the same name is running. This scans both sides in bulk and pairs them up by name —
and finds the reverse too: OpenStack servers that no Infrahub record claims, which is what
<em>Suspected Orphan VM</em> was meant to catch before its input metric went empty.</p>
<div id="body"><div class="empty">Run a scan to begin. It lists every server in all four regions, so it takes a few minutes.</div></div>
</main>
<script>
const $=s=>document.querySelector(s);
const esc=s=>String(s==null?"":s).replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
const api=(p,o)=>fetch(p,o).then(r=>r.json());
let POLL=null;
async function tick(){
const s=await api("/api/linkage");
$("#meta").textContent = s.state==="running" ? "scanning… "+(s.progress||"")
: s.finished ? "last scan "+(s.age_seconds>90?Math.round(s.age_seconds/60)+"m":s.age_seconds+"s")+" ago" : "";
$("#run").disabled = s.state==="running";
if(s.state==="running"){
$("#body").innerHTML=`<div class="empty"><span class="spin"></span><br><br>${esc(s.progress||"scanning")}…<br>
<span class="sub">Listing every server across four regions — a few minutes.</span></div>`;
return;
}
if(POLL){clearInterval(POLL);POLL=null;}
if(s.state==="error"){$("#body").innerHTML=`<div class="empty t-bad">${esc(s.error)}</div>`;return;}
if(s.state==="done") render(s.result);
}
function render(r){
const links=r.broken_links||[], orphans=r.orphans||[];
const high=links.filter(l=>l.confidence==="high");
const out=[];
out.push(`<div class="cards">
<div class="stat ${high.length?"hot":""}"><div class="n">${high.length}</div><div class="l">likely linkage failures</div></div>
<div class="stat warn"><div class="n">${links.length}</div><div class="l">Infrahub records with a broken link</div></div>
<div class="stat ${r.orphan_total?"warn":""}"><div class="n">${r.orphan_total}</div><div class="l">OpenStack servers no record claims</div></div>
<div class="stat"><div class="n">${r.openstack_servers}</div><div class="l">servers scanned</div></div>
<div class="stat"><div class="n">${r.infrahub_records}</div><div class="l">Infrahub records</div></div>
</div>`);
if(Object.keys(r.region_failures||{}).length)
out.push(`<div class="note">Some regions could not be listed: ${
Object.entries(r.region_failures).map(([k,v])=>`<b>${esc(k)}</b> (${esc(v)})`).join(", ")}.
Results below exclude those regions entirely (${r.skipped_unscanned_regions||0} record(s) skipped), so nothing
here is a false positive from a failed listing — but the scan is not complete.</div>`);
out.push(`<h2>Infrahub records whose OpenStack server is missing or unlinked</h2>`);
if(!links.length) out.push('<div class="tbl"><div class="empty">None — every record resolves.</div></div>');
else out.push(`<div class="tbl">
<div class="tr"><span>Infrahub instance</span><span>IH status</span><span>Matching OpenStack server</span>
<span>OS status</span><span>Confidence</span><span></span></div>
${links.slice(0,300).map((l,i)=>`<div class="tr">
<span class="nm">${esc(l.instance_name)}<span class="sub"><br>${esc(l.organization)} · ${esc(l.region)}</span></span>
<span class="mono t-bad">${esc(l.infrahub_status)}</span>
<span>${l.candidate
? `<span class="mono">${esc(l.candidate.id)}</span><span class="sub"><br>${esc(l.candidate.name)} · ${esc(l.candidate.region)}${l.candidate_claimed_by_other?' · <span class="t-warn">claimed by another record</span>':""}</span>`
: `<span class="sub">${esc(l.reason)}</span>`}</span>
<span class="mono">${esc(l.candidate?l.candidate.status:"")}</span>
<span><span class="tag ${esc(l.confidence)}">${esc(l.confidence)}</span></span>
<span>${l.candidate?`<button class="sm" onclick="enrich(${i},'${esc(l.candidate.region)}','${esc(l.candidate.id)}')">Details</button>`:""}</span>
</div><div class="tr" id="ex${i}" style="display:none;grid-template-columns:1fr"></div>`).join("")}
</div>`);
if(links.length>300) out.push(`<div class="sub" style="margin-top:8px">Showing the first 300 of ${links.length}.</div>`);
out.push(`<h2>OpenStack servers with no Infrahub record</h2>`);
if(!orphans.length) out.push('<div class="tbl"><div class="empty">None.</div></div>');
else out.push(`<div class="tbl">
<div class="tr" style="grid-template-columns:1.4fr 1.4fr 110px 1fr 120px"><span>Server</span><span>OpenStack ID</span>
<span>Status</span><span>Host</span><span>Name in Infrahub?</span></div>
${orphans.slice(0,200).map(o=>`<div class="tr" style="grid-template-columns:1.4fr 1.4fr 110px 1fr 120px">
<span class="nm">${esc(o.name||"(unnamed)")}<span class="sub"><br>${esc(o.region)}</span></span>
<span class="mono">${esc(o.id)}</span>
<span class="mono">${esc(o.status)}</span>
<span class="mono">${esc(o.host||"")}</span>
<span class="${o.name_known_to_infrahub?"t-warn":"t-bad"}">${o.name_known_to_infrahub?"name exists":"unknown"}</span>
</div>`).join("")}
</div>`);
if(r.orphan_total>orphans.length) out.push(`<div class="sub" style="margin-top:8px">Showing ${orphans.length} of ${r.orphan_total}.</div>`);
$("#body").innerHTML=out.join("");
}
async function enrich(i,region,osid){
const row=$("#ex"+i);
row.style.display="block"; row.innerHTML='<span class="spin"></span> loading…';
const d=await api("/api/linkage/enrich",{method:"POST",headers:{"Content-Type":"application/json"},
body:JSON.stringify({region,openstack_id:osid})});
row.innerHTML = d.ok
? `<span class="mono">created ${esc(d.created)} · launched ${esc(d.launched||"")} · status ${esc(d.status)} · host ${esc(d.host||"")}</span>
<span class="sub"><br>fault: ${esc(d.fault)}</span>`
: `<span class="t-bad">${esc(d.error)}</span>`;
}
$("#run").onclick=async()=>{
await api("/api/linkage/scan",{method:"POST",headers:{"Content-Type":"application/json"},body:"{}"});
if(POLL) clearInterval(POLL);
POLL=setInterval(tick,2500); tick();
};
tick();
</script>
</body>
</html>
"""

221
triagelib/ui_settings.py Normal file
View File

@@ -0,0 +1,221 @@
"""Settings page: suppression rules and comms identity."""
from __future__ import annotations
PAGE = r"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CX Triage — Settings</title>
<style>
:root{--bg:#0d1017;--panel:#141821;--panel2:#1b202b;--line:#262d3a;--line2:#333b4a;
--fg:#e8ebf2;--dim:#8a93a5;--faint:#5d6675;--accent:#4c8dff;
--ok:#35c46a;--warn:#e0a336;--bad:#f2545b;--violet:#a97bf0;
--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
@media(prefers-color-scheme:light){:root{--bg:#f4f6f9;--panel:#fff;--panel2:#f1f4f8;--line:#e0e5ec;
--line2:#cfd6e0;--fg:#151a22;--dim:#5b6473;--faint:#8e97a5;--accent:#1f6feb;
--ok:#12864a;--warn:#96650a;--bad:#cf2530;--violet:#7a44d6}}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);
font:14px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif}
header{display:flex;align-items:center;gap:14px;padding:9px 18px;background:var(--panel);
border-bottom:1px solid var(--line);position:sticky;top:0;z-index:10;flex-wrap:wrap}
.brand{font-weight:680;font-size:14px}
.brand em{font-style:normal;color:var(--faint);font-weight:400;font-size:12.5px}
a.navlink{font-size:12.5px;color:var(--accent);text-decoration:none;border:1px solid var(--line2);
padding:4px 10px;border-radius:7px}
.spacer{margin-left:auto}
button{font:inherit;font-size:13px;padding:7px 13px;border-radius:8px;border:1px solid var(--line2);
background:var(--panel2);color:var(--fg);cursor:pointer}
button:hover:not(:disabled){border-color:var(--accent)}
button.pri{background:var(--accent);border-color:var(--accent);color:#fff;font-weight:600}
button.sm{padding:3px 9px;font-size:11.5px}
button.del{color:var(--bad);border-color:color-mix(in srgb,var(--bad) 45%,transparent)}
main{padding:22px 26px 80px;max-width:1080px}
h2{font-size:13px;text-transform:uppercase;letter-spacing:.6px;color:var(--dim);margin:26px 0 10px;font-weight:600}
h2:first-child{margin-top:0}
.lede{color:var(--dim);max-width:82ch;margin-bottom:16px}
.card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:16px 18px;margin-bottom:12px}
.rule{background:var(--panel);border:1px solid var(--line);border-radius:10px;margin-bottom:10px;overflow:hidden}
.rule.off{opacity:.55}
.rh{display:flex;align-items:center;gap:10px;padding:12px 16px;flex-wrap:wrap}
.rh .nm{font-weight:640;font-size:14px}
.rh .why{color:var(--dim);font-size:12.5px;width:100%;margin-top:-4px}
.conds{display:flex;gap:6px;flex-wrap:wrap;padding:0 16px 12px}
.cond{font-size:11.5px;font-family:var(--mono);padding:3px 9px;border-radius:6px;
background:var(--panel2);border:1px solid var(--line2)}
.cond b{color:var(--accent);font-weight:600}
.andor{font-size:10.5px;color:var(--faint);align-self:center;text-transform:uppercase;letter-spacing:.5px}
.sw{position:relative;width:34px;height:19px;flex:none}
.sw input{opacity:0;width:0;height:0}
.sw span{position:absolute;inset:0;background:var(--line2);border-radius:99px;transition:.15s;cursor:pointer}
.sw span::before{content:"";position:absolute;width:15px;height:15px;left:2px;top:2px;background:#fff;
border-radius:50%;transition:.15s}
.sw input:checked + span{background:var(--ok)}
.sw input:checked + span::before{transform:translateX(15px)}
.grid{display:grid;grid-template-columns:170px 1fr;gap:10px 12px;align-items:center}
label.f{font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--faint)}
input,select,textarea{font:inherit;font-size:13px;padding:7px 10px;border-radius:7px;
border:1px solid var(--line2);background:var(--bg);color:var(--fg);width:100%}
.condrow{display:grid;grid-template-columns:190px 1fr 34px;gap:8px;margin-bottom:7px}
.hint{font-size:11.5px;color:var(--faint);margin-top:4px}
.pv{margin-top:12px;border:1px solid var(--line2);border-radius:8px;padding:11px 13px;background:var(--panel2);font-size:12.5px}
.pv ul{margin:6px 0 0;padding-left:18px;max-height:180px;overflow-y:auto}
.t-ok{color:var(--ok)}.t-bad{color:var(--bad)}.t-warn{color:var(--warn)}
.empty{color:var(--faint);padding:26px;text-align:center}
.saved{color:var(--ok);font-size:12.5px}
</style>
</head>
<body>
<header>
<div class="brand">CX Triage <em>— settings</em></div>
<a class="navlink" href="/">&larr; Alert queue</a>
<a class="navlink" href="/linkage">Linkage scan</a>
<div class="spacer"></div>
<span class="saved" id="saved"></span>
</header>
<main>
<h2>Suppression rules</h2>
<p class="lede">Hide alerts you already know about. A rule fires when <b>every</b> condition it sets matches,
so you can combine them &mdash; for example type <code>error</code> <em>and</em> organisation containing
<code>modal</code>. Suppressed alerts are not deleted: they stay reachable under the
<b>hidden by a rule</b> filter on the queue.</p>
<div id="rules"></div>
<button class="pri" id="addRule">Add a rule</button>
<div id="editor"></div>
<h2>Comms identity</h2>
<div class="card">
<div class="grid">
<label class="f">Sign-off name</label>
<div><input id="agent" placeholder="e.g. Mohammad Affan">
<div class="hint">Appended after &ldquo;Kind Regards&rdquo; in customer emails.</div></div>
<label class="f">Chronic after (days)</label>
<div><input id="chronic" type="number" min="1" max="90" style="max-width:110px">
<div class="hint">An alert whose condition has held longer than this is treated as chronic rather than new
work &mdash; except for types with a runbook time commitment, which become <b>overdue</b> instead.</div></div>
</div>
<div style="margin-top:14px"><button class="pri" id="saveGeneral">Save</button></div>
</div>
<h2>Where this is stored</h2>
<div class="card"><span class="hint" id="path"></span></div>
</main>
<script>
const $=s=>document.querySelector(s);
const esc=s=>String(s==null?"":s).replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
const api=(p,o)=>fetch(p,o).then(r=>r.json());
let CFG=null, EDIT=null;
function flash(msg){$("#saved").textContent=msg;setTimeout(()=>$("#saved").textContent="",2200);}
async function load(){ CFG=await api("/api/settings"); render(); }
function render(){
$("#agent").value=CFG.agent_name||"";
$("#chronic").value=CFG.chronic_days||3;
$("#path").textContent=CFG.path;
const rs=CFG.rules||[];
$("#rules").innerHTML = rs.length ? rs.map(r=>`
<div class="rule ${r.enabled?"":"off"}">
<div class="rh">
<label class="sw"><input type="checkbox" ${r.enabled?"checked":""} onchange="toggle('${esc(r.id)}',this.checked)"><span></span></label>
<span class="nm">${esc(r.name)}</span>
<span class="spacer" style="margin-left:auto"></span>
<button class="sm" onclick='edit(${JSON.stringify(JSON.stringify(r))})'>Edit</button>
<button class="sm del" onclick="del('${esc(r.id)}','${esc(r.name)}')">Delete</button>
${r.reason?`<span class="why">${esc(r.reason)}</span>`:""}
</div>
<div class="conds">${Object.entries(r.conditions||{}).map(([k,v],i)=>
`${i?'<span class="andor">and</span>':""}<span class="cond"><b>${esc(CFG.conditions[k]||k)}</b> ${esc(v.join(" or "))}</span>`
).join("")||'<span class="cond t-bad">no conditions — inactive</span>'}</div>
</div>`).join("") : '<div class="card"><div class="empty">No rules yet.</div></div>';
}
function blank(){return {id:"",name:"",enabled:true,reason:"",conditions:{}};}
function edit(json){ EDIT=typeof json==="string"?JSON.parse(json):json; drawEditor(); }
$("#addRule").onclick=()=>{EDIT=blank();drawEditor();};
function drawEditor(){
if(!EDIT){$("#editor").innerHTML="";return;}
const conds=Object.entries(EDIT.conditions||{});
if(!conds.length) conds.push(["kind",[]]);
$("#editor").innerHTML=`<div class="card" style="border-color:var(--accent)">
<div class="grid">
<label class="f">Rule name</label><input id="rName" value="${esc(EDIT.name)}" placeholder="e.g. Modal ERROR noise">
<label class="f">Why (shown on the alert)</label><input id="rWhy" value="${esc(EDIT.reason)}" placeholder="e.g. Known batch churn, customer already aware">
</div>
<div style="margin-top:14px">
<label class="f">Conditions &mdash; all must match</label>
<div id="condList" style="margin-top:7px">${conds.map((c,i)=>condRow(c[0],c[1],i)).join("")}</div>
<button class="sm" onclick="addCond()">+ Add condition</button>
</div>
<div id="pv"></div>
<div style="margin-top:14px;display:flex;gap:9px;flex-wrap:wrap">
<button class="pri" onclick="saveRule()">Save rule</button>
<button onclick="previewRule()">Preview what this hides</button>
<button onclick="EDIT=null;drawEditor()">Cancel</button>
</div></div>`;
}
function condRow(field,values,i){
return `<div class="condrow" data-i="${i}">
<select class="cf">${Object.entries(CFG.conditions).map(([k,v])=>
`<option value="${esc(k)}" ${k===field?"selected":""}>${esc(v)}</option>`).join("")}</select>
<input class="cv" value="${esc((values||[]).join(", "))}" placeholder="comma-separated; any one matches">
<button class="sm del" onclick="this.parentElement.remove()">&times;</button></div>`;
}
function addCond(){ $("#condList").insertAdjacentHTML("beforeend", condRow("organization",[],Date.now())); }
function collect(){
const conditions={};
document.querySelectorAll("#condList .condrow").forEach(row=>{
const f=row.querySelector(".cf").value;
const v=row.querySelector(".cv").value.split(",").map(x=>x.trim()).filter(Boolean);
if(v.length) conditions[f]=(conditions[f]||[]).concat(v);
});
return {id:EDIT.id,name:$("#rName").value||"Untitled rule",reason:$("#rWhy").value,
enabled:EDIT.enabled!==false,conditions};
}
async function previewRule(){
const r=await api("/api/settings",{method:"POST",headers:{"Content-Type":"application/json"},
body:JSON.stringify({action:"preview",rule:collect()})});
$("#pv").innerHTML=`<div class="pv">
<b class="${r.count?"t-warn":"t-ok"}">${r.count}</b> currently-firing alert(s) would be hidden.
${r.count?`<ul>${r.matches.slice(0,40).map(m=>
`<li>${esc(m.title)} &mdash; ${esc(m.instance_name||m.host||"?")} <span style="color:var(--faint)">${esc(m.org_name||"")}</span></li>`
).join("")}</ul>`:""}</div>`;
}
async function saveRule(){
const r=await api("/api/settings",{method:"POST",headers:{"Content-Type":"application/json"},
body:JSON.stringify({action:"save_rule",rule:collect()})});
if(r.ok){CFG=r.settings;EDIT=null;drawEditor();render();flash("Rule saved");}
}
async function toggle(id,on){
const r=await api("/api/settings",{method:"POST",headers:{"Content-Type":"application/json"},
body:JSON.stringify({action:"toggle_rule",id,enabled:on})});
if(r.ok){CFG=r.settings;render();flash(on?"Rule enabled":"Rule disabled");}
}
async function del(id,name){
if(!confirm(`Delete the rule “${name}”? Alerts it was hiding will come back.`)) return;
const r=await api("/api/settings",{method:"POST",headers:{"Content-Type":"application/json"},
body:JSON.stringify({action:"delete_rule",id})});
if(r.ok){CFG=r.settings;render();flash("Rule deleted");}
}
$("#saveGeneral").onclick=async()=>{
const r=await api("/api/settings",{method:"POST",headers:{"Content-Type":"application/json"},
body:JSON.stringify({action:"general",agent_name:$("#agent").value,chronic_days:$("#chronic").value})});
if(r.ok){CFG=r.settings;flash("Saved");}
};
load();
</script>
</body>
</html>
"""

572
triagelib/ui_v2.py Normal file
View File

@@ -0,0 +1,572 @@
"""Action-oriented UI.
Design intent: the reader already knows the runbooks. Each case shows the state
of the world as a picture, one line of verdict, and the buttons that actually
move it forward. Everything else is collapsed.
"""
from __future__ import annotations
PAGE = r"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CX Triage</title>
<style>
:root {
--bg:#0d1017; --panel:#141821; --panel2:#1b202b; --line:#262d3a; --line2:#333b4a;
--fg:#e8ebf2; --dim:#8a93a5; --faint:#5d6675; --accent:#4c8dff;
--ok:#35c46a; --warn:#e0a336; --bad:#f2545b; --info:#59a0f5; --violet:#a97bf0;
--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
--r:10px;
}
@media (prefers-color-scheme:light){:root{
--bg:#f4f6f9;--panel:#fff;--panel2:#f1f4f8;--line:#e0e5ec;--line2:#cfd6e0;
--fg:#151a22;--dim:#5b6473;--faint:#8e97a5;--accent:#1f6feb;
--ok:#12864a;--warn:#96650a;--bad:#cf2530;--info:#0969da;--violet:#7a44d6;}}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);
font:14px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;}
button{font:inherit;font-size:13px;padding:7px 13px;border-radius:8px;border:1px solid var(--line2);
background:var(--panel2);color:var(--fg);cursor:pointer;transition:.12s}
button:hover:not(:disabled){border-color:var(--accent)}
button:disabled{opacity:.45;cursor:not-allowed}
button.pri{background:var(--accent);border-color:var(--accent);color:#fff;font-weight:600}
button.pri:hover:not(:disabled){filter:brightness(1.1)}
button.danger{border-color:color-mix(in srgb,var(--bad) 50%,transparent);color:var(--bad)}
button.sm{padding:4px 9px;font-size:12px}
header{display:flex;align-items:center;gap:14px;padding:9px 18px;background:var(--panel);
border-bottom:1px solid var(--line);position:sticky;top:0;z-index:20;flex-wrap:wrap}
.brand{font-weight:680;font-size:14px;letter-spacing:.2px}
.brand em{font-style:normal;color:var(--faint);font-weight:400;font-size:12.5px}
.navlink{font-size:12.5px;color:var(--accent);text-decoration:none;border:1px solid var(--line2);padding:4px 10px;border-radius:7px}
.navlink:hover{border-color:var(--accent)}
.chips{display:flex;gap:6px;flex-wrap:wrap;margin-left:6px}
.chip{font-size:11.5px;padding:3px 10px;border-radius:99px;border:1px solid var(--line2);
background:var(--panel2);color:var(--dim);cursor:pointer;white-space:nowrap;user-select:none}
.chip.on{background:var(--accent);border-color:var(--accent);color:#fff}
.chip b{font-weight:700}
.chip.overdue:not(.on){color:var(--bad);border-color:color-mix(in srgb,var(--bad) 45%,transparent)}
.spacer{margin-left:auto}
.dotstat{width:7px;height:7px;border-radius:50%;display:inline-block;margin-right:5px}
.filters{background:var(--panel);border-bottom:1px solid var(--line);padding:7px 18px;
display:flex;flex-direction:column;gap:5px;position:sticky;top:51px;z-index:19}
.frow{display:flex;align-items:center;gap:9px}
.flab{font-size:10.5px;text-transform:uppercase;letter-spacing:.5px;color:var(--faint);width:44px;flex:none}
main{display:grid;grid-template-columns:320px 1fr;height:calc(100vh - 118px)}
@media(max-width:940px){main{grid-template-columns:1fr}}
#rail{border-right:1px solid var(--line);background:var(--panel);overflow-y:auto}
.case .bar.chronic,.case .bar.low_impact{background:var(--faint)}
.case .bar.rule_defect{background:var(--violet)}
.case .bar.pending{background:var(--info);opacity:.5}
.case .bar.resolved{background:var(--ok)}
.statepill{font-size:9.5px;text-transform:uppercase;letter-spacing:.4px;padding:0 5px;
border-radius:3px;border:1px solid var(--line2);color:var(--faint);margin-left:6px}
.statepill.pending{color:var(--info);border-color:color-mix(in srgb,var(--info) 45%,transparent)}
#stage{overflow-y:auto;padding:22px 26px 60px}
.grp{border-bottom:1px solid var(--line)}
.grp-h{display:flex;align-items:center;gap:8px;padding:8px 14px;cursor:pointer;
background:var(--panel2);user-select:none;font-size:12px;letter-spacing:.3px;text-transform:uppercase;color:var(--dim)}
.grp-h:hover{color:var(--fg)}
.car{font-size:9px;width:9px;transition:.12s}
.grp.shut .car{transform:rotate(-90deg)}
.grp.shut .grp-b{display:none}
.grp-h .n{margin-left:auto;font-size:11px;padding:1px 7px;border-radius:99px;background:var(--bg);color:var(--dim);text-transform:none}
.grp-h .n.hot{background:var(--bad);color:#fff}
.case{padding:9px 14px;border-top:1px solid var(--line);cursor:pointer;display:flex;gap:9px;align-items:flex-start}
.case:hover{background:var(--panel2)}
.case.on{background:color-mix(in srgb,var(--accent) 15%,transparent);box-shadow:inset 3px 0 var(--accent)}
.case.off{opacity:.5}
.case .bar{width:3px;align-self:stretch;border-radius:2px;background:var(--faint);flex:none}
.case .bar.overdue{background:var(--bad)} .case .bar.real{background:var(--warn)}
.case .bar.unverified{background:var(--info)}
.case .mid{min-width:0;flex:1}
.case .nm{font-weight:600;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.case .sub{color:var(--dim);font-size:11.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.case .ag{font-family:var(--mono);font-size:11px;color:var(--faint);flex:none}
.hero{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);padding:20px 22px;margin-bottom:14px}
.crumb{font-size:11.5px;color:var(--faint);letter-spacing:.3px;text-transform:uppercase;margin-bottom:9px;
display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.vd{font-size:20px;font-weight:660;line-height:1.3;letter-spacing:-.2px}
.vsub{color:var(--dim);margin-top:7px;font-size:13.5px;max-width:80ch}
.badge{font-size:11px;padding:2px 9px;border-radius:99px;border:1px solid var(--line2);color:var(--dim);
text-transform:none;letter-spacing:0}
.badge.overdue{background:var(--bad);border-color:var(--bad);color:#fff;font-weight:600}
.badge.real{color:var(--warn);border-color:color-mix(in srgb,var(--warn) 50%,transparent)}
.badge.rule_defect{color:var(--violet);border-color:color-mix(in srgb,var(--violet) 50%,transparent)}
.badge.resolved{color:var(--ok);border-color:color-mix(in srgb,var(--ok) 45%,transparent)}
/* ---- visuals ---- */
.viz{margin:18px 0 4px}
.states{display:flex;align-items:center;gap:14px;flex-wrap:wrap}
.sbox{flex:1;min-width:150px;background:var(--panel2);border:1px solid var(--line);border-radius:9px;padding:11px 14px}
.sbox .lbl{font-size:10.5px;text-transform:uppercase;letter-spacing:.5px;color:var(--faint)}
.sbox .val{font-family:var(--mono);font-size:16px;font-weight:600;margin-top:3px}
.sbox.bad{border-color:color-mix(in srgb,var(--bad) 45%,transparent)}
.sbox.bad .val{color:var(--bad)}
.sbox.ok .val{color:var(--ok)}
.link{font-size:20px;color:var(--faint);flex:none}
.link.bad{color:var(--bad)}
.slots{display:flex;gap:4px;margin-top:8px;flex-wrap:wrap}
.slot{flex:1 1 78px;min-width:70px;height:52px;border-radius:7px;border:1px solid var(--line2);
display:flex;flex-direction:column;justify-content:center;padding:5px 7px;overflow:hidden}
.slot .sn{font-size:10.5px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.slot .ss{font-size:9px;text-transform:uppercase;letter-spacing:.4px;opacity:.75;margin-top:1px}
.slot.vm{background:color-mix(in srgb,var(--ok) 22%,transparent);
border-color:color-mix(in srgb,var(--ok) 55%,transparent)}
.slot.vm.bad{background:color-mix(in srgb,var(--bad) 20%,transparent);
border-color:color-mix(in srgb,var(--bad) 55%,transparent)}
.slot.vm.unlinked{background:color-mix(in srgb,var(--bad) 28%,transparent);
border-color:var(--bad)}
.slot.unaccounted{background:color-mix(in srgb,var(--bad) 16%,transparent);
border-color:color-mix(in srgb,var(--bad) 50%,transparent);border-style:dashed;
align-items:center;justify-content:center;color:var(--bad)}
.slot.free{border-style:dashed;border-color:var(--line2);color:var(--faint);
align-items:center;justify-content:center;background:transparent}
.slotnum{font-size:9px;color:var(--faint);margin-bottom:1px}
.gpubar{display:flex;height:34px;border-radius:8px;overflow:hidden;border:1px solid var(--line2);margin-top:6px}
.gseg{display:flex;align-items:center;justify-content:center;font-size:11.5px;font-weight:600;
font-family:var(--mono);color:#fff;min-width:0;overflow:hidden;white-space:nowrap;padding:0 4px}
.gseg.alloc{background:color-mix(in srgb,var(--ok) 78%,#000)}
.gseg.gap{background:color-mix(in srgb,var(--warn) 72%,#000)}
.gseg.gapbad{background:color-mix(in srgb,var(--bad) 72%,#000)}
.glegend{display:flex;gap:16px;margin-top:8px;font-size:12px;color:var(--dim);flex-wrap:wrap}
.glegend i{width:9px;height:9px;border-radius:2px;display:inline-block;margin-right:5px}
.roster{margin-top:8px;border:1px solid var(--line);border-radius:9px;overflow:hidden}
.rrow{display:grid;grid-template-columns:1fr 118px 26px 118px 52px;gap:8px;align-items:center;
padding:7px 12px;border-top:1px solid var(--line);font-size:12.5px}
.rrow:first-child{border-top:none;background:var(--panel2);font-size:10.5px;text-transform:uppercase;
letter-spacing:.5px;color:var(--faint)}
.rrow .rn{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:600}
.rrow .rs{font-family:var(--mono);font-size:12px}
.rrow .eq{text-align:center;font-size:14px;color:var(--ok)}
.rrow.bad .eq{color:var(--bad)} .rrow.bad .rs{color:var(--bad)}
.rrow.unlinked{background:color-mix(in srgb,var(--bad) 9%,transparent)}
.rrow.unlinked .rs.ih{color:var(--bad);font-style:italic}
.rrow .rg{text-align:right;color:var(--faint);font-family:var(--mono);font-size:11.5px}
.claims{display:flex;flex-direction:column;gap:7px;margin-top:6px}
.claim{display:flex;gap:10px;align-items:center;background:var(--panel2);border:1px solid var(--line);
border-radius:8px;padding:8px 12px;font-size:13px}
.claim .cn{font-weight:600;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* ---- actions ---- */
.acts{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);padding:16px 18px;margin-bottom:14px}
.acts h3{margin:0 0 4px;font-size:12px;text-transform:uppercase;letter-spacing:.6px;color:var(--dim)}
.actrow{display:flex;gap:10px;flex-wrap:wrap;margin-top:12px}
.who{display:flex;align-items:center;gap:8px;background:var(--panel2);border:1px solid var(--line);
border-radius:8px;padding:8px 12px;margin-top:11px;font-size:13px;flex-wrap:wrap}
.who .em{font-family:var(--mono);font-size:12.5px}
.hintline{font-size:12px;color:var(--faint);margin-top:9px}
.cmd{display:flex;gap:8px;align-items:center;background:var(--bg);border:1px solid var(--line);
border-radius:7px;padding:7px 10px;margin-top:8px;font-family:var(--mono);font-size:12.5px}
.cmd code{flex:1;min-width:0;overflow-x:auto;white-space:nowrap}
details.fold{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);margin-bottom:10px}
details.fold>summary{cursor:pointer;padding:11px 18px;font-size:12px;text-transform:uppercase;
letter-spacing:.5px;color:var(--dim);user-select:none;list-style:none;display:flex;align-items:center;gap:8px}
details.fold>summary::-webkit-details-marker{display:none}
details.fold>summary::before{content:"";font-size:10px;color:var(--faint)}
details.fold[open]>summary::before{content:""}
details.fold>summary:hover{color:var(--fg)}
.foldb{padding:2px 18px 16px}
table{width:100%;border-collapse:collapse}
td{padding:5px 6px;border-bottom:1px solid var(--line);vertical-align:top;font-size:12.5px}
tr:last-child td{border-bottom:none}
td.k{color:var(--dim);width:180px;white-space:nowrap}
td.v{font-family:var(--mono);word-break:break-word}
.t-ok{color:var(--ok)}.t-warn{color:var(--warn)}.t-bad{color:var(--bad)}
.det{display:block;color:var(--faint);font-family:inherit;font-size:11.5px;margin-top:2px}
ol.steps{margin:0;padding-left:18px;font-size:13px}
ol.steps li{margin-bottom:7px}
ol.steps .ow{font-size:10.5px;padding:1px 6px;border-radius:4px;background:var(--panel2);color:var(--dim);margin-left:5px}
ol.steps .ow.i{color:var(--warn)}
pre{background:var(--bg);border:1px solid var(--line);padding:10px;border-radius:7px;overflow-x:auto;
font-size:11.5px;margin:0}
/* ---- drawer ---- */
#scrim{position:fixed;inset:0;background:rgba(0,0,0,.55);opacity:0;pointer-events:none;transition:.16s;z-index:40}
#scrim.on{opacity:1;pointer-events:auto}
#drawer{position:fixed;top:0;right:0;height:100%;width:min(620px,94vw);background:var(--panel);
border-left:1px solid var(--line);z-index:50;transform:translateX(100%);transition:.18s;
display:flex;flex-direction:column}
#drawer.on{transform:none}
.dh{padding:15px 20px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:10px}
.dh h2{margin:0;font-size:15px;font-weight:650;flex:1}
.db{padding:18px 20px;overflow-y:auto;flex:1}
.df{padding:14px 20px;border-top:1px solid var(--line);display:flex;gap:10px;align-items:center;flex-wrap:wrap}
.fld{margin-bottom:14px}
.fld label{display:block;font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--faint);margin-bottom:5px}
.fld input,.fld textarea,.fld select{width:100%;font:inherit;font-size:13px;padding:8px 10px;
border-radius:7px;border:1px solid var(--line2);background:var(--bg);color:var(--fg)}
.fld textarea{min-height:230px;resize:vertical;line-height:1.55}
.warnbox{border:1px solid color-mix(in srgb,var(--warn) 50%,transparent);
background:color-mix(in srgb,var(--warn) 11%,transparent);border-radius:8px;padding:10px 12px;
font-size:12.5px;margin-bottom:14px}
.empty{color:var(--faint);text-align:center;padding:70px 20px}
.spin{width:15px;height:15px;border:2px solid var(--line2);border-top-color:var(--accent);
border-radius:50%;display:inline-block;animation:sp .7s linear infinite;vertical-align:-3px}
@keyframes sp{to{transform:rotate(360deg)}}
.banner{margin:0 0 12px;padding:10px 13px;border-radius:8px;font-size:12.5px;
border:1px solid color-mix(in srgb,var(--warn) 45%,transparent);
background:color-mix(in srgb,var(--warn) 9%,transparent)}
</style>
</head>
<body>
<header>
<div class="brand">CX Triage <em id="qsum"></em></div>
<a class="navlink" href="/linkage">Linkage scan</a>
<a class="navlink" href="/settings">Settings</a>
<div class="spacer"></div>
<span class="badge" id="conn"></span>
<button id="refresh" class="sm">Refresh</button>
</header>
<div class="filters">
<div class="frow"><span class="flab">Status</span><div class="chips" id="chips"></div></div>
<div class="frow"><span class="flab">Type</span><div class="chips" id="kinds"></div></div>
</div>
<main>
<aside id="rail"><div class="empty"><span class="spin"></span></div></aside>
<section id="stage"><div class="empty">Select a case.</div></section>
</main>
<div id="scrim"></div>
<aside id="drawer">
<div class="dh"><h2 id="dTitle">Action</h2><button class="sm" onclick="closeDrawer()">Close</button></div>
<div class="db" id="dBody"></div>
<div class="df" id="dFoot"></div>
</aside>
<script>
const $=s=>document.querySelector(s);
const esc=s=>String(s==null?"":s).replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
const api=(p,o)=>fetch(p,o).then(r=>r.json());
let S={data:null,sel:null,poll:null,gen:0,shut:{},filter:"actionable",kind:"all",diag:null};
// Prometheus states we never surface: an alert that has not committed yet
// is not work, and showing it invites acting on something that may clear.
const HIDDEN_STATES=["pending"];
const VERDICT_HELP={
overdue:"The condition still holds and has passed the point where the runbook says to contact the customer.",
real:"The condition still holds right now - re-checked against live Infrahub/OpenStack state.",
unverified:"Could not be re-checked, so it is kept in the queue rather than hidden on a guess.",
chronic:"Still true, but it has been true for days - already known rather than new work.",
low_impact:"Still true, but the owner is internal or the VM is platform-owned.",
pending:"Prometheus has not committed to this alert yet; it may still clear on its own.",
resolved:"The condition no longer holds - Infrahub/OpenStack have moved on since it fired.",
rule_defect:"The alert rule itself is wrong, so the alert is not evidence of a problem. Opening the case explains which part of the expression misfires.",
suppressed:"Hidden by a rule you configured in Settings.",
};
const VERDICT_ORDER=["overdue","real","unverified","chronic","low_impact","pending","resolved","rule_defect"];
async function load(force){
const d=await api("/api/alerts"+(force?"?force=1":""));
S.data=d; drawChips(); drawRail();
const ic=d.integrations||{};
$("#conn").innerHTML=`Zendesk ${ic.zendesk?"&#9679; connected":"&#9675; not connected"} &nbsp;|&nbsp; Jira ${ic.jira?"&#9679; connected":"&#9675; not connected"}`;
}
function allCases(){return (S.data?.groups||[]).flatMap(g=>g.alerts);}
function drawChips(){
const all=allCases();
const counts={};
all.forEach(a=>{const v=a.screen?.verdict||"unverified";counts[v]=(counts[v]||0)+1;});
const act=all.filter(a=>a.screen?.actionable&&!HIDDEN_STATES.includes(a.state)).length;
const chips=[`<span class="chip ${S.filter==="actionable"?"on":""}" data-f="actionable">To action <b>${act}</b></span>`];
VERDICT_ORDER.forEach(v=>{ if(!counts[v]) return;
chips.push(`<span class="chip ${v} ${S.filter===v?"on":""}" data-f="${v}">${esc(S.data.summary.labels[v]||v)} <b>${counts[v]}</b></span>`);});
chips.push(`<span class="chip ${S.filter==="all"?"on":""}" data-f="all" title="Everything except alerts Prometheus has not committed to yet">All firing</span>`);
$("#chips").innerHTML=chips.join("");
document.querySelectorAll("#chips .chip").forEach(c=>c.onclick=()=>{S.filter=c.dataset.f;drawChips();drawRail();});
const byKind={};
all.forEach(a=>{byKind[a.kind]=(byKind[a.kind]||0)+1;});
const kinds=[`<span class="chip ${S.kind==="all"?"on":""}" data-k="all">All types</span>`];
(S.data.groups||[]).forEach(g=>{
kinds.push(`<span class="chip ${S.kind===g.kind?"on":""}" data-k="${esc(g.kind)}">${esc(g.title.replace(/^Instance in /,"").replace(/ state$/,""))} <b>${byKind[g.kind]||0}</b></span>`);});
$("#kinds").innerHTML=kinds.join("");
document.querySelectorAll("#kinds .chip").forEach(c=>c.onclick=()=>{S.kind=c.dataset.k;drawChips();drawRail();});
const t=S.data.totals||{};
$("#qsum").textContent=`${act} to action \u00b7 ${t.cx||0} CX alerts \u00b7 ${t.prometheus||0} firing in Prometheus`;
}
function visible(a){
if(S.kind!=="all" && a.kind!==S.kind) return false;
const st=a.state||"firing";
// Pending alerts are only reachable by asking for them by name.
if(HIDDEN_STATES.includes(st) && S.filter!==st) return false;
if(S.filter==="all") return true;
if(S.filter==="actionable") return !!a.screen?.actionable;
return a.screen?.verdict===S.filter;
}
function drawRail(){
const out=[];
for(const g of S.data?.groups||[]){
const rows=(g.alerts||[]).filter(visible);
if(!rows.length) continue;
const hot=rows.filter(a=>a.screen?.actionable).length;
const shut=S.shut[g.kind]===true;
out.push(`<div class="grp ${shut?"shut":""}" data-k="${esc(g.kind)}">
<div class="grp-h"><span class="car">&#9660;</span>${esc(g.title)}
<span class="n ${hot?"hot":""}" title="${rows.length} shown of ${g.total} firing">${
rows.length}${rows.length<g.total?` <span style="opacity:.6">of ${g.total}</span>`:""}</span></div>
<div class="grp-b">${rows.map(row).join("")}</div></div>`);
}
$("#rail").innerHTML=out.length?out.join(""):'<div class="empty">Nothing here.</div>';
document.querySelectorAll(".grp-h").forEach(h=>h.onclick=()=>{
const k=h.parentElement.dataset.k;S.shut[k]=!S.shut[k];drawRail();});
document.querySelectorAll(".case").forEach(c=>c.onclick=()=>open(c.dataset.id));
}
function subjectOf(a){
if(a.kind==="duplicate_ip") return a.floating_ip;
if(["rogue_vm","total_gpus","orphan_vm"].includes(a.kind)) return a.host;
return a.instance_name||a.openstack_id||"unknown";
}
function row(a){
const v=a.screen?.verdict||"unverified";
return `<div class="case ${S.sel===a.id?"on":""} ${a.screen?.actionable?"":"off"}" data-id="${esc(a.id)}">
<span class="bar ${esc(v)}" title="${esc(S.data.summary.labels[v]||v)}"></span>
<span class="mid"><span class="nm">${esc(subjectOf(a))}${a.state==="pending"?'<span class="statepill pending">pending</span>':""}</span>
<span class="sub">${esc([a.org_name,a.region_label||a.region].filter(Boolean).join(" · "))}</span></span>
<span class="ag">${esc(a.effective_age_text||"")}</span></div>`;
}
async function open(id, force){
// Every request gets a generation number. Clicking a second case while the
// first is still polling used to leave the first timer running, and its
// result would later overwrite the stage - collapsing whatever the reader
// had expanded, and sometimes showing the wrong case. Stale generations now
// stop themselves.
const gen = ++S.gen;
S.sel=id; drawRail();
if(S.poll){clearInterval(S.poll);S.poll=null;}
$("#stage").innerHTML='<div class="empty"><span class="spin"></span><br><br>Checking Infrahub, OpenStack and InfraInsight…</div>';
const st=await api("/api/triage",{method:"POST",headers:{"Content-Type":"application/json"},
body:JSON.stringify({alert_id:id,force:!!force})});
if(gen!==S.gen) return;
if(st.error){$("#stage").innerHTML=`<div class="empty t-bad">${esc(st.error)}</div>`;return;}
const timer=setInterval(async()=>{
if(gen!==S.gen){clearInterval(timer);return;}
const j=await api("/api/jobs/"+st.job_id);
if(gen!==S.gen){clearInterval(timer);return;}
if(j.state==="running") return;
clearInterval(timer); if(S.poll===timer) S.poll=null;
if(j.state==="error"){$("#stage").innerHTML=`<pre>${esc(j.error)}</pre>`;return;}
S.diag=j.result; drawCase(j.result);
},900);
S.poll=timer;
}
function rosterHTML(v){
const rows=v.roster||[];
if(!rows.length) return "";
const unlinked=rows.filter(r=>!r.linked).length, mismatched=rows.filter(r=>r.linked&&!r.match).length;
return `<div class="roster">
<div class="rrow"><span>VM on this host</span><span>Infrahub</span><span></span><span>OpenStack</span><span class="rg">GPU</span></div>
${rows.map(r=>`<div class="rrow ${r.linked?(r.match?"":"bad"):"unlinked"}">
<span class="rn">${esc(r.name)}${r.tempest?' <span class="badge">tempest</span>':""}</span>
<span class="rs ih">${esc(r.ih_status)}</span>
<span class="eq">${r.linked?(r.match?"=":"\u2260"):"\u2717"}</span>
<span class="rs">${esc(r.os_status)}</span>
<span class="rg">${esc(r.gpus)}</span></div>`).join("")}
</div>
<div class="glegend"><span>${rows.length} VM(s) on host</span>
<span class="${unlinked?"t-bad":""}">${unlinked} with no Infrahub record</span>
<span class="${mismatched?"t-bad":""}">${mismatched} state mismatch(es)</span></div>`;
}
function vizHTML(d){
const v=d.visual||{};
if(v.type==="states"){
const bad=!v.match;
return `<div class="viz"><div class="states">
<div class="sbox ${bad?"bad":"ok"}"><div class="lbl">Infrahub says</div><div class="val">${esc(v.infrahub)}</div></div>
<div class="link ${bad?"bad":""}">${bad?"&#8800;":"&#61;"}</div>
<div class="sbox ${bad?"bad":"ok"}"><div class="lbl">OpenStack says</div><div class="val">${esc(v.openstack)}</div></div>
</div>
<div class="glegend">
${v.task&&v.task!=="None"?`<span>task state <b>${esc(v.task)}</b></span>`:""}
<span>host <b>${esc(v.never_built?"never placed":v.host)}</b></span>
${v.flavor?`<span>flavor <b>${esc(v.flavor)}</b></span>`:""}
${v.fault&&v.fault!=="None"?`<span class="t-bad">fault present</span>`:""}
</div></div>`;
}
if(v.type==="gpu"){
const slots=v.slots||[];
const named=slots.filter(x=>x.kind==="vm").length;
const un=slots.filter(x=>x.kind==="unaccounted").length;
const free=slots.filter(x=>x.kind==="free").length;
return `<div class="viz">
<div class="slots">${slots.map((x,i)=>{
if(x.kind==="vm") return `<div class="slot vm ${x.linked?(x.match?"":"bad"):"unlinked"}" title="${esc(x.name)} — Infrahub ${esc(x.ih_status)} / OpenStack ${esc(x.os_status)}">
<span class="slotnum">GPU ${i+1}</span>
<span class="sn">${esc(x.name)}</span>
<span class="ss">${x.linked?(x.match?"in sync":"state mismatch"):"not in Infrahub"}</span></div>`;
if(x.kind==="unaccounted") return `<div class="slot unaccounted" title="The host reports this GPU in use, but no instance claims it">
<span class="sn">unaccounted</span></div>`;
return `<div class="slot free" title="Physically present, nothing using it"><span class="sn">free</span></div>`;
}).join("")}</div>
<div class="glegend">
<span>${v.physical!=null?v.physical+" GPU sockets on this host":"GPU count unknown"}</span>
<span><i style="background:color-mix(in srgb,var(--ok) 60%,transparent)"></i>${named} held by ${v.instances} VM(s)</span>
${un?`<span class="t-bad"><i style="background:color-mix(in srgb,var(--bad) 55%,transparent)"></i>${un} in use but unclaimed</span>`:""}
${free?`<span><i style="border:1px dashed var(--line2)"></i>${free} free</span>`:""}
${v.in_use_metric!=null?`<span class="sub">host reports ${v.in_use_metric} in use</span>`:""}
</div>
${rosterHTML(v)}</div>`;
}
if(v.roster && v.type!=="gpu"){ return `<div class="viz">${rosterHTML(v)}</div>`; }
if(v.type==="claimants"){
return `<div class="viz"><div class="claims">${(v.items||[]).map(c=>`
<div class="claim"><span class="cn">${esc(c.name)}</span>
<span class="badge">${esc(c.ih_status)} / ${esc(c.os_status)}</span>
<span style="color:var(--dim);font-size:12px">${esc(c.verdict||"")}</span></div>`).join("")}</div></div>`;
}
return "";
}
function drawCase(d){
const a=d.alert||{}, sc=a.screen||{}, ig=d.integrations||{};
const acts=(ig.actions||[]);
const zd=acts.find(x=>x.kind==="zendesk"), jr=acts.find(x=>x.kind==="jira");
const manual=acts.filter(x=>x.kind==="manual");
const out=[];
out.push(`<div class="hero">
<div class="crumb">
<span class="badge ${esc(sc.verdict||"")}" title="${esc(VERDICT_HELP[sc.verdict]||"")}">${esc(sc.label||"")}</span>
<span>${esc(a.title)}</span><span>&middot;</span><span>${esc(a.region_label||a.region||"")}</span>
<span>&middot;</span><span>held ${esc(a.effective_age_text||"?")}</span>
<span>&middot;</span><span class="${a.state==="pending"?"t-warn":""}">${esc(a.state||"firing")} in prometheus</span>
${a.age_is_reset?`<span title="Prometheus activeAt was reset by a metric-pipeline dip">&middot; prometheus says ${esc(a.age_text)}</span>`:""}
</div>
<div class="vd">${esc(d.verdict||d.error||"No verdict")}</div>
${d.assessment?`<div class="vsub">${esc(d.assessment)}</div>`:""}
${vizHTML(d)}
</div>`);
// The point of the screen: what to do now.
const hasAny = zd||jr||manual.length;
out.push(`<div class="acts">
<h3>Do this</h3>
${sc.actionable?"":`<div class="hintline">This case is screened out (${esc(sc.label)}). ${esc(sc.reason||"")}</div>`}
${zd?`<div class="who">
<span style="color:var(--dim)">Customer</span>
<span class="em">${esc((zd.recipients||[])[0]||"unresolved")}</span>
${a.org_name?`<span class="badge">${esc(a.org_name)}</span>`:""}
</div>`:""}
<div class="actrow">
${zd?`<button class="pri" onclick="openZendesk()">Contact customer via Zendesk</button>`:""}
${jr?`<button onclick="openJira()">Escalate to Infrastructure (Jira)</button>`:""}
${!sc.actionable?`<button class="sm" onclick="open('${esc(a.id)}',true)">Re-run full diagnosis</button>`:""}
${!hasAny?`<span class="hintline">No outbound action for this case — the runbook keeps it internal.</span>`:""}
</div>
${manual.map(m=>`<div class="cmd"><code>${esc(m.payload.command||m.label)}</code>
${m.payload.command?`<button class="sm" onclick="cp(this,'${esc(m.payload.command)}')">Copy</button>`:""}</div>`).join("")}
${manual.length?`<div class="hintline">Run these yourself — CX Triage is read-only and never mutates the platform.</div>`:""}
</div>`);
if((d.notes||[]).length)
out.push(`<details class="fold"><summary>Caveats (${d.notes.length})</summary><div class="foldb">
<ul style="margin:0;padding-left:18px;font-size:13px;color:var(--warn)">
${d.notes.map(n=>`<li>${esc(n)}</li>`).join("")}</ul></div></details>`);
out.push(`<details class="fold"><summary>Why — what the platforms say</summary><div class="foldb"><table>
${(d.findings||[]).map(f=>`<tr><td class="k">${esc(f.label)}</td><td class="v t-${esc(f.tone)}">${esc(f.value)}
${f.detail?`<span class="det">${esc(f.detail)}</span>`:""}</td></tr>`).join("")}
</table></div></details>`);
out.push(`<details class="fold"><summary>Runbook steps (${(d.actions||[]).length})</summary><div class="foldb">
<ol class="steps">${(d.actions||[]).map(x=>`<li>${esc(x.text)}
<span class="ow ${x.owner!=="CX"?"i":""}">${esc(x.owner)}</span>
${x.guide?`<span class="det">Guide: ${esc(x.guide)}</span>`:""}</li>`).join("")}</ol></div></details>`);
out.push(`<details class="fold"><summary>Raw evidence</summary><div class="foldb">
<pre>${esc(JSON.stringify({screen:a.screen,labels:a.labels,evidence:d.evidence},null,2))}</pre></div></details>`);
$("#stage").innerHTML=out.join("");
}
function cp(btn,text){navigator.clipboard.writeText(text).then(()=>{const o=btn.textContent;btn.textContent="Copied";setTimeout(()=>btn.textContent=o,1100);});}
/* ---------- drawers ---------- */
function showDrawer(){$("#scrim").classList.add("on");$("#drawer").classList.add("on");}
function closeDrawer(){$("#scrim").classList.remove("on");$("#drawer").classList.remove("on");}
$("#scrim").onclick=closeDrawer;
function openZendesk(){
const ig=S.diag.integrations||{}, z=(ig.actions||[]).find(x=>x.kind==="zendesk");
if(!z) return;
const t=z.payload.ticket||{};
$("#dTitle").textContent="Contact customer via Zendesk";
$("#dBody").innerHTML=`
${z.enabled?"":`<div class="warnbox"><b>Preview only.</b> ${esc(z.blocked_reason)} Nothing will be sent.</div>`}
${z.payload._when?`<div class="warnbox">When to send: ${esc(z.payload._when)}</div>`:""}
<div class="fld"><label>To</label><input id="zTo" value="${esc((t.requester||{}).email||"")}"></div>
<div class="fld"><label>Subject</label><input id="zSub" value="${esc(t.subject||"")}"></div>
<div class="fld"><label>Message (approved runbook wording — edit if needed)</label>
<textarea id="zBody">${esc((t.comment||{}).body||"")}</textarea></div>
<div class="fld"><label>Priority</label><select id="zPri">
${["low","normal","high","urgent"].map(p=>`<option ${p===t.priority?"selected":""}>${p}</option>`).join("")}
</select></div>
<div class="fld"><label>Tags</label><input id="zTags" value="${esc((t.tags||[]).join(", "))}"></div>`;
$("#dFoot").innerHTML=`
<button class="pri" id="zSend" ${z.enabled?"":"disabled"}>Send to customer</button>
<button onclick="closeDrawer()">Cancel</button>
<span class="hintline" style="margin:0">${z.enabled?"You will be asked to confirm.":"Configure Zendesk to enable sending."}</span>`;
if(z.enabled) $("#zSend").onclick=confirmSend;
showDrawer();
}
function confirmSend(){
const to=$("#zTo").value;
$("#dFoot").innerHTML=`<span style="font-size:13px">Send a public reply to <b>${esc(to)}</b>?</span>
<button class="pri" id="zYes">Yes, send</button><button onclick="openZendesk()">Back</button>`;
$("#zYes").onclick=async()=>{
$("#zYes").disabled=true;$("#zYes").textContent="Sending…";
const r=await api("/api/actions/zendesk",{method:"POST",headers:{"Content-Type":"application/json"},
body:JSON.stringify({alert_id:S.sel,to,subject:$("#zSub").value,body:$("#zBody").value,
priority:$("#zPri").value,tags:$("#zTags").value.split(",").map(s=>s.trim()).filter(Boolean)})});
$("#dFoot").innerHTML=r.ok
? `<span class="t-ok">Sent — ticket #${esc(r.ticket_id)}</span><button onclick="closeDrawer()">Close</button>`
: `<span class="t-bad">${esc(r.error||"Send failed")}</span><button onclick="openZendesk()">Back</button>`;
};
}
function openJira(){
const ig=S.diag.integrations||{}, j=(ig.actions||[]).find(x=>x.kind==="jira");
if(!j) return;
const f=j.payload.fields||{};
$("#dTitle").textContent="Escalate to Infrastructure";
$("#dBody").innerHTML=`
${j.enabled?"":`<div class="warnbox"><b>Preview only.</b> ${esc(j.blocked_reason)}</div>`}
<div class="fld"><label>Project</label><input id="jProj" value="${esc((f.project||{}).key||"")}"></div>
<div class="fld"><label>Summary</label><input id="jSum" value="${esc(f.summary||"")}"></div>
<div class="fld"><label>Description</label><textarea id="jDesc">${esc(f.description||"")}</textarea></div>
<div class="fld"><label>Labels</label><input id="jLab" value="${esc((f.labels||[]).join(", "))}"></div>`;
$("#dFoot").innerHTML=`<button class="pri" ${j.enabled?"":"disabled"}>Create issue</button>
<button onclick="closeDrawer()">Cancel</button>
<span class="hintline" style="margin:0">${j.enabled?"":"Configure Jira to enable."}</span>`;
showDrawer();
}
$("#refresh").onclick=()=>load(true);
load(false);
setInterval(()=>load(false),60000);
</script>
</body>
</html>
"""