feat(ui): a host's "Full metrics" heads its page, and a resource's anomalies are one line with a way to review them (#6797)
The host page's link to the full metrics sat in the Agent card, under the anomaly card and the vitals strip, and was missed. It now heads the vitals strip at the top of the page. The anomaly card on the host, monitor, container and Traefik pages listed every anomaly of the last 30 days with its review form, reviewed ones included, and took over the top of the page. It is now one line: how many wait for review, which are out of their normal now, and a Review button to Alerts → Anomalies narrowed to that resource (?source=&resource=), where a review returns. It is hidden when nothing waits and nothing is going on. The callers fetch up to 100 rows so the count isn't capped at 10 or 20. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -284,15 +284,24 @@ async def alert_history():
|
||||
@require_role(UserRole.viewer)
|
||||
async def anomalies():
|
||||
"""Metric anomalies (#5740): those to review, newest occurrence first,
|
||||
then the reviewed and lapsed ones under day dividers."""
|
||||
then the reviewed and lapsed ones under day dividers. `source` and
|
||||
`resource` narrow it to one resource's, its parts included: a resource's
|
||||
page links here with them."""
|
||||
from urllib.parse import urlencode
|
||||
from steward.audit.service import mark_day_groups
|
||||
from steward.core import anomalies as anom
|
||||
from steward.core import formatting as fmt
|
||||
source = request.args.get("source", "").strip() or None
|
||||
resource = request.args.get("resource", "").strip() or None
|
||||
only = {"source": source, "resource": resource}
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
to_review = await anom.views(db, await anom.list_anomalies(db, (anom.TO_REVIEW,)))
|
||||
to_review = await anom.views(db, await anom.list_anomalies(db, (anom.TO_REVIEW,), **only))
|
||||
past = await anom.views(db, await anom.list_anomalies(
|
||||
db, (anom.NORMAL, anom.REAL, anom.LAPSED), limit=PAGE_SIZE * 4))
|
||||
db, (anom.NORMAL, anom.REAL, anom.LAPSED), limit=PAGE_SIZE * 4, **only))
|
||||
days = int(await get_setting(db, "anomalies.review_days") or 14)
|
||||
here = "/alerts/anomalies"
|
||||
if source or resource:
|
||||
here += "?" + urlencode({k: v for k, v in only.items() if v})
|
||||
tz = fmt.zone(session.get("tz"))
|
||||
to_review = [{"v": v} for v in to_review]
|
||||
past = [{"v": v} for v in past]
|
||||
@@ -300,7 +309,7 @@ async def anomalies():
|
||||
mark_day_groups(to_review, tz, at=at)
|
||||
mark_day_groups(past, tz, at=at)
|
||||
return await render_template("alerts/anomalies.html", to_review=to_review, past=past,
|
||||
review_days=days)
|
||||
review_days=days, source=source, resource=resource, here=here)
|
||||
|
||||
|
||||
@alerts_bp.post("/anomalies/<anomaly_id>/review")
|
||||
|
||||
@@ -132,10 +132,11 @@ async def host_detail(host_id: str):
|
||||
linkable_targets = (await db.execute(
|
||||
select(AnsibleTarget).where(AnsibleTarget.host_id.is_(None))
|
||||
.order_by(AnsibleTarget.name))).scalars().all()
|
||||
# The host's metric anomalies, its disks and interfaces included (#5740).
|
||||
# The host's metric anomalies, its disks and interfaces included
|
||||
# (#5740). The card counts those to review, so the limit is generous.
|
||||
anomalies = await anom.views(db, await anom.list_anomalies(
|
||||
db, source="host_agent", resource=host.name,
|
||||
since=datetime.now(timezone.utc) - timedelta(days=30), limit=10), links=False)
|
||||
since=datetime.now(timezone.utc) - timedelta(days=30), limit=100), links=False)
|
||||
|
||||
return await render_template(
|
||||
"hosts/detail.html",
|
||||
|
||||
@@ -128,7 +128,7 @@ async def detail(monitor_id: str):
|
||||
norm = (await monitor_svc.latency_norms(db, [m])).get(m.id)
|
||||
anomalies = await anom.views(db, await anom.list_anomalies(
|
||||
db, source=m.type, resource=m.name,
|
||||
since=datetime.now(timezone.utc) - timedelta(days=30), limit=20), links=False)
|
||||
since=datetime.now(timezone.utc) - timedelta(days=30), limit=100), links=False)
|
||||
host = await db.get(Host, m.host_id) if m.host_id else None
|
||||
thresholds = current_app.config.get("THRESHOLDS", {})
|
||||
latest = st["latest"]
|
||||
|
||||
@@ -1,14 +1,31 @@
|
||||
{# A resource's metric anomalies (#5740), for its own page: `anomalies` are
|
||||
anomalies.views() rows, to review first; `back` is this page's path, so a
|
||||
review returns here. Shown only when there is one. #}
|
||||
{% from "_anomaly.html" import anomaly_row with context %}
|
||||
{# A resource's metric anomalies, for its own page: one line saying how many
|
||||
wait for review and which are out of their normal now, with a button to
|
||||
Alerts → Anomalies narrowed to this resource, where they are reviewed.
|
||||
`anomalies` are anomalies.views() rows; `base_resource` is the page's
|
||||
resource (none for a page covering a whole source, like Traefik). Shown
|
||||
only while one waits for review or is going on. #}
|
||||
{% if anomalies %}
|
||||
{% set going = anomalies | selectattr("ongoing") | list %}
|
||||
<section class="card{% if going %} problem-card{% endif %}" aria-labelledby="resource-anomalies" style="margin-bottom:1rem;">
|
||||
<h2 id="resource-anomalies" class="section-title" style="margin-bottom:0.35rem;">
|
||||
{% if going %}Out of its normal now{% else %}Metric anomalies, last 30 days{% endif %}
|
||||
{% set waiting = anomalies | selectattr("anomaly.review", "equalto", "to_review") | list %}
|
||||
{% set going = anomalies | selectattr("ongoing") | rejectattr("anomaly.review", "equalto", "normal") | list %}
|
||||
{% if waiting or going %}
|
||||
{% set q = {"source": anomalies[0].anomaly.source_module} %}
|
||||
{% if base_resource %}{% set _ = q.update({"resource": base_resource}) %}{% endif %}
|
||||
<section class="card{% if going %} problem-card{% endif %}" aria-labelledby="resource-anomalies"
|
||||
style="margin-bottom:1rem;display:flex;align-items:center;gap:0.5rem 1rem;flex-wrap:wrap;">
|
||||
<h2 id="resource-anomalies" class="section-title" style="margin:0;">
|
||||
{% if going %}Out of its normal now{% else %}Metric anomalies{% endif %}
|
||||
</h2>
|
||||
{% for v in anomalies %}{{ anomaly_row(v, back=back, show_resource=v.anomaly.resource_name != base_resource) }}{% endfor %}
|
||||
<div style="font-size:0.78rem;margin-top:0.4rem;"><a href="/alerts/anomalies">All anomalies</a></div>
|
||||
<span style="font-size:0.85rem;">
|
||||
{%- if going -%}
|
||||
{%- for v in going -%}
|
||||
{%- if v.anomaly.resource_name != base_resource %}{{ v.anomaly.resource_name }} {% endif -%}
|
||||
{{ metric_label(v.anomaly.metric_name) }}{{ ", " if not loop.last }}
|
||||
{%- endfor -%}
|
||||
{%- if waiting %} · {% endif -%}
|
||||
{%- endif -%}
|
||||
{%- if waiting %}<strong>{{ waiting|length }}</strong> to review{% endif -%}
|
||||
</span>
|
||||
<a class="btn btn-sm" style="margin-left:auto;" href="/alerts/anomalies?{{ q|urlencode }}">Review</a>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
@@ -11,10 +11,17 @@
|
||||
{% macro divided(items) %}
|
||||
{% for item in items %}
|
||||
{% if item.opens_group %}<h3 class="table-divider" style="font-size:0.78rem;font-weight:500;color:var(--text);background:var(--bg-elevated);padding:0.35rem 0.75rem;margin:0.75rem -0.75rem 0;border-top:1px solid var(--border-mid);border-bottom:1px solid var(--border-mid);">{{ item.group }}</h3>{% endif %}
|
||||
{{ anomaly_row(item.v, back="/alerts/anomalies") }}
|
||||
{{ anomaly_row(item.v, back=here|default("/alerts/anomalies"), show_resource=not resource or item.v.anomaly.resource_name != resource) }}
|
||||
{% endfor %}
|
||||
{% endmacro %}
|
||||
|
||||
{% if source or resource %}
|
||||
<p class="field-hint" style="margin:0.75rem 0 0;">
|
||||
Only {% if resource %}<strong>{{ resource }}</strong>{% if source %} ({{ source_label(source) }}){% endif %}{% else %}{{ source_label(source) }}{% endif %}.
|
||||
<a href="/alerts/anomalies">Show every anomaly</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="card" style="margin-top:1rem;">
|
||||
<h2 class="section-title" style="margin-bottom:0.35rem;">
|
||||
{% if to_review %}{{ to_review|length }} to review{% else %}Nothing to review{% endif %}
|
||||
|
||||
Reference in New Issue
Block a user