feat(mcp): monitoring-config write tools — monitors, alert rules, acks, maintenance
MCP-7 #4971 "Monitoring-config write tools: monitors, alert rules, acks, maintenance". The validation the forms did inline (or not at all) moves to the services, and both the routes and the tools call it, so bad input is refused the same way with the same reason: - monitors/service: monitor_fields, build_config, normalise_target. Port 1-65535, status 100-599, timeout 1-60, method GET/HEAD/POST, headers an object of strings, a key the type does not take is named. The forms now answer 400 with the reason instead of redirecting silently or a 500. - alerts/service: METRIC_CATALOG and SOURCE_MODULES, resources_for, metrics_for, rule_fields (source, resource and metric from the same choices the rule form offers), add_rule, acknowledge, window_fields. Tools: create_monitor, update_monitor (config merged over the stored one; a rename warns about rules still watching the old name), set_monitor_enabled, create_alert_rule, update_alert_rule, acknowledge_alert, create_maintenance_window (duration or end), end_maintenance_window (ends one in progress, cancels an upcoming one) — operator. delete_monitor, delete_alert_rule — admin. get_alert_rule_options — viewer. Every write is audited as "<user> via MCP". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+56
-128
@@ -6,63 +6,32 @@ from sqlalchemy import select
|
||||
from steward.auth.middleware import require_role
|
||||
from steward.alerts import service as alert_svc
|
||||
from steward.ansible import sources as src_module
|
||||
from steward.models.alerts import AlertRule, AlertState, AlertStateEnum, AlertOperator, MaintenanceWindow
|
||||
from steward.models.metrics import PluginMetric
|
||||
from steward.models.alerts import AlertRule, AlertOperator, MaintenanceWindow
|
||||
from steward.models.users import UserRole
|
||||
|
||||
alerts_bp = Blueprint("alerts", __name__, url_prefix="/alerts")
|
||||
|
||||
_OPERATORS = [(op.value, op.value) for op in AlertOperator]
|
||||
|
||||
# Static catalog: source_module → available metric names.
|
||||
# Unified monitors emit metrics under their TYPE (icmp/tcp/dns/http), each with
|
||||
# is_up (1.0 up / 0.0 down) and response_ms (where the probe times it).
|
||||
METRIC_CATALOG: dict[str, list[str]] = {
|
||||
"icmp": ["is_up", "response_ms"],
|
||||
"tcp": ["is_up", "response_ms"],
|
||||
"dns": ["is_up", "response_ms"],
|
||||
"http": ["is_up", "response_ms"],
|
||||
"traefik": ["request_rate", "error_rate", "latency_p50_ms", "latency_p95_ms",
|
||||
"latency_p99_ms", "response_bytes_rate", "cert_expiry_days"],
|
||||
"unifi": ["is_up", "latency_ms", "total_clients"],
|
||||
# restart_count = cumulative restarts (alert on crash-looping); is_healthy =
|
||||
# 1.0 healthy / 0.0 unhealthy from the container HEALTHCHECK (alert on <1).
|
||||
"docker": ["cpu_pct", "mem_pct", "restart_count", "is_healthy"],
|
||||
"host_agent": [
|
||||
"cpu_pct", "mem_used_pct", "mem_available_bytes", "swap_used_bytes",
|
||||
"disk_used_pct_worst", "load_1m", "load_5m", "load_15m", "uptime_secs",
|
||||
],
|
||||
# snmp metrics are user-defined OID labels — populated dynamically from plugin_metrics
|
||||
"snmp": [],
|
||||
}
|
||||
|
||||
# Ordered list for source module picker
|
||||
_SOURCE_MODULES = list(METRIC_CATALOG.keys())
|
||||
def _rule_form(form) -> dict:
|
||||
"""The rule form's fields, as alert_svc.rule_fields takes them."""
|
||||
return {
|
||||
"name": form.get("name", ""),
|
||||
"source_module": form.get("source_module", ""),
|
||||
"resource_name": form.get("resource_name", ""),
|
||||
"metric_name": form.get("metric_name", ""),
|
||||
"operator": form.get("operator", ""),
|
||||
"threshold": form.get("threshold"),
|
||||
"consecutive_failures_required": form.get("consecutive_failures_required"),
|
||||
}
|
||||
|
||||
|
||||
async def _get_resources(db, source_module: str) -> list[str]:
|
||||
"""Return sorted distinct resource names for a source module.
|
||||
|
||||
For monitor types (icmp/tcp/dns/http), supplements with monitor names so
|
||||
the list is populated even before any metrics have been recorded.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(PluginMetric.resource_name)
|
||||
.where(PluginMetric.source_module == source_module)
|
||||
.distinct()
|
||||
.order_by(PluginMetric.resource_name)
|
||||
)
|
||||
resources: set[str] = {r for (r,) in result}
|
||||
|
||||
if source_module in ("icmp", "tcp", "dns", "http"):
|
||||
from steward.models.monitors import Monitor
|
||||
mon_result = await db.execute(
|
||||
select(Monitor.name).where(Monitor.type == source_module).order_by(Monitor.name)
|
||||
)
|
||||
for (name,) in mon_result:
|
||||
resources.add(name)
|
||||
|
||||
return sorted(resources)
|
||||
def _form_time(value) -> datetime:
|
||||
"""A datetime-local field, read as UTC."""
|
||||
try:
|
||||
return datetime.fromisoformat((value or "").strip()).replace(tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
raise ValueError(f"{value!r} is not a date and time.") from None
|
||||
|
||||
|
||||
def _is_admin() -> bool:
|
||||
@@ -137,18 +106,9 @@ async def rule_fields_api():
|
||||
current_resource = request.args.get("current_resource", "")
|
||||
current_metric = request.args.get("current_metric", "")
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
resources = await _get_resources(db, source) if source else []
|
||||
metrics = METRIC_CATALOG.get(source, [])
|
||||
# Dynamic sources (e.g. snmp) have empty static catalog — query live metric names
|
||||
if source and not metrics:
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
m_result = await db.execute(
|
||||
select(PluginMetric.metric_name)
|
||||
.where(PluginMetric.source_module == source)
|
||||
.distinct()
|
||||
.order_by(PluginMetric.metric_name)
|
||||
)
|
||||
metrics = [r for (r,) in m_result]
|
||||
resources = await alert_svc.resources_for(db, source) if source else []
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
metrics = await alert_svc.metrics_for(db, source)
|
||||
return await render_template(
|
||||
"alerts/_rule_fields.html",
|
||||
resources=resources,
|
||||
@@ -161,17 +121,17 @@ async def rule_fields_api():
|
||||
@alerts_bp.get("/rules/new")
|
||||
@require_role(UserRole.operator)
|
||||
async def new_rule():
|
||||
first_source = _SOURCE_MODULES[0]
|
||||
first_source = alert_svc.SOURCE_MODULES[0]
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
resources = await _get_resources(db, first_source)
|
||||
resources = await alert_svc.resources_for(db, first_source)
|
||||
return await render_template(
|
||||
"alerts/rules_form.html",
|
||||
rule=None,
|
||||
operators=_OPERATORS,
|
||||
source_modules=_SOURCE_MODULES,
|
||||
source_modules=alert_svc.SOURCE_MODULES,
|
||||
initial_source=first_source,
|
||||
resources=resources,
|
||||
metrics=METRIC_CATALOG.get(first_source, []),
|
||||
metrics=alert_svc.METRIC_CATALOG.get(first_source, []),
|
||||
ansible_sources=_ansible_source_names(),
|
||||
)
|
||||
|
||||
@@ -184,15 +144,15 @@ async def edit_rule(rule_id: str):
|
||||
rule = result.scalar_one_or_none()
|
||||
if rule is None:
|
||||
return "Not found", 404
|
||||
resources = await _get_resources(db, rule.source_module)
|
||||
resources = await alert_svc.resources_for(db, rule.source_module)
|
||||
return await render_template(
|
||||
"alerts/rules_form.html",
|
||||
rule=rule,
|
||||
operators=_OPERATORS,
|
||||
source_modules=_SOURCE_MODULES,
|
||||
source_modules=alert_svc.SOURCE_MODULES,
|
||||
initial_source=rule.source_module,
|
||||
resources=resources,
|
||||
metrics=METRIC_CATALOG.get(rule.source_module, []),
|
||||
metrics=alert_svc.METRIC_CATALOG.get(rule.source_module, []),
|
||||
ansible_sources=_ansible_source_names(),
|
||||
)
|
||||
|
||||
@@ -202,36 +162,20 @@ async def edit_rule(rule_id: str):
|
||||
async def create_rule():
|
||||
form = await request.form
|
||||
user_id = session.get("user_id")
|
||||
now = datetime.now(timezone.utc)
|
||||
# Ansible action is admin-only; non-admins simply can't set one.
|
||||
ansible_action = None
|
||||
if _is_admin():
|
||||
ansible_action, err = _parse_ansible_action(form)
|
||||
if err:
|
||||
return err, 400
|
||||
rule = AlertRule(
|
||||
name=form["name"].strip(),
|
||||
source_module=form["source_module"].strip(),
|
||||
resource_name=form["resource_name"].strip(),
|
||||
metric_name=form["metric_name"].strip(),
|
||||
operator=AlertOperator(form["operator"]),
|
||||
threshold=float(form["threshold"]),
|
||||
consecutive_failures_required=int(form.get("consecutive_failures_required") or 1),
|
||||
enabled=True,
|
||||
ansible_action=ansible_action,
|
||||
created_by=user_id,
|
||||
)
|
||||
state = AlertState(
|
||||
rule_id=rule.id,
|
||||
state=AlertStateEnum.inactive,
|
||||
last_transition_at=now,
|
||||
)
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
db.add(rule)
|
||||
await db.flush()
|
||||
state.rule_id = rule.id
|
||||
db.add(state)
|
||||
try:
|
||||
fields = await alert_svc.rule_fields(db, **_rule_form(form))
|
||||
except ValueError as exc:
|
||||
return str(exc), 400
|
||||
rule = await alert_svc.add_rule(db, fields, created_by=user_id,
|
||||
ansible_action=ansible_action)
|
||||
await log_audit(current_app, user_id, session.get("username", ""), "alert_rule.created",
|
||||
entity_type="alert_rule", entity_id=rule.name,
|
||||
detail={"source": rule.source_module, "resource": rule.resource_name,
|
||||
@@ -256,13 +200,12 @@ async def update_rule(rule_id: str):
|
||||
rule = result.scalar_one_or_none()
|
||||
if rule is None:
|
||||
return "Not found", 404
|
||||
rule.name = form["name"].strip()
|
||||
rule.source_module = form["source_module"].strip()
|
||||
rule.resource_name = form["resource_name"].strip()
|
||||
rule.metric_name = form["metric_name"].strip()
|
||||
rule.operator = AlertOperator(form["operator"])
|
||||
rule.threshold = float(form["threshold"])
|
||||
rule.consecutive_failures_required = int(form.get("consecutive_failures_required") or 1)
|
||||
try:
|
||||
fields = await alert_svc.rule_fields(db, **_rule_form(form))
|
||||
except ValueError as exc:
|
||||
return str(exc), 400
|
||||
for k, v in fields.items():
|
||||
setattr(rule, k, v)
|
||||
if apply_action:
|
||||
rule.ansible_action = ansible_action
|
||||
await log_audit(current_app, session.get("user_id"), session.get("username", ""),
|
||||
@@ -313,7 +256,7 @@ async def list_maintenance():
|
||||
"alerts/maintenance.html",
|
||||
windows=windows,
|
||||
now=now,
|
||||
source_modules=_SOURCE_MODULES,
|
||||
source_modules=alert_svc.SOURCE_MODULES,
|
||||
)
|
||||
|
||||
|
||||
@@ -323,7 +266,7 @@ async def new_maintenance():
|
||||
return await render_template(
|
||||
"alerts/maintenance_form.html",
|
||||
window=None,
|
||||
source_modules=_SOURCE_MODULES,
|
||||
source_modules=alert_svc.SOURCE_MODULES,
|
||||
)
|
||||
|
||||
|
||||
@@ -332,26 +275,18 @@ async def new_maintenance():
|
||||
async def create_maintenance():
|
||||
form = await request.form
|
||||
user_id = session.get("user_id")
|
||||
start_at = datetime.fromisoformat(form["start_at"]).replace(tzinfo=timezone.utc)
|
||||
end_at = datetime.fromisoformat(form["end_at"]).replace(tzinfo=timezone.utc)
|
||||
scope_source = form.get("scope_source", "").strip() or None
|
||||
scope_resource = form.get("scope_resource", "").strip() or None
|
||||
if scope_source is None:
|
||||
scope_resource = None # can't have resource without source
|
||||
window = MaintenanceWindow(
|
||||
name=form["name"].strip(),
|
||||
start_at=start_at,
|
||||
end_at=end_at,
|
||||
scope_source=scope_source,
|
||||
scope_resource=scope_resource,
|
||||
created_by=user_id,
|
||||
)
|
||||
try:
|
||||
fields = alert_svc.window_fields(
|
||||
name=form.get("name", ""),
|
||||
start_at=_form_time(form.get("start_at")), end_at=_form_time(form.get("end_at")),
|
||||
scope_source=form.get("scope_source"), scope_resource=form.get("scope_resource"))
|
||||
except ValueError as exc:
|
||||
return str(exc), 400
|
||||
window = MaintenanceWindow(created_by=user_id, **fields)
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
db.add(window)
|
||||
scope_desc = "all" if not scope_source else (
|
||||
f"{scope_source}/{scope_resource}" if scope_resource else f"{scope_source}/*"
|
||||
)
|
||||
scope_desc = alert_svc.window_scope(window)
|
||||
await log_audit(current_app, user_id, session.get("username", ""),
|
||||
"maintenance_window.created", entity_type="maintenance_window",
|
||||
entity_id=window.name, detail={"scope": scope_desc})
|
||||
@@ -382,19 +317,12 @@ async def delete_maintenance(window_id: str):
|
||||
@require_role(UserRole.operator)
|
||||
async def acknowledge(rule_id: str):
|
||||
user_id = session.get("user_id")
|
||||
now = datetime.now(timezone.utc)
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
result = await db.execute(
|
||||
select(AlertState).where(AlertState.rule_id == rule_id)
|
||||
)
|
||||
state = result.scalar_one_or_none()
|
||||
if state is None:
|
||||
try:
|
||||
await alert_svc.acknowledge(db, rule_id, user_id)
|
||||
except LookupError:
|
||||
return "Not found", 404
|
||||
if state.state != AlertStateEnum.firing:
|
||||
return "Conflict: alert is not in FIRING state", 409
|
||||
state.state = AlertStateEnum.acknowledged
|
||||
state.acknowledged_by = user_id
|
||||
state.acknowledged_at = now
|
||||
state.last_transition_at = now
|
||||
except ValueError as exc:
|
||||
return f"Conflict: {exc}", 409
|
||||
return redirect(url_for("alerts.list_alerts"))
|
||||
|
||||
+177
-5
@@ -1,14 +1,45 @@
|
||||
"""Alert queries shared by the alerts pages and the MCP tools.
|
||||
"""Alert queries and validation shared by the alerts pages and the MCP tools.
|
||||
|
||||
Each function takes an open session and returns rows, with no Quart request
|
||||
context, so the routes and the /mcp tools (which run outside Quart's pipeline)
|
||||
call the same code.
|
||||
Each function takes an open session (or plain values), with no Quart request
|
||||
context, so the routes and the /mcp tools (which run outside Quart's
|
||||
pipeline) call the same code and refuse the same input for the same reasons.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from steward.models.alerts import AlertRule, AlertState, AlertStateEnum, MaintenanceWindow
|
||||
from steward.models.alerts import (
|
||||
AlertOperator, AlertRule, AlertState, AlertStateEnum, MaintenanceWindow,
|
||||
)
|
||||
from steward.models.metrics import PluginMetric
|
||||
from steward.models.monitors import MONITOR_TYPE_VALUES, Monitor
|
||||
|
||||
# Static catalog: source_module → available metric names.
|
||||
# Unified monitors emit metrics under their TYPE (icmp/tcp/dns/http), each with
|
||||
# is_up (1.0 up / 0.0 down) and response_ms (where the probe times it).
|
||||
METRIC_CATALOG: dict[str, list[str]] = {
|
||||
"icmp": ["is_up", "response_ms"],
|
||||
"tcp": ["is_up", "response_ms"],
|
||||
"dns": ["is_up", "response_ms"],
|
||||
"http": ["is_up", "response_ms"],
|
||||
"traefik": ["request_rate", "error_rate", "latency_p50_ms", "latency_p95_ms",
|
||||
"latency_p99_ms", "response_bytes_rate", "cert_expiry_days"],
|
||||
"unifi": ["is_up", "latency_ms", "total_clients"],
|
||||
# restart_count = cumulative restarts (alert on crash-looping); is_healthy =
|
||||
# 1.0 healthy / 0.0 unhealthy from the container HEALTHCHECK (alert on <1).
|
||||
"docker": ["cpu_pct", "mem_pct", "restart_count", "is_healthy"],
|
||||
"host_agent": [
|
||||
"cpu_pct", "mem_used_pct", "mem_available_bytes", "swap_used_bytes",
|
||||
"disk_used_pct_worst", "load_1m", "load_5m", "load_15m", "uptime_secs",
|
||||
],
|
||||
# snmp metrics are user-defined OID labels — populated dynamically from plugin_metrics
|
||||
"snmp": [],
|
||||
}
|
||||
|
||||
# Ordered list for the source module picker.
|
||||
SOURCE_MODULES = list(METRIC_CATALOG.keys())
|
||||
|
||||
# States that need someone's attention: pending (breaching, not yet for long
|
||||
# enough), firing, and acknowledged-but-still-breaching.
|
||||
@@ -34,3 +65,144 @@ async def list_maintenance_windows(db) -> list[MaintenanceWindow]:
|
||||
return list((await db.execute(
|
||||
select(MaintenanceWindow).order_by(MaintenanceWindow.start_at.desc())
|
||||
)).scalars())
|
||||
|
||||
|
||||
async def resources_for(db, source_module: str) -> list[str]:
|
||||
"""Sorted distinct resource names for a source module.
|
||||
|
||||
For monitor types (icmp/tcp/dns/http), supplements with monitor names so
|
||||
the list is populated even before any metrics have been recorded.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(PluginMetric.resource_name)
|
||||
.where(PluginMetric.source_module == source_module)
|
||||
.distinct()
|
||||
.order_by(PluginMetric.resource_name)
|
||||
)
|
||||
resources: set[str] = {r for (r,) in result}
|
||||
if source_module in MONITOR_TYPE_VALUES:
|
||||
mon_result = await db.execute(
|
||||
select(Monitor.name).where(Monitor.type == source_module).order_by(Monitor.name))
|
||||
resources.update(name for (name,) in mon_result)
|
||||
return sorted(resources)
|
||||
|
||||
|
||||
async def metrics_for(db, source_module: str) -> list[str]:
|
||||
"""The metric names a rule on this source can watch: the static catalog, or
|
||||
for dynamic sources (snmp) the names recorded so far."""
|
||||
metrics = METRIC_CATALOG.get(source_module, [])
|
||||
if source_module and not metrics:
|
||||
result = await db.execute(
|
||||
select(PluginMetric.metric_name)
|
||||
.where(PluginMetric.source_module == source_module)
|
||||
.distinct()
|
||||
.order_by(PluginMetric.metric_name))
|
||||
metrics = [r for (r,) in result]
|
||||
return metrics
|
||||
|
||||
|
||||
def _choices(values: list[str], limit: int = 20) -> str:
|
||||
if not values:
|
||||
return "none yet"
|
||||
shown = ", ".join(values[:limit])
|
||||
return shown + (f" (and {len(values) - limit} more)" if len(values) > limit else "")
|
||||
|
||||
|
||||
async def rule_fields(db, *, name: str, source_module: str, resource_name: str, metric_name: str,
|
||||
operator: str, threshold, consecutive_failures_required=1) -> dict:
|
||||
"""Validated column values for a new or edited alert rule.
|
||||
|
||||
The same choices the rule form offers: a known source, a resource that
|
||||
source has reported (or, for monitors, a monitor of that type), and a
|
||||
metric it emits. ValueError says what was wrong and what is valid.
|
||||
"""
|
||||
name = (name or "").strip()
|
||||
if not name:
|
||||
raise ValueError("an alert rule needs a name.")
|
||||
source_module = (source_module or "").strip()
|
||||
if source_module not in METRIC_CATALOG:
|
||||
raise ValueError(f"source_module {source_module!r} is not one of {', '.join(SOURCE_MODULES)}.")
|
||||
resource_name = (resource_name or "").strip()
|
||||
resources = await resources_for(db, source_module)
|
||||
if resource_name not in resources:
|
||||
raise ValueError(f"{source_module} has no resource {resource_name!r}. "
|
||||
f"Known: {_choices(resources)}.")
|
||||
metric_name = (metric_name or "").strip()
|
||||
metrics = await metrics_for(db, source_module)
|
||||
if metric_name not in metrics:
|
||||
raise ValueError(f"{source_module} does not report {metric_name!r}. "
|
||||
f"Its metrics: {_choices(metrics)}.")
|
||||
try:
|
||||
op = AlertOperator((operator or "").strip())
|
||||
except ValueError:
|
||||
raise ValueError(f"operator {operator!r} is not one of "
|
||||
f"{', '.join(o.value for o in AlertOperator)}.") from None
|
||||
try:
|
||||
threshold = float(threshold)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError(f"threshold must be a number, got {threshold!r}.") from None
|
||||
try:
|
||||
consecutive = (1 if consecutive_failures_required in (None, "")
|
||||
else int(consecutive_failures_required))
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError("consecutive checks must be a whole number.") from None
|
||||
if consecutive < 1:
|
||||
raise ValueError("consecutive checks must be at least 1.")
|
||||
return {"name": name[:128], "source_module": source_module, "resource_name": resource_name,
|
||||
"metric_name": metric_name, "operator": op, "threshold": threshold,
|
||||
"consecutive_failures_required": consecutive}
|
||||
|
||||
|
||||
async def add_rule(db, fields: dict, *, created_by: str, enabled: bool = True,
|
||||
ansible_action: dict | None = None) -> AlertRule:
|
||||
"""Insert a rule and its inactive state row, in the caller's transaction."""
|
||||
rule = AlertRule(enabled=enabled, ansible_action=ansible_action, created_by=created_by, **fields)
|
||||
db.add(rule)
|
||||
await db.flush()
|
||||
db.add(AlertState(rule_id=rule.id, state=AlertStateEnum.inactive,
|
||||
last_transition_at=datetime.now(timezone.utc)))
|
||||
return rule
|
||||
|
||||
|
||||
async def acknowledge(db, rule_id: str, user_id: str) -> AlertState:
|
||||
"""Acknowledge a firing alert, in the caller's transaction.
|
||||
|
||||
LookupError when the rule has no state; ValueError when it is not firing.
|
||||
"""
|
||||
state = (await db.execute(
|
||||
select(AlertState).where(AlertState.rule_id == rule_id))).scalar_one_or_none()
|
||||
if state is None:
|
||||
raise LookupError(rule_id)
|
||||
if state.state != AlertStateEnum.firing:
|
||||
raise ValueError(f"the alert is {state.state.value}, not firing; only a firing alert "
|
||||
"can be acknowledged.")
|
||||
now = datetime.now(timezone.utc)
|
||||
state.state = AlertStateEnum.acknowledged
|
||||
state.acknowledged_by = user_id
|
||||
state.acknowledged_at = now
|
||||
state.last_transition_at = now
|
||||
return state
|
||||
|
||||
|
||||
def window_fields(*, name: str, start_at: datetime, end_at: datetime,
|
||||
scope_source: str | None, scope_resource: str | None) -> dict:
|
||||
"""Validated columns for a maintenance window. A resource needs a source."""
|
||||
name = (name or "").strip()
|
||||
if not name:
|
||||
raise ValueError("a maintenance window needs a name.")
|
||||
if end_at <= start_at:
|
||||
raise ValueError("the window must end after it starts.")
|
||||
scope_source = (scope_source or "").strip() or None
|
||||
scope_resource = (scope_resource or "").strip() or None
|
||||
if scope_source is not None and scope_source not in METRIC_CATALOG:
|
||||
raise ValueError(f"source {scope_source!r} is not one of {', '.join(SOURCE_MODULES)}.")
|
||||
if scope_source is None:
|
||||
scope_resource = None # can't have resource without source
|
||||
return {"name": name[:128], "start_at": start_at, "end_at": end_at,
|
||||
"scope_source": scope_source, "scope_resource": scope_resource}
|
||||
|
||||
|
||||
def window_scope(w) -> str:
|
||||
if not w.scope_source:
|
||||
return "all"
|
||||
return f"{w.scope_source}/{w.scope_resource}" if w.scope_resource else f"{w.scope_source}/*"
|
||||
|
||||
+212
-5
@@ -1,12 +1,16 @@
|
||||
"""Alert tools: what is firing, what fired before, the rules, and maintenance windows."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from steward.alerts import service as alert_svc
|
||||
from steward.models.alerts import AlertEvent, AlertRule, AlertState
|
||||
from steward.mcp._context import current_caller
|
||||
from steward.models.alerts import AlertEvent, AlertOperator, AlertRule, AlertState, MaintenanceWindow
|
||||
from steward.models.users import User, UserRole
|
||||
|
||||
from ._audit import audit_mcp
|
||||
from ._shape import (
|
||||
age_s, bounded, clamp_limit, db_session, iso, lookback, now_utc, pick, rnd,
|
||||
)
|
||||
@@ -184,8 +188,211 @@ async def list_maintenance_windows(include_past: bool = False, limit: int = 50)
|
||||
return bounded(rows, limit, key="windows")
|
||||
|
||||
|
||||
# ── changes ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async def get_alert_rule_options(source_module: str = "") -> dict:
|
||||
"""What an alert rule can watch: the choices create_alert_rule accepts.
|
||||
|
||||
source_module: empty lists every source and its metrics; a source's name
|
||||
also lists its resources (hosts, containers, monitors… whatever that
|
||||
source reports under).
|
||||
|
||||
A monitor reports under its type (icmp/tcp/dns/http) with its name as the
|
||||
resource: is_up (1 up, 0 down) and response_ms.
|
||||
"""
|
||||
async with db_session() as db:
|
||||
if not source_module:
|
||||
return {"sources": {src: await alert_svc.metrics_for(db, src)
|
||||
for src in alert_svc.SOURCE_MODULES},
|
||||
"operators": [o.value for o in AlertOperator]}
|
||||
if source_module not in alert_svc.METRIC_CATALOG:
|
||||
raise ValueError(f"source_module {source_module!r} is not one of "
|
||||
f"{', '.join(alert_svc.SOURCE_MODULES)}.")
|
||||
return {"source_module": source_module,
|
||||
"metrics": await alert_svc.metrics_for(db, source_module),
|
||||
"resources": await alert_svc.resources_for(db, source_module),
|
||||
"operators": [o.value for o in AlertOperator]}
|
||||
|
||||
|
||||
async def _rule(db, rule: str) -> AlertRule:
|
||||
return pick(rule, await alert_svc.list_rules(db), kind="alert rule", list_tool="list_alert_rules")
|
||||
|
||||
|
||||
def _rule_row(r: AlertRule) -> dict:
|
||||
return {"rule_id": r.id, "rule_name": r.name, "enabled": r.enabled,
|
||||
"source_module": r.source_module, "resource": r.resource_name,
|
||||
"metric": r.metric_name, "condition": _condition(r), "ansible_action": _action(r)}
|
||||
|
||||
|
||||
async def create_alert_rule(name: str, source_module: str, resource: str, metric: str,
|
||||
operator: str, threshold: float, consecutive_checks: int = 1,
|
||||
enabled: bool = True) -> dict:
|
||||
"""Add an alert rule: notify when `metric` of `resource` crosses `threshold`.
|
||||
|
||||
source_module, resource, metric: get_alert_rule_options lists the valid
|
||||
choices; a resource must have reported (or, for a monitor, exist).
|
||||
operator: ">", "<", ">=", "<=", "==" or "!=".
|
||||
consecutive_checks: how many checks in a row must breach before it fires
|
||||
(default 1); higher values ride out a single blip.
|
||||
|
||||
Notifications go to the channels set in Steward's Settings. The playbook a
|
||||
rule runs when it fires is set separately (set_alert_action, admin).
|
||||
Recorded in the audit log as "<you> via MCP".
|
||||
"""
|
||||
caller = current_caller()
|
||||
async with db_session() as db, db.begin():
|
||||
fields = await alert_svc.rule_fields(
|
||||
db, name=name, source_module=source_module, resource_name=resource,
|
||||
metric_name=metric, operator=operator, threshold=threshold,
|
||||
consecutive_failures_required=consecutive_checks)
|
||||
r = await alert_svc.add_rule(db, fields, created_by=caller.user_id, enabled=enabled)
|
||||
await audit_mcp("alert_rule.created", "alert_rule", r.name,
|
||||
{"rule_id": r.id, "source": r.source_module, "resource": r.resource_name,
|
||||
"metric": r.metric_name})
|
||||
return _rule_row(r)
|
||||
|
||||
|
||||
async def update_alert_rule(rule: str, name: str | None = None, source_module: str | None = None,
|
||||
resource: str | None = None, metric: str | None = None,
|
||||
operator: str | None = None, threshold: float | None = None,
|
||||
consecutive_checks: int | None = None,
|
||||
enabled: bool | None = None) -> dict:
|
||||
"""Change an alert rule. Only the arguments you pass change; the rest stay.
|
||||
|
||||
rule: its name or id (list_alert_rules). Pass enabled=false to silence it
|
||||
without deleting it. The changed rule is validated as create_alert_rule
|
||||
validates a new one. Recorded in the audit log.
|
||||
"""
|
||||
async with db_session() as db, db.begin():
|
||||
r = await _rule(db, rule)
|
||||
fields = await alert_svc.rule_fields(
|
||||
db,
|
||||
name=r.name if name is None else name,
|
||||
source_module=r.source_module if source_module is None else source_module,
|
||||
resource_name=r.resource_name if resource is None else resource,
|
||||
metric_name=r.metric_name if metric is None else metric,
|
||||
operator=r.operator.value if operator is None else operator,
|
||||
threshold=r.threshold if threshold is None else threshold,
|
||||
consecutive_failures_required=(r.consecutive_failures_required
|
||||
if consecutive_checks is None else consecutive_checks))
|
||||
for k, v in fields.items():
|
||||
setattr(r, k, v)
|
||||
if enabled is not None:
|
||||
r.enabled = bool(enabled)
|
||||
changed = sorted(k for k, v in {"name": name, "source_module": source_module,
|
||||
"resource": resource, "metric": metric, "operator": operator,
|
||||
"threshold": threshold, "consecutive_checks": consecutive_checks,
|
||||
"enabled": enabled}.items() if v is not None)
|
||||
await audit_mcp("alert_rule.updated", "alert_rule", r.name, {"rule_id": r.id, "changed": changed})
|
||||
return _rule_row(r)
|
||||
|
||||
|
||||
async def delete_alert_rule(rule: str) -> dict:
|
||||
"""Delete an alert rule and its history. To silence it but keep it,
|
||||
update_alert_rule(enabled=false). Admin only; recorded in the audit log."""
|
||||
async with db_session() as db, db.begin():
|
||||
r = await _rule(db, rule)
|
||||
await db.delete(r)
|
||||
await audit_mcp("alert_rule.deleted", "alert_rule", r.name, {"rule_id": r.id})
|
||||
return {"deleted": r.name, "rule_id": r.id}
|
||||
|
||||
|
||||
async def acknowledge_alert(rule: str) -> dict:
|
||||
"""Acknowledge a firing alert: you have seen it. It stays "acknowledged"
|
||||
while the condition holds and resolves when it clears.
|
||||
|
||||
rule: the firing rule's name or id (list_alerts). Recorded in the audit log.
|
||||
"""
|
||||
caller = current_caller()
|
||||
async with db_session() as db, db.begin():
|
||||
r = await _rule(db, rule)
|
||||
try:
|
||||
state = await alert_svc.acknowledge(db, r.id, caller.user_id)
|
||||
except LookupError:
|
||||
raise ValueError(f"{r.name} has never been evaluated, so there is nothing to "
|
||||
"acknowledge.") from None
|
||||
await audit_mcp("alert_rule.acknowledged", "alert_rule", r.name, {"rule_id": r.id})
|
||||
return {"rule_id": r.id, "rule_name": r.name, "state": state.state.value,
|
||||
"acknowledged_at": iso(state.acknowledged_at)}
|
||||
|
||||
|
||||
def _when(value: str, what: str) -> datetime:
|
||||
try:
|
||||
dt = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
raise ValueError(f"{what} {value!r} is not an ISO-8601 time, e.g. 2026-10-06T22:00:00Z.") from None
|
||||
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _window_row(w: MaintenanceWindow, now: datetime) -> dict:
|
||||
return {"window_id": w.id, "name": w.name, "start_at": iso(w.start_at), "end_at": iso(w.end_at),
|
||||
"active": w.start_at <= now <= w.end_at,
|
||||
"scope": {"source_module": w.scope_source, "resource": w.scope_resource}}
|
||||
|
||||
|
||||
async def create_maintenance_window(name: str, duration_minutes: int = 0, end_at: str = "",
|
||||
start_at: str = "", source_module: str = "",
|
||||
resource: str = "") -> dict:
|
||||
"""Hold back alert notifications while you work on something.
|
||||
|
||||
Give the end as duration_minutes OR end_at (ISO-8601; a time with no zone
|
||||
is UTC). start_at defaults to now.
|
||||
source_module, resource: limit the window to one source, or to one
|
||||
resource of it; both empty silences everything.
|
||||
|
||||
Alerts still change state during a window, so list_alerts shows them;
|
||||
only notifications are held. Recorded in the audit log.
|
||||
"""
|
||||
now = now_utc()
|
||||
start = _when(start_at, "start_at") if (start_at or "").strip() else now
|
||||
if bool(duration_minutes) == bool((end_at or "").strip()):
|
||||
raise ValueError("give exactly one of duration_minutes or end_at.")
|
||||
if duration_minutes:
|
||||
if duration_minutes < 0:
|
||||
raise ValueError("duration_minutes must be positive.")
|
||||
end = start + timedelta(minutes=duration_minutes)
|
||||
else:
|
||||
end = _when(end_at, "end_at")
|
||||
fields = alert_svc.window_fields(name=name, start_at=start, end_at=end,
|
||||
scope_source=source_module, scope_resource=resource)
|
||||
w = MaintenanceWindow(created_by=current_caller().user_id, **fields)
|
||||
async with db_session() as db, db.begin():
|
||||
db.add(w)
|
||||
await audit_mcp("maintenance_window.created", "maintenance_window", w.name,
|
||||
{"window_id": w.id, "scope": alert_svc.window_scope(w),
|
||||
"start_at": iso(w.start_at), "end_at": iso(w.end_at)})
|
||||
return _window_row(w, now)
|
||||
|
||||
|
||||
async def end_maintenance_window(window: str) -> dict:
|
||||
"""End a maintenance window now, so notifications resume.
|
||||
|
||||
window: its name or id (list_maintenance_windows). A window in progress
|
||||
ends now and stays on record; one that has not started yet is removed.
|
||||
Recorded in the audit log.
|
||||
"""
|
||||
now = now_utc()
|
||||
async with db_session() as db, db.begin():
|
||||
windows = await alert_svc.list_maintenance_windows(db)
|
||||
w = pick(window, windows, kind="maintenance window", list_tool="list_maintenance_windows")
|
||||
if w.end_at <= now:
|
||||
raise ValueError(f"{w.name} already ended at {iso(w.end_at)}.")
|
||||
if w.start_at > now:
|
||||
await db.delete(w)
|
||||
outcome = "cancelled"
|
||||
else:
|
||||
w.end_at = now
|
||||
outcome = "ended"
|
||||
await audit_mcp(f"maintenance_window.{outcome}", "maintenance_window", w.name,
|
||||
{"window_id": w.id})
|
||||
return {**_window_row(w, now), "outcome": outcome}
|
||||
|
||||
|
||||
def register(register_tool) -> None:
|
||||
register_tool(list_alerts, role=UserRole.viewer, read_only=True)
|
||||
register_tool(get_alert_history, role=UserRole.viewer, read_only=True)
|
||||
register_tool(list_alert_rules, role=UserRole.viewer, read_only=True)
|
||||
register_tool(list_maintenance_windows, role=UserRole.viewer, read_only=True)
|
||||
for fn in (list_alerts, get_alert_history, list_alert_rules, list_maintenance_windows,
|
||||
get_alert_rule_options):
|
||||
register_tool(fn, role=UserRole.viewer, read_only=True)
|
||||
for fn in (create_alert_rule, update_alert_rule, acknowledge_alert,
|
||||
create_maintenance_window, end_maintenance_window):
|
||||
register_tool(fn, role=UserRole.operator, read_only=False)
|
||||
register_tool(delete_alert_rule, role=UserRole.admin, read_only=False)
|
||||
|
||||
@@ -5,11 +5,13 @@ import json
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from steward.models.alerts import AlertRule
|
||||
from steward.models.hosts import Host
|
||||
from steward.models.monitors import MONITOR_TYPE_VALUES, Monitor
|
||||
from steward.models.users import UserRole
|
||||
from steward.monitors import service as monitor_svc
|
||||
|
||||
from ._audit import audit_mcp
|
||||
from ._shape import (
|
||||
age_s, bounded, clamp_limit, db_session, iso, now_utc, pick, redact_url, rnd,
|
||||
)
|
||||
@@ -137,6 +139,138 @@ async def get_monitor(monitor: str, failures: int = 10) -> dict:
|
||||
}
|
||||
|
||||
|
||||
# ── changes ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async def _host_id(db, host: str) -> str | None:
|
||||
if not (host or "").strip():
|
||||
return None
|
||||
hosts = (await db.execute(select(Host))).scalars()
|
||||
return pick(host, hosts, kind="host", list_tool="list_hosts").id
|
||||
|
||||
|
||||
def _saved(m: Monitor, host_names: dict[str, str]) -> dict:
|
||||
return {
|
||||
"monitor_id": m.id,
|
||||
"monitor_name": m.name,
|
||||
"type": m.type,
|
||||
"target": redact_url(m.target),
|
||||
"host_name": host_names.get(m.host_id) if m.host_id else None,
|
||||
"enabled": m.enabled,
|
||||
"check_interval_s": m.check_interval_seconds or None,
|
||||
"config": _config(m),
|
||||
"next": f'get_monitor(monitor="{m.id}")',
|
||||
}
|
||||
|
||||
|
||||
async def create_monitor(name: str, type: str, target: str, host: str = "",
|
||||
config: dict | None = None, check_interval_s: int = 0,
|
||||
enabled: bool = True) -> dict:
|
||||
"""Add an uptime check. Recorded in the audit log as "<you> via MCP".
|
||||
|
||||
type: "icmp" (ping), "tcp" (connect to a port), "dns" (resolve a name)
|
||||
or "http" (request a URL).
|
||||
target: an address or hostname; for http a URL (a bare host gets https://).
|
||||
host: a Steward host's name or id to file the check under; empty for a
|
||||
free-standing destination.
|
||||
config, by type (every key optional, defaults shown):
|
||||
tcp: {"port": 80}
|
||||
dns: {"expected_ip": null} (set to fail when the name resolves elsewhere)
|
||||
http: {"method": "GET" | "HEAD" | "POST", "expected_status": 200,
|
||||
"content_match": "", "headers": {}, "timeout_seconds": 10 (1-60),
|
||||
"follow_redirects": true, "verify_ssl": true}
|
||||
icmp takes none.
|
||||
check_interval_s: seconds between checks; 0 (default) uses the global
|
||||
interval from Settings.
|
||||
|
||||
The check's metrics are reported under source_module = its type and
|
||||
resource = its name, which is what an alert rule on it watches.
|
||||
"""
|
||||
fields = monitor_svc.monitor_fields(type, target=target, name=name,
|
||||
check_interval_seconds=check_interval_s, opts=config or {})
|
||||
async with db_session() as db, db.begin():
|
||||
m = Monitor(host_id=await _host_id(db, host), enabled=enabled, **fields)
|
||||
db.add(m)
|
||||
_monitors, host_names = await _load(db)
|
||||
await audit_mcp("monitor.created", "monitor", m.name,
|
||||
{"monitor_id": m.id, "type": m.type, "target": redact_url(m.target)})
|
||||
return _saved(m, host_names)
|
||||
|
||||
|
||||
async def update_monitor(monitor: str, name: str | None = None, target: str | None = None,
|
||||
host: str | None = None, config: dict | None = None,
|
||||
check_interval_s: int | None = None) -> dict:
|
||||
"""Change a monitor. Only the arguments you pass change; the rest stay.
|
||||
|
||||
monitor: its name or id (list_monitors).
|
||||
host: a host's name or id to file it under, or "" to unlink it.
|
||||
config: the keys to change, merged over its current config (see
|
||||
create_monitor for the keys of each type). A monitor's type cannot
|
||||
change; delete it and create another.
|
||||
|
||||
Alert rules watch a monitor by its name, so a rename returns `warning`
|
||||
naming the rules still on the old name.
|
||||
|
||||
Recorded in the audit log.
|
||||
"""
|
||||
async with db_session() as db, db.begin():
|
||||
monitors, host_names = await _load(db)
|
||||
m = pick(monitor, monitors, kind="monitor", list_tool="list_monitors")
|
||||
current = {k: v for k, v in m.config.items() if k in monitor_svc.CONFIG_KEYS[m.type]}
|
||||
fields = monitor_svc.monitor_fields(
|
||||
m.type, target=m.target if target is None else target,
|
||||
name=m.name if name is None else name,
|
||||
check_interval_seconds=(m.check_interval_seconds if check_interval_s is None
|
||||
else check_interval_s),
|
||||
opts={**current, **(config or {})})
|
||||
old_name = m.name
|
||||
for k, v in fields.items():
|
||||
setattr(m, k, v)
|
||||
if host is not None:
|
||||
m.host_id = await _host_id(db, host)
|
||||
stranded = []
|
||||
if m.name != old_name:
|
||||
stranded = list((await db.execute(
|
||||
select(AlertRule.name).where(AlertRule.source_module == m.type,
|
||||
AlertRule.resource_name == old_name))).scalars())
|
||||
changed = sorted(k for k, v in {"name": name, "target": target, "host": host, "config": config,
|
||||
"check_interval_s": check_interval_s}.items() if v is not None)
|
||||
await audit_mcp("monitor.updated", "monitor", m.name, {"monitor_id": m.id, "changed": changed})
|
||||
out = _saved(m, host_names)
|
||||
if stranded:
|
||||
out["warning"] = (f"Alert rules watch this monitor by name and still watch {old_name!r}: "
|
||||
f"{', '.join(stranded)}. update_alert_rule(resource=\"{m.name}\") "
|
||||
"points each at the new name.")
|
||||
return out
|
||||
|
||||
|
||||
async def set_monitor_enabled(monitor: str, enabled: bool) -> dict:
|
||||
"""Pause (enabled=false) or resume a monitor. A paused monitor runs no checks
|
||||
and raises no alerts. Recorded in the audit log."""
|
||||
async with db_session() as db, db.begin():
|
||||
monitors, host_names = await _load(db)
|
||||
m = pick(monitor, monitors, kind="monitor", list_tool="list_monitors")
|
||||
m.enabled = bool(enabled)
|
||||
await audit_mcp("monitor.enabled" if m.enabled else "monitor.disabled", "monitor", m.name,
|
||||
{"monitor_id": m.id})
|
||||
return _saved(m, host_names)
|
||||
|
||||
|
||||
async def delete_monitor(monitor: str) -> dict:
|
||||
"""Delete a monitor and its check history. To stop it but keep its history,
|
||||
set_monitor_enabled(enabled=false) instead. Admin only; recorded in the
|
||||
audit log."""
|
||||
async with db_session() as db, db.begin():
|
||||
monitors, _host_names = await _load(db)
|
||||
m = pick(monitor, monitors, kind="monitor", list_tool="list_monitors")
|
||||
await db.delete(m)
|
||||
await audit_mcp("monitor.deleted", "monitor", m.name,
|
||||
{"monitor_id": m.id, "type": m.type, "target": redact_url(m.target)})
|
||||
return {"deleted": m.name, "monitor_id": m.id}
|
||||
|
||||
|
||||
def register(register_tool) -> None:
|
||||
register_tool(list_monitors, role=UserRole.viewer, read_only=True)
|
||||
register_tool(get_monitor, role=UserRole.viewer, read_only=True)
|
||||
for fn in (create_monitor, update_monitor, set_monitor_enabled):
|
||||
register_tool(fn, role=UserRole.operator, read_only=False)
|
||||
register_tool(delete_monitor, role=UserRole.admin, read_only=False)
|
||||
|
||||
+29
-41
@@ -5,7 +5,6 @@ type-aware add/edit form, one dashboard widget. A monitor may be linked to a
|
||||
Host (host_id) or watch a free-standing custom destination (host_id = NULL).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
@@ -20,35 +19,24 @@ from steward.monitors import service as monitor_svc
|
||||
monitors_bp = Blueprint("monitors", __name__, url_prefix="/monitors")
|
||||
|
||||
|
||||
def _build_config(form, mtype: str) -> dict:
|
||||
"""Assemble the type-specific config dict from submitted form fields."""
|
||||
def _form_opts(form, mtype: str) -> dict:
|
||||
"""The submitted fields of `mtype`'s config, for monitor_svc.build_config.
|
||||
An unchecked checkbox is absent from the form, so it reads as False."""
|
||||
if mtype == MonitorType.tcp.value:
|
||||
return {"port": int(form.get("port") or 80)}
|
||||
return {"port": form.get("port")}
|
||||
if mtype == MonitorType.dns.value:
|
||||
return {"expected_ip": (form.get("expected_ip", "").strip() or None)}
|
||||
return {"expected_ip": form.get("expected_ip")}
|
||||
if mtype == MonitorType.http.value:
|
||||
try:
|
||||
headers = json.loads(form.get("headers_json") or "{}")
|
||||
except (ValueError, TypeError):
|
||||
headers = {}
|
||||
return {
|
||||
"method": form.get("method", "GET").upper(),
|
||||
"expected_status": int(form.get("expected_status", 200) or 200),
|
||||
"content_match": form.get("content_match", "").strip(),
|
||||
"headers": headers,
|
||||
"timeout_seconds": int(form.get("timeout_seconds", 10) or 10),
|
||||
"method": form.get("method"),
|
||||
"expected_status": form.get("expected_status"),
|
||||
"content_match": form.get("content_match"),
|
||||
"headers": form.get("headers_json"),
|
||||
"timeout_seconds": form.get("timeout_seconds"),
|
||||
"follow_redirects": "follow_redirects" in form,
|
||||
"verify_ssl": "verify_ssl" in form,
|
||||
}
|
||||
return {} # icmp
|
||||
|
||||
|
||||
def _normalise_target(mtype: str, target: str) -> str:
|
||||
"""For http, default a bare host to https://; others pass through."""
|
||||
target = target.strip()
|
||||
if mtype == MonitorType.http.value and target and not target.startswith(("http://", "https://")):
|
||||
return "https://" + target
|
||||
return target
|
||||
return {}
|
||||
|
||||
|
||||
@monitors_bp.get("/")
|
||||
@@ -80,22 +68,17 @@ async def rows():
|
||||
async def add_monitor():
|
||||
form = await request.form
|
||||
mtype = form.get("type", "").strip()
|
||||
target = _normalise_target(mtype, form.get("target", ""))
|
||||
if mtype not in MONITOR_TYPE_VALUES or not target:
|
||||
return redirect(url_for("monitors.index"))
|
||||
try:
|
||||
fields = monitor_svc.monitor_fields(
|
||||
mtype, target=form.get("target", ""), name=form.get("name", ""),
|
||||
check_interval_seconds=form.get("check_interval_seconds"),
|
||||
opts=_form_opts(form, mtype))
|
||||
except ValueError as exc:
|
||||
return str(exc), 400
|
||||
|
||||
host_id = form.get("host_id", "").strip() or None
|
||||
monitor = Monitor(
|
||||
id=str(uuid.uuid4()),
|
||||
name=form.get("name", "").strip() or target,
|
||||
type=mtype,
|
||||
target=target,
|
||||
host_id=host_id,
|
||||
config_json=json.dumps(_build_config(form, mtype)),
|
||||
enabled=True,
|
||||
check_interval_seconds=int(form.get("check_interval_seconds", 0) or 0),
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
monitor = Monitor(id=str(uuid.uuid4()), host_id=host_id, enabled=True,
|
||||
created_at=datetime.now(timezone.utc), **fields)
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
db.add(monitor)
|
||||
@@ -129,10 +112,15 @@ async def edit_monitor(monitor_id: str):
|
||||
return redirect(url_for("monitors.index"))
|
||||
# Type is immutable on edit (changes the meaning of config/target);
|
||||
# to switch type, delete and re-add.
|
||||
m.name = form.get("name", "").strip() or m.target
|
||||
m.target = _normalise_target(m.type, form.get("target", m.target))
|
||||
m.config_json = json.dumps(_build_config(form, m.type))
|
||||
m.check_interval_seconds = int(form.get("check_interval_seconds", 0) or 0)
|
||||
try:
|
||||
fields = monitor_svc.monitor_fields(
|
||||
m.type, target=form.get("target", m.target), name=form.get("name", ""),
|
||||
check_interval_seconds=form.get("check_interval_seconds"),
|
||||
opts=_form_opts(form, m.type))
|
||||
except ValueError as exc:
|
||||
return str(exc), 400
|
||||
for k, v in fields.items():
|
||||
setattr(m, k, v)
|
||||
return redirect(url_for("monitors.index"))
|
||||
|
||||
|
||||
|
||||
+104
-5
@@ -1,18 +1,117 @@
|
||||
"""Monitor queries shared by the monitors pages and the MCP tools.
|
||||
"""Monitor queries and validation shared by the monitors pages and the MCP tools.
|
||||
|
||||
Each function takes an open session, with no Quart request context, so the
|
||||
routes and the /mcp tools (which run outside Quart's pipeline) call the same
|
||||
code.
|
||||
Each function takes an open session (or plain values), with no Quart request
|
||||
context, so the routes and the /mcp tools (which run outside Quart's
|
||||
pipeline) call the same code and refuse the same input for the same reasons.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from steward.core.status import _heartbeat_title, _last_n_results, _uptime_by_monitor
|
||||
from steward.core.time_range import parse_range
|
||||
from steward.models.monitors import Monitor, MonitorResult
|
||||
from steward.models.monitors import MONITOR_TYPE_VALUES, Monitor, MonitorResult, MonitorType
|
||||
|
||||
HTTP_METHODS = ("GET", "HEAD", "POST")
|
||||
# The config keys each type takes (models/monitors.py documents their meaning).
|
||||
CONFIG_KEYS: dict[str, tuple[str, ...]] = {
|
||||
MonitorType.icmp.value: (),
|
||||
MonitorType.tcp.value: ("port",),
|
||||
MonitorType.dns.value: ("expected_ip",),
|
||||
MonitorType.http.value: ("method", "expected_status", "content_match", "headers",
|
||||
"timeout_seconds", "follow_redirects", "verify_ssl"),
|
||||
}
|
||||
|
||||
|
||||
def _whole(name: str, value, default: int, lo: int, hi: int | None = None) -> int:
|
||||
if value is None or value == "":
|
||||
return default
|
||||
if isinstance(value, bool):
|
||||
raise ValueError(f"{name} must be a whole number, got {value!r}.")
|
||||
try:
|
||||
n = int(value)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError(f"{name} must be a whole number, got {value!r}.") from None
|
||||
if n < lo or (hi is not None and n > hi):
|
||||
bounds = f"between {lo} and {hi}" if hi is not None else f"at least {lo}"
|
||||
raise ValueError(f"{name} must be {bounds}, got {n}.")
|
||||
return n
|
||||
|
||||
|
||||
def _headers(value) -> dict:
|
||||
if value is None or value == "":
|
||||
return {}
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
value = json.loads(value)
|
||||
except ValueError:
|
||||
raise ValueError("headers must be a JSON object of name: value strings.") from None
|
||||
if not isinstance(value, dict) or not all(isinstance(k, str) and isinstance(v, str)
|
||||
for k, v in value.items()):
|
||||
raise ValueError("headers must be an object of name: value strings.")
|
||||
return value
|
||||
|
||||
|
||||
def normalise_target(mtype: str, target: str) -> str:
|
||||
"""For http, default a bare host to https://; others pass through."""
|
||||
target = (target or "").strip()
|
||||
if mtype == MonitorType.http.value and target and not target.startswith(("http://", "https://")):
|
||||
return "https://" + target
|
||||
return target
|
||||
|
||||
|
||||
def build_config(mtype: str, opts: Mapping) -> dict:
|
||||
"""The type-specific config from already-typed or form-string values.
|
||||
|
||||
Missing keys take their defaults; a key the type does not take, or a
|
||||
value out of range, raises ValueError saying which and why.
|
||||
"""
|
||||
unknown = sorted(set(opts) - set(CONFIG_KEYS[mtype]))
|
||||
if unknown:
|
||||
allowed = ", ".join(CONFIG_KEYS[mtype]) or "no config"
|
||||
raise ValueError(f"a {mtype} monitor does not take {', '.join(unknown)}; it takes {allowed}.")
|
||||
if mtype == MonitorType.tcp.value:
|
||||
return {"port": _whole("port", opts.get("port"), 80, 1, 65535)}
|
||||
if mtype == MonitorType.dns.value:
|
||||
return {"expected_ip": (str(opts.get("expected_ip") or "").strip() or None)}
|
||||
if mtype == MonitorType.http.value:
|
||||
method = str(opts.get("method") or "GET").strip().upper()
|
||||
if method not in HTTP_METHODS:
|
||||
raise ValueError(f"method {method!r} is not one of {', '.join(HTTP_METHODS)}.")
|
||||
return {
|
||||
"method": method,
|
||||
"expected_status": _whole("expected_status", opts.get("expected_status"), 200, 100, 599),
|
||||
"content_match": str(opts.get("content_match") or "").strip(),
|
||||
"headers": _headers(opts.get("headers")),
|
||||
"timeout_seconds": _whole("timeout_seconds", opts.get("timeout_seconds"), 10, 1, 60),
|
||||
"follow_redirects": bool(opts.get("follow_redirects", True)),
|
||||
"verify_ssl": bool(opts.get("verify_ssl", True)),
|
||||
}
|
||||
return {} # icmp
|
||||
|
||||
|
||||
def monitor_fields(mtype: str, *, target: str, name: str = "", check_interval_seconds=0,
|
||||
opts: Mapping | None = None) -> dict:
|
||||
"""Validated column values for a new or edited monitor; ValueError says why not."""
|
||||
mtype = (mtype or "").strip().lower()
|
||||
if mtype not in MONITOR_TYPE_VALUES:
|
||||
raise ValueError(f"type {mtype!r} is not one of {', '.join(MONITOR_TYPE_VALUES)}.")
|
||||
target = normalise_target(mtype, target)
|
||||
if not target:
|
||||
raise ValueError("a monitor needs a target: an address, or a URL for http.")
|
||||
if len(target) > 2048:
|
||||
raise ValueError("target is longer than 2048 characters.")
|
||||
return {
|
||||
"type": mtype,
|
||||
"target": target,
|
||||
"name": ((name or "").strip() or target)[:128],
|
||||
"config_json": json.dumps(build_config(mtype, opts or {})),
|
||||
"check_interval_seconds": _whole("check_interval_seconds", check_interval_seconds, 0, 0),
|
||||
}
|
||||
|
||||
|
||||
async def monitor_rows(db, range_key: str):
|
||||
|
||||
Reference in New Issue
Block a user