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:
@@ -125,7 +125,7 @@ What learns a normal, and the floor each departure must clear (Settings → Thre
|
|||||||
|
|
||||||
Host metrics learn with `parts=False`: the host learns, its cores (`<host>:core<N>`) don't. Disk space, a monitor that is down, an agent gone quiet and a router's 5xx share are failures and keep their fixed cutoffs (a router's shares are judged only over enough requests).
|
Host metrics learn with `parts=False`: the host learns, its cores (`<host>:core<N>`) don't. Disk space, a monitor that is down, an agent gone quiet and a router's 5xx share are failures and keep their fixed cutoffs (a router's shares are judged only over enough requests).
|
||||||
|
|
||||||
Pages grade by the normal through `anomalies.graded()` (hosts' `host_grades` and `worst_reading(normals=)`, `docker.service.usage(thresholds=, norms=)`, `monitors.service.latency_grade`): a reading past its cutoff but within its normal reads healthy and says what is usual at this hour. The host, monitor, container and Traefik pages list their anomalies with `_anomaly_card.html`.
|
Pages grade by the normal through `anomalies.graded()` (hosts' `host_grades` and `worst_reading(normals=)`, `docker.service.usage(thresholds=, norms=)`, `monitors.service.latency_grade`): a reading past its cutoff but within its normal reads healthy and says what is usual at this hour. The host, monitor, container and Traefik pages each carry a one-line `_anomaly_card.html`. It says how many anomalies wait for review and names any that are out of their normal now. Its Review button opens Alerts → Anomalies narrowed to that resource (`?source=&resource=`), where they are reviewed.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -482,7 +482,7 @@ async def container_detail(host_id: str, name: str):
|
|||||||
resource = docker_svc.container_resource(host.name, name) if host is not None else None
|
resource = docker_svc.container_resource(host.name, name) if host is not None else None
|
||||||
anomalies = await anom.views(db, await anom.list_anomalies(
|
anomalies = await anom.views(db, await anom.list_anomalies(
|
||||||
db, source="docker", resource=resource,
|
db, source="docker", resource=resource,
|
||||||
since=datetime.now(timezone.utc) - timedelta(days=30), limit=20),
|
since=datetime.now(timezone.utc) - timedelta(days=30), limit=100),
|
||||||
links=False) if resource else []
|
links=False) if resource else []
|
||||||
|
|
||||||
if c is None:
|
if c is None:
|
||||||
|
|||||||
@@ -21,6 +21,8 @@
|
|||||||
{%- if reg.agent_version %}{{ " · " if not stale }}agent v{{ reg.agent_version }}{% endif -%}
|
{%- if reg.agent_version %}{{ " · " if not stale }}agent v{{ reg.agent_version }}{% endif -%}
|
||||||
{%- if clock and clock[0] %} · <span{% if clock[1] in ('warn', 'crit') %} class="tone-{{ clock[1] }}" title="Further from Steward's clock than Settings → Thresholds allows: check what keeps this host's time in sync"{% endif %}>{{ clock[0] }}</span>{% endif -%}
|
{%- if clock and clock[0] %} · <span{% if clock[1] in ('warn', 'crit') %} class="tone-{{ clock[1] }}" title="Further from Steward's clock than Settings → Thresholds allows: check what keeps this host's time in sync"{% endif %}>{{ clock[0] }}</span>{% endif -%}
|
||||||
</span>
|
</span>
|
||||||
|
{# Charts, every mount, interface, disk and temperature sensor. #}
|
||||||
|
<a href="/plugins/host_agent/{{ reg.host_id }}/" class="btn btn-sm btn-ghost" style="margin-left:auto;">Full metrics →</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-strip host-health-tiles" style="margin-bottom:0;">
|
<div class="stat-strip host-health-tiles" style="margin-bottom:0;">
|
||||||
{{ stat_tile("CPU", pct(cpu), state=(grades.cpu or [status_class(cpu, 'cpu')])[0], hint=sparks.cpu|safe) }}
|
{{ stat_tile("CPU", pct(cpu), state=(grades.cpu or [status_class(cpu, 'cpu')])[0], hint=sparks.cpu|safe) }}
|
||||||
|
|||||||
@@ -14,10 +14,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if reporting %}
|
{% if reporting %}
|
||||||
{# Reporting: live vitals are shown by the vitals strip above; this panel is
|
{# Reporting: live vitals, and the way to the full metrics, are in the
|
||||||
the way to the full metrics, with the lifecycle actions folded under
|
vitals strip at the top of the page; this panel holds what the host has
|
||||||
"Manage agent" (#5278). #}
|
waiting, with the lifecycle actions folded under "Manage agent" (#5278). #}
|
||||||
<a href="/plugins/host_agent/{{ host.id }}/" class="btn btn-sm btn-ghost">Full metrics →</a>
|
|
||||||
|
|
||||||
{# What the host has waiting, from the agent's hourly check (#5765): a
|
{# What the host has waiting, from the agent's hourly check (#5765): a
|
||||||
needed reboot first, then the updates. #}
|
needed reboot first, then the updates. #}
|
||||||
|
|||||||
@@ -410,7 +410,7 @@ async def rows():
|
|||||||
phases.mark("window")
|
phases.mark("window")
|
||||||
# The routers' metric anomalies, last 30 days (#5741).
|
# The routers' metric anomalies, last 30 days (#5741).
|
||||||
anomalies = await anom.views(db, await anom.list_anomalies(
|
anomalies = await anom.views(db, await anom.list_anomalies(
|
||||||
db, source="traefik", since=now - timedelta(days=30), limit=20), links=False)
|
db, source="traefik", since=now - timedelta(days=30), limit=100), links=False)
|
||||||
for r in current:
|
for r in current:
|
||||||
points = history[r["name"]]
|
points = history[r["name"]]
|
||||||
r["since"], r["since_before"] = (problem_since(points, thresholds) if r["state"] != "ok"
|
r["since"], r["since_before"] = (problem_since(points, thresholds) if r["state"] != "ok"
|
||||||
|
|||||||
@@ -284,15 +284,24 @@ async def alert_history():
|
|||||||
@require_role(UserRole.viewer)
|
@require_role(UserRole.viewer)
|
||||||
async def anomalies():
|
async def anomalies():
|
||||||
"""Metric anomalies (#5740): those to review, newest occurrence first,
|
"""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.audit.service import mark_day_groups
|
||||||
from steward.core import anomalies as anom
|
from steward.core import anomalies as anom
|
||||||
from steward.core import formatting as fmt
|
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:
|
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(
|
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)
|
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"))
|
tz = fmt.zone(session.get("tz"))
|
||||||
to_review = [{"v": v} for v in to_review]
|
to_review = [{"v": v} for v in to_review]
|
||||||
past = [{"v": v} for v in past]
|
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(to_review, tz, at=at)
|
||||||
mark_day_groups(past, tz, at=at)
|
mark_day_groups(past, tz, at=at)
|
||||||
return await render_template("alerts/anomalies.html", to_review=to_review, past=past,
|
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")
|
@alerts_bp.post("/anomalies/<anomaly_id>/review")
|
||||||
|
|||||||
@@ -132,10 +132,11 @@ async def host_detail(host_id: str):
|
|||||||
linkable_targets = (await db.execute(
|
linkable_targets = (await db.execute(
|
||||||
select(AnsibleTarget).where(AnsibleTarget.host_id.is_(None))
|
select(AnsibleTarget).where(AnsibleTarget.host_id.is_(None))
|
||||||
.order_by(AnsibleTarget.name))).scalars().all()
|
.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(
|
anomalies = await anom.views(db, await anom.list_anomalies(
|
||||||
db, source="host_agent", resource=host.name,
|
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(
|
return await render_template(
|
||||||
"hosts/detail.html",
|
"hosts/detail.html",
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ async def detail(monitor_id: str):
|
|||||||
norm = (await monitor_svc.latency_norms(db, [m])).get(m.id)
|
norm = (await monitor_svc.latency_norms(db, [m])).get(m.id)
|
||||||
anomalies = await anom.views(db, await anom.list_anomalies(
|
anomalies = await anom.views(db, await anom.list_anomalies(
|
||||||
db, source=m.type, resource=m.name,
|
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
|
host = await db.get(Host, m.host_id) if m.host_id else None
|
||||||
thresholds = current_app.config.get("THRESHOLDS", {})
|
thresholds = current_app.config.get("THRESHOLDS", {})
|
||||||
latest = st["latest"]
|
latest = st["latest"]
|
||||||
|
|||||||
@@ -1,14 +1,31 @@
|
|||||||
{# A resource's metric anomalies (#5740), for its own page: `anomalies` are
|
{# A resource's metric anomalies, for its own page: one line saying how many
|
||||||
anomalies.views() rows, to review first; `back` is this page's path, so a
|
wait for review and which are out of their normal now, with a button to
|
||||||
review returns here. Shown only when there is one. #}
|
Alerts → Anomalies narrowed to this resource, where they are reviewed.
|
||||||
{% from "_anomaly.html" import anomaly_row with context %}
|
`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 %}
|
{% if anomalies %}
|
||||||
{% set going = anomalies | selectattr("ongoing") | list %}
|
{% set waiting = anomalies | selectattr("anomaly.review", "equalto", "to_review") | list %}
|
||||||
<section class="card{% if going %} problem-card{% endif %}" aria-labelledby="resource-anomalies" style="margin-bottom:1rem;">
|
{% set going = anomalies | selectattr("ongoing") | rejectattr("anomaly.review", "equalto", "normal") | list %}
|
||||||
<h2 id="resource-anomalies" class="section-title" style="margin-bottom:0.35rem;">
|
{% if waiting or going %}
|
||||||
{% if going %}Out of its normal now{% else %}Metric anomalies, last 30 days{% endif %}
|
{% 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>
|
</h2>
|
||||||
{% for v in anomalies %}{{ anomaly_row(v, back=back, show_resource=v.anomaly.resource_name != base_resource) }}{% endfor %}
|
<span style="font-size:0.85rem;">
|
||||||
<div style="font-size:0.78rem;margin-top:0.4rem;"><a href="/alerts/anomalies">All anomalies</a></div>
|
{%- 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>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|||||||
@@ -11,10 +11,17 @@
|
|||||||
{% macro divided(items) %}
|
{% macro divided(items) %}
|
||||||
{% for item in 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 %}
|
{% 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 %}
|
{% endfor %}
|
||||||
{% endmacro %}
|
{% 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;">
|
<div class="card" style="margin-top:1rem;">
|
||||||
<h2 class="section-title" style="margin-bottom:0.35rem;">
|
<h2 class="section-title" style="margin-bottom:0.35rem;">
|
||||||
{% if to_review %}{{ to_review|length }} to review{% else %}Nothing to review{% endif %}
|
{% if to_review %}{{ to_review|length }} to review{% else %}Nothing to review{% endif %}
|
||||||
|
|||||||
@@ -88,6 +88,45 @@ async def test_a_quiet_page_says_nothing_waits(app):
|
|||||||
assert "Nothing to review" in page and "within 14 days" in page
|
assert "Nothing to review" in page and "within 14 days" in page
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_page_narrowed_to_one_resource_says_so_and_a_review_returns_to_it(app):
|
||||||
|
here = "/alerts/anomalies?source=host_agent&resource=nas"
|
||||||
|
async with app.test_request_context(here):
|
||||||
|
session["user_id"], session["user_role"] = "u1", "operator"
|
||||||
|
page = await render_template("alerts/anomalies.html", review_days=14, past=[],
|
||||||
|
to_review=[{"v": _view(), "opens_group": True, "group": "Today"}],
|
||||||
|
source="host_agent", resource="nas", here=here)
|
||||||
|
assert "<strong>nas</strong>" in page and '<a href="/alerts/anomalies">Show every anomaly</a>' in page
|
||||||
|
assert 'name="next" value="/alerts/anomalies?source=host_agent&resource=nas"' in page
|
||||||
|
|
||||||
|
|
||||||
|
async def _card(app, anomalies, base_resource="nas"):
|
||||||
|
async with app.test_request_context("/hosts/h1"):
|
||||||
|
session["user_id"], session["user_role"] = "u1", "operator"
|
||||||
|
return await render_template("_anomaly_card.html", anomalies=anomalies,
|
||||||
|
back="/hosts/h1", base_resource=base_resource)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_resources_card_is_one_line_counting_what_waits_with_a_way_to_review(app):
|
||||||
|
ended = NOW - timedelta(minutes=5)
|
||||||
|
page = await _card(app, [_view(ended_at=ended), _view(id="a2", metric_name="load_pct", ended_at=ended),
|
||||||
|
_view(id="a3", review="normal", ended_at=ended)])
|
||||||
|
assert "<strong>2</strong> to review" in page
|
||||||
|
assert 'href="/alerts/anomalies?source=host_agent&resource=nas"' in page
|
||||||
|
assert "This is normal" not in page and "Out of its normal now" not in page # reviewed there
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_card_names_what_is_out_of_its_normal_now(app):
|
||||||
|
page = await _card(app, [_view(resource_name="nas:/var", metric_name="disk_used_pct")])
|
||||||
|
assert "Out of its normal now" in page and "nas:/var" in page and "problem-card" in page
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_card_with_nothing_waiting_or_going_is_not_shown(app):
|
||||||
|
reviewed = _view(review="normal", ended_at=NOW - timedelta(minutes=5))
|
||||||
|
assert "resource-anomalies" not in await _card(app, [reviewed])
|
||||||
|
going_but_accepted = _view(review="normal") # the new normal being learned
|
||||||
|
assert "resource-anomalies" not in await _card(app, [going_but_accepted])
|
||||||
|
|
||||||
|
|
||||||
async def _widget(app, logged_in, anomalies, to_review):
|
async def _widget(app, logged_in, anomalies, to_review):
|
||||||
async with app.test_request_context("/status/widget"):
|
async with app.test_request_context("/status/widget"):
|
||||||
if logged_in:
|
if logged_in:
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ async def test_the_agent_card_keeps_its_actions_under_manage(app):
|
|||||||
app, "host_agent", "panel.html", host=host, reg=reg, reporting=True, stale=False,
|
app, "host_agent", "panel.html", host=host, reg=reg, reporting=True, stale=False,
|
||||||
target=target, ansible_available=True, managed_key_set=True)
|
target=target, ansible_available=True, managed_key_set=True)
|
||||||
manage = page.index(">Manage agent</summary>")
|
manage = page.index(">Manage agent</summary>")
|
||||||
assert page.index("Full metrics →") < manage
|
assert "Full metrics" not in page # it heads the vitals strip, at the top of the page
|
||||||
for action in (">Update agent</button>", ">Rotate token</button>", ">Unregister agent</button>"):
|
for action in (">Update agent</button>", ">Rotate token</button>", ">Unregister agent</button>"):
|
||||||
assert page.index(action) > manage
|
assert page.index(action) > manage
|
||||||
assert ">Remove</button>" not in page
|
assert ">Remove</button>" not in page
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ async def _vitals(app, *, cpu=40.0, mem=50.0, stale=False, seen_min=1):
|
|||||||
async with app.test_request_context("/plugins/host_agent/vitals/h1"):
|
async with app.test_request_context("/plugins/host_agent/vitals/h1"):
|
||||||
page = await render_plugin_template(
|
page = await render_plugin_template(
|
||||||
app, "host_agent", "_host_vitals.html", reporting=True, stale=stale,
|
app, "host_agent", "_host_vitals.html", reporting=True, stale=stale,
|
||||||
reg=SimpleNamespace(last_seen_at=datetime.now(timezone.utc) - timedelta(minutes=seen_min),
|
reg=SimpleNamespace(host_id="h1", agent_version="1.9.0",
|
||||||
agent_version="1.9.0"),
|
last_seen_at=datetime.now(timezone.utc) - timedelta(minutes=seen_min)),
|
||||||
cpu=cpu, mem=mem, disk_root=30.0, load_per_core=20,
|
cpu=cpu, mem=mem, disk_root=30.0, load_per_core=20,
|
||||||
sparks={"cpu": "", "mem": "", "disk": "", "load": ""},
|
sparks={"cpu": "", "mem": "", "disk": "", "load": ""},
|
||||||
psi={"cpu": None, "mem": None, "io": None}, worst=worst_reading(readings, TH))
|
psi={"cpu": None, "mem": None, "io": None}, worst=worst_reading(readings, TH))
|
||||||
@@ -53,6 +53,12 @@ async def test_a_quiet_host_says_so(app):
|
|||||||
assert "Every reading is within its thresholds." in await _vitals(app)
|
assert "Every reading is within its thresholds." in await _vitals(app)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_strip_at_the_top_of_the_page_leads_to_the_full_metrics(app):
|
||||||
|
"""The temperatures, charts and every mount are on the full-metrics page;
|
||||||
|
its link sat in the Agent card below the fold and was missed."""
|
||||||
|
assert 'href="/plugins/host_agent/h1/"' in await _vitals(app)
|
||||||
|
|
||||||
|
|
||||||
async def test_a_stale_host_says_since_when_before_any_reading(app):
|
async def test_a_stale_host_says_since_when_before_any_reading(app):
|
||||||
page = await _vitals(app, mem=93.0, stale=True, seen_min=20)
|
page = await _vitals(app, mem=93.0, stale=True, seen_min=20)
|
||||||
assert '<span class="badge badge-warn">Stale</span>' in page
|
assert '<span class="badge badge-warn">Stale</span>' in page
|
||||||
|
|||||||
Reference in New Issue
Block a user