5 Commits
Author SHA1 Message Date
bvandeusenandClaude Opus 5.5 fda54a3c31 feat(status): what is wrong first — down monitors and active alerts, and since when (#5120)
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 56s
CI / unit (push) Successful in 1m11s
CI / publish (push) Successful in 1m15s
#5120 "Status page: what is wrong first".

- A "Needs attention" card heads the Status board: each down monitor with
  how long it has been down, and each active alert (firing, acknowledged,
  pending, no data) with its condition and how long, linking to its history.
- StatusEntry.since / since_at_least: when the current up/down run began,
  from the recent checks (streak_start); "for at least" when every check we
  have agrees.
- Counts as stat tiles (Down, Active alerts, Pending, Up); names wrap
  instead of being cut off; dots carry their label.
- Uptime and TLS are coloured by status_class against Settings → Thresholds
  instead of literal cutoffs; heartbeat pills and DOWN/UP use tokens and
  status_badge rather than hex and bold.
- tests/core/test_status_page.py: streak_start cases, and the rows render
  with the problem card before the list, each since-time, threshold tones
  and no hex colour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:40:45 -04:00
bvandeusenandClaude Opus 5.5 e864cd4b6c feat(ui): share view on steward.css; breadcrumbs everywhere they belong; focus ring; tables scroll in their card (#5095)
#5095 "Share view on the shared stylesheet; breadcrumbs, focus-visible and
wide tables".

- dashboard/share_view.html loads theme.css and steward.css; its own copy of
  the old purple palette is gone. Only the read-only frame and the x/y/w/h
  grid stay inline, written in tokens. HEX_CEILING 75 -> 58.
- Breadcrumbs on settings/general, settings/thresholds, the dashboard share
  page and share-link-created. tests/test_breadcrumbs.py fails on any page
  extending base.html without a crumbs() trail, unless it is on the named
  list of section top pages, logged-out and error pages (rule 134).
- :focus-visible draws --fs-focus-ring on links, buttons, summaries, inputs
  and anything with tabindex.
- Any card holding a .table scrolls sideways (card:has(table.table)), so a
  wide table no longer widens the page at phone width; .card-flush scrolls
  too, main gets min-width:0, and .table-wrap covers a table outside a card.
- tests/test_share_view.py renders the share view: shared sheets, no hex
  colour, escaped name, widgets loading through the share token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:37:39 -04:00
bvandeusenandClaude Opus 5.5 477b05b9d0 feat(ui): status_badge, status_dot, stat_tile, empty_state, page_header, icon_button and form classes (#5094)
#5094 "Status and layout macros, and form classes".

- formatting.STATES: one state -> (tone, label) map for up, down, stale,
  firing, pending, acknowledged, no_data, ok, warn, crit, resolved and
  inactive; status_info() reads it (an unmapped state is drawn dim under its
  own name). required() lets a macro refuse a missing argument.
- _macros.html: status_badge and status_dot (a dot always carries its label,
  sr-only unless shown), stat_tile, empty_state, page_header (actions via a
  call block) and icon_button, which raises without a label.
- steward.css: tone classes (badge-/dot-/stat- ok, warn, crit, info, dim),
  .sr-only, .page-header, .stat-tile, .empty-state, .btn-icon and the form
  classes .field, .field-label, .field-hint and .input. Info text is a 40%
  mix (--blue): the house --fs-info-fg falls to 4.18:1 on its tint over a
  hovered row, 40% holds 5.09:1.
- First users: the Alerts list, history and maintenance pages (headers,
  state badges, empty states) and the alert forms' hints; the inline inp/lbl
  style sets on the monitor and host forms become .input / .field-label.
- tests/core/test_macros.py renders each macro through the app environment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:34:21 -04:00
bvandeusenandClaude Opus 5.5 f47fc23265 fix(ui): delete prompts use data-confirm, so a quote in a name no longer runs script (#5099)
#5099 "A name containing a quote runs script when an admin clicks Delete".
Inline handlers like onclick="return confirm('Delete {{ rule.name }}?')" are
HTML-decoded before the JavaScript is parsed, so autoescaping did not stop a
name such as  x');fetch(…);//  from running.

- Every prompt (22 handlers, plus the docker prune and token revoke ones)
  is now data-confirm="…", read through dataset by one submit listener in
  base.html; nothing rendered is parsed as JavaScript.
- The remaining inline handlers take integers only (|int), and the time
  range buttons pass this.dataset.range.
- tests/test_inline_handlers.py fails on any {{ }} in an inline handler that
  is not |int, with a self-test; tests/_templates.py is the one template
  walker the template guards now share.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:30:13 -04:00
bvandeusenandClaude Opus 5.5 737d699308 fix(ci): integration finds its own Postgres when another repo's job shares the runner (#5104)
#5104 "CI integration lane connects to another repo's Postgres when two
integration jobs share the runner". Run 8358 took task 31950's Postgres
(another repo's integration job) with `--filter name=integration | head -n1`
and every test failed to connect. Read this job's GITEA-ACTIONS-TASK-<n>-
prefix from its own container and require exactly one Postgres under it.
(Docker ORs repeated name= filters, so the old one is dropped, not kept.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:30:13 -04:00
52 changed files with 695 additions and 284 deletions
+13 -3
View File
@@ -80,14 +80,24 @@ jobs:
# act_runner (swarm-runner) puts service containers on the default bridge
# with no embedded DNS, so the hostname `postgres` won't resolve — we
# discover the service container's bridge IP via the docker socket. The
# job name `integration` (no underscore) is the docker-ps name filter.
# job container and its Postgres share a task-number name prefix.
run: |
set -euxo pipefail
echo "=== container landscape (diagnostic for filter scoping) ==="
docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}'
echo "=== end landscape ==="
PG=$(docker ps --filter "name=integration" --filter "ancestor=postgres:16-alpine" -q | head -n1)
test -n "$PG"
# The runner's daemon is shared: another repo's integration job can be
# running beside this one, and `name=integration` matches its Postgres
# too (run 8358 took the wrong one and every test failed to connect).
# Scope to this job's own task: act_runner names every container of a
# task GITEA-ACTIONS-TASK-<n>-…, so read <n> from our own container
# (its id is in the /etc/hostname bind mount) and require one match.
SELF=$(grep -o '/containers/[0-9a-f]\{64\}/' /proc/self/mountinfo | head -n1 | cut -d/ -f3 || true)
SELF=${SELF:-$(hostname)}
TASK=$(docker inspect -f '{{.Name}}' "$SELF" | grep -o 'GITEA-ACTIONS-TASK-[0-9]*-')
test -n "$TASK"
PG=$(docker ps --filter "name=$TASK" --filter "ancestor=postgres:16-alpine" -q)
test "$(printf '%s\n' "$PG" | grep -c .)" -eq 1
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
test -n "$PG_IP"
export STEWARD_DATABASE_URL="postgresql+asyncpg://steward:steward@$PG_IP:5432/steward"
+3 -6
View File
@@ -53,20 +53,17 @@
{% if ansible_available and g.has_target %}
<div style="display:flex;flex-wrap:wrap;gap:0.5rem;align-items:center;">
<form method="post" action="/plugins/docker/disk/{{ g.host_id }}/prune" style="margin:0;"
data-msg="Remove all STOPPED containers on {{ g.host_name|e }}? This cannot be undone."
onsubmit="return confirm(this.dataset.msg);">{{ csrf_field() }}
data-confirm="Remove all STOPPED containers on {{ g.host_name }}? This cannot be undone.">{{ csrf_field() }}
<input type="hidden" name="target" value="containers">
<button type="submit" class="btn btn-sm">Prune stopped containers</button>
</form>
<form method="post" action="/plugins/docker/disk/{{ g.host_id }}/prune" style="margin:0;"
data-msg="Remove ALL unused images on {{ g.host_name|e }} (docker image prune -a)? Any image not used by a container is deleted."
onsubmit="return confirm(this.dataset.msg);">{{ csrf_field() }}
data-confirm="Remove ALL unused images on {{ g.host_name }} (docker image prune -a)? Any image not used by a container is deleted.">{{ csrf_field() }}
<input type="hidden" name="target" value="images">
<button type="submit" class="btn btn-sm">Prune unused images</button>
</form>
<form method="post" action="/plugins/docker/disk/{{ g.host_id }}/prune" style="margin:0;"
data-msg="Run a full system prune on {{ g.host_name|e }}? Removes stopped containers, unused networks, dangling images and build cache."
onsubmit="return confirm(this.dataset.msg);">{{ csrf_field() }}
data-confirm="Run a full system prune on {{ g.host_name }}? Removes stopped containers, unused networks, dangling images and build cache.">{{ csrf_field() }}
<input type="hidden" name="target" value="system">
<button type="submit" class="btn btn-sm btn-danger">System prune…</button>
</form>
+3 -3
View File
@@ -26,11 +26,11 @@
</form>
{% endif %}
<form method="post" action="/plugins/host_agent/fleet/{{ host.id }}/rotate-token" style="margin:0;"
onsubmit="return confirm('Rotate token? The agent stops reporting until reinstalled/updated with the new token.');">{{ csrf_field() }}
data-confirm="Rotate token? The agent stops reporting until reinstalled/updated with the new token.">{{ csrf_field() }}
<button type="submit" class="btn btn-sm btn-ghost">Rotate token</button>
</form>
<form method="post" action="/plugins/host_agent/fleet/{{ host.id }}/delete" style="margin:0;"
onsubmit="return confirm('Remove this agent registration? Metrics stop until re-registered.');">{{ csrf_field() }}
data-confirm="Remove this agent registration? Metrics stop until re-registered.">{{ csrf_field() }}
<button type="submit" class="btn btn-sm btn-danger">Remove</button>
</form>
{% endif %}
@@ -138,7 +138,7 @@
{% if reg and session.user_role == 'admin' %}
<form method="post" action="/plugins/host_agent/fleet/{{ host.id }}/delete" style="margin:0.75rem 0 0;"
onsubmit="return confirm('Clear this pending registration? Its token is discarded.');">{{ csrf_field() }}
data-confirm="Clear this pending registration? Its token is discarded.">{{ csrf_field() }}
<button type="submit" class="btn btn-sm btn-ghost">Clear pending registration</button>
</form>
{% endif %}
@@ -156,12 +156,12 @@
<td class="td-actions">
<form method="post" action="/plugins/host_agent/fleet/{{ item.reg.host_id }}/rotate-token"
style="display:inline;"
onsubmit="return confirm('Rotate the token for this host? The existing agent will stop working until reinstalled with the new token.');">{{ csrf_field() }}
data-confirm="Rotate the token for this host? The existing agent will stop working until reinstalled with the new token.">{{ csrf_field() }}
<button type="submit" class="btn btn-ghost btn-sm">Rotate token</button>
</form>
<form method="post" action="/plugins/host_agent/fleet/{{ item.reg.host_id }}/delete"
style="display:inline;"
onsubmit="return confirm('Delete this host registration? The agent will be unable to report metrics until re-registered.');">{{ csrf_field() }}
data-confirm="Delete this host registration? The agent will be unable to report metrics until re-registered.">{{ csrf_field() }}
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
</form>
</td>
@@ -22,7 +22,7 @@
{% endif %}
<span class="hist-range" style="margin-left:auto;display:inline-flex;border:1px solid var(--border-mid);border-radius:5px;overflow:hidden;">
{% for opt in [1, 6, 24] %}
<button type="button" onclick="histRange({{ wid }}, {{ opt }})"
<button type="button" onclick="histRange({{ wid|int }}, {{ opt|int }})"
style="border:0;cursor:pointer;padding:0.1rem 0.45rem;font-size:0.72rem;
background:{% if opt == hours %}var(--accent){% else %}transparent{% endif %};
color:{% if opt == hours %}#fff{% else %}var(--text-muted){% endif %};">{{ opt }}h</button>
+3
View File
@@ -140,6 +140,9 @@ def create_app(
app.jinja_env.globals["status_class"] = (
lambda value, kind: _fmt.status_class(value, kind, app.config.get("THRESHOLDS", {}))
)
# The status and layout macros in _macros.html read these.
app.jinja_env.globals["status_info"] = _fmt.status_info
app.jinja_env.globals["required"] = _fmt.required
# Forged-request protection: every POST form renders csrf_field(), HTMX and
# fetch() send csrf_token() as X-CSRF-Token, and one check refuses any
+34
View File
@@ -95,3 +95,37 @@ def status_class(value: Any, kind: str, thresholds: dict) -> str:
if th.get("dir", "high") == "low":
return "crit" if value < crit else "warn" if value < warn else "ok"
return "crit" if value >= crit else "warn" if value >= warn else "ok"
# Every state a page shows, and the tone it is drawn in: ok (green), warn
# (amber), crit (red), info (blue) or dim. The status_badge and status_dot
# macros read this, so one state looks the same on every page.
STATES: dict[str, tuple[str, str]] = {
"up": ("ok", "Up"),
"ok": ("ok", "OK"),
"resolved": ("ok", "Resolved"),
"down": ("crit", "Down"),
"firing": ("crit", "Firing"),
"crit": ("crit", "Critical"),
"warn": ("warn", "Warning"),
"pending": ("warn", "Pending"),
"stale": ("warn", "Stale"),
"no_data": ("warn", "No data"),
"acknowledged": ("info", "Acknowledged"),
"inactive": ("dim", "Inactive"),
"unknown": ("dim", "Unknown"),
}
def status_info(state: Any) -> tuple[str, str]:
"""(tone, label) for a state name or enum. A state the map doesn't know
is drawn dim under its own name rather than breaking the page."""
key = str(getattr(state, "value", state) or "unknown").lower()
return STATES.get(key, ("dim", key.replace("_", " ").capitalize()))
def required(value: Any, what: str) -> Any:
"""For a macro argument that must be given: raise if it is empty."""
if value is None or not str(value).strip():
raise ValueError(f"{what} is required")
return value
+17
View File
@@ -38,6 +38,10 @@ class StatusEntry:
spark_svg: str = "" # filled by the route via sparkline_svg()
tls_days: float | None = None # days until TLS expiry (http only)
detail_url: str | None = None
# When the current up/down state began, as far as the recent checks show;
# since_at_least means every check we have agrees, so it began earlier.
since: datetime | None = None
since_at_least: bool = False
StatusSource = Callable[[object], Awaitable[list[StatusEntry]]]
@@ -158,6 +162,17 @@ async def _uptime_by_monitor(db, monitor_ids: list[str]) -> dict[str, dict]:
return out
def streak_start(results) -> tuple[datetime | None, bool]:
"""(checked_at of the first check in the latest run of the same result,
whether that run fills every check given). Results are oldest-first."""
if not results:
return None, False
i = len(results) - 1
while i > 0 and results[i - 1].is_up == results[-1].is_up:
i -= 1
return results[i].checked_at, i == 0
def _heartbeat_title(mtype: str, r) -> str:
"""Type-aware tooltip for one heartbeat cell."""
ts = f"{r.checked_at:%H:%M:%S} UTC"
@@ -191,6 +206,7 @@ async def monitor_status_source(db) -> list[StatusEntry]:
rows = recent.get(m.id, [])
latest = rows[-1] if rows else None
status = "pending" if latest is None else ("up" if latest.is_up else "down")
since, since_at_least = streak_start(rows)
tls_days = None
if latest and latest.tls_expires_at:
tls_days = (latest.tls_expires_at - now).total_seconds() / 86400.0
@@ -203,5 +219,6 @@ async def monitor_status_source(db) -> list[StatusEntry]:
latency_ms=latest.response_ms if latest else None,
spark=[r.response_ms for r in rows if r.response_ms is not None],
tls_days=tls_days, detail_url="/monitors/",
since=since, since_at_least=since_at_least,
))
return entries
+69 -2
View File
@@ -31,6 +31,10 @@
--orange-dim: color-mix(in srgb, var(--fs-warning) 20%, transparent);
--red: var(--fs-error-fg);
--red-dim: color-mix(in srgb, var(--fs-error) 20%, transparent);
/* Info (acknowledged): lighter than the house --fs-info-fg (50%), which falls
to 4.18:1 on its tint over a hovered row; 40% holds 5.09:1 there. */
--blue: color-mix(in srgb, var(--fs-info) 40%, var(--fs-text-primary));
--blue-dim: color-mix(in srgb, var(--fs-info) 20%, transparent);
--font-serif: var(--fs-font-display);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
@@ -101,7 +105,7 @@ code { font-family: var(--fs-font-mono); font-size: 0.875em; background: var(--b
.nav-scrim { display: none; }
/* Layout */
main { padding: 1.5rem 2rem; max-width: 1400px; margin: 0 auto; width: 100%; box-sizing: border-box; position: relative; z-index: 1; }
main { padding: 1.5rem 2rem; max-width: 1400px; margin: 0 auto; width: 100%; min-width: 0; box-sizing: border-box; position: relative; z-index: 1; }
/* Responsive: sidebar slides off-canvas under 900px, toggled by ☰ */
@media (max-width: 900px) {
@@ -143,7 +147,13 @@ main { padding: 1.5rem 2rem; max-width: 1400px; margin: 0 auto; width: 100%; box
/* Cards */
.card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 1.25rem; margin-bottom: 1rem; }
.card-flush { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; margin-bottom: 1rem; }
.card-flush { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; overflow-x: auto; overflow-y: hidden; margin-bottom: 1rem; }
/* A table wider than the screen scrolls sideways inside its card instead of
widening the page at phone width. Any card holding a table does this, so no
page has to remember a wrapper; .table-wrap is for a table outside a card.
(Only cards with a table: a plain card can hold a popover that must not clip.) */
.card:has(table.table) { overflow-x: auto; }
.table-wrap { overflow-x: auto; }
/* Typography */
.page-title { font-family: var(--font-serif); font-size: 1.6rem; font-weight: 500; color: var(--text); line-height: var(--fs-leading-heading); margin-bottom: 1.5rem; }
@@ -170,13 +180,25 @@ input[type=text], input[type=email], input[type=password], input[type=number], i
transition: border-color .15s;
}
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent); }
/* Keyboard focus is always visible: the design system's ring on anything
that takes focus. */
a:focus-visible, button:focus-visible, summary:focus-visible, [tabindex]:focus-visible,
input:focus-visible, select:focus-visible, textarea:focus-visible { outline: none; box-shadow: var(--fs-focus-ring); }
select { cursor: pointer; }
textarea { resize: vertical; }
/* Form fields: .field wraps a .field-label, the .input and an optional .field-hint. */
.field { display: flex; flex-direction: column; gap: 0.2rem; margin-bottom: 0.9rem; }
.field-label { display: block; margin-bottom: 0; font-size: 0.75rem; color: var(--text-muted); }
.field-hint { font-size: 0.75rem; color: var(--text-dim); }
.input { width: 100%; padding: 0.4rem 0.65rem; background: var(--bg); border: 1px solid var(--border-mid); border-radius: var(--fs-radius-sm); color: var(--text); font-size: 0.88rem; font-family: inherit; }
.input:focus { outline: none; border-color: var(--accent); }
.input:disabled { opacity: var(--fs-disabled-opacity); }
/* Buttons */
.btn { display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.45rem 1rem; background: var(--accent); color: var(--fs-surface-page); border: none; border-radius: 5px; cursor: pointer; font-size: 0.875rem; font-family: inherit; font-weight: 500; transition: background .15s; text-decoration: none; }
.btn:hover { background: var(--accent-hover); color: var(--fs-surface-page); }
.btn-sm { padding: 0.3rem 0.65rem; font-size: 0.8rem; }
.btn-icon { padding: 0.3rem 0.5rem; line-height: 1; }
.btn-ghost { background: transparent; color: var(--text-muted); border: 1px solid var(--border-mid); }
.btn-ghost:hover { background: var(--bg-elevated); color: var(--text); }
.btn-danger { background: var(--fs-action-destructive); color: var(--fs-text-on-action); }
@@ -197,9 +219,52 @@ textarea { resize: vertical; }
.dot-down { background: var(--red); }
.dot-warn { background: var(--yellow); }
.dot-dim { background: var(--text-dim); }
/* Tones the status_badge / status_dot macros draw (formatting.STATES). */
.badge-ok { background: var(--green-dim); color: var(--green); }
.badge-warn { background: var(--yellow-dim); color: var(--yellow); }
.badge-crit { background: var(--red-dim); color: var(--red); }
.badge-info { background: var(--blue-dim); color: var(--blue); }
.dot-ok { background: var(--green); }
.dot-crit { background: var(--red); }
.dot-info { background: var(--blue); }
.dot-label { margin-left: 0.35rem; font-size: 0.85rem; }
/* Text in a tone: status_class() results ('ok' / 'warn' / 'crit' / 'unknown')
and the STATES tones. */
.tone-ok { color: var(--green); }
.tone-warn { color: var(--yellow); }
.tone-crit { color: var(--red); }
.tone-info { color: var(--blue); }
.tone-dim, .tone-unknown { color: var(--text-dim); }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
/* Empty state */
.empty { color: var(--text-muted); font-size: 0.9rem; padding: 2rem; text-align: center; }
.empty-state { display: flex; flex-direction: column; align-items: center; gap: 0.75rem; color: var(--text-muted); font-size: 0.9rem; padding: 2rem 1rem; text-align: center; }
/* Page header: title on the left, its actions on the right (page_header macro). */
.page-header { display: flex; align-items: flex-start; justify-content: space-between; flex-wrap: wrap; gap: 0.75rem 1rem; margin-bottom: 1.5rem; }
.page-header .page-title { margin-bottom: 0; }
.page-subtitle { margin-top: 0.3rem; color: var(--text-muted); font-size: 0.9rem; }
.page-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
/* Stat tile (stat_tile macro): a label over one headline number. */
.stat-tile { display: flex; flex-direction: column; gap: 0.25rem; padding: 0.9rem 1rem; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--fs-radius-md); color: var(--text); min-width: 8rem; }
a.stat-tile:hover { border-color: var(--border-mid); background: var(--bg-elevated); color: var(--text); }
.stat-tile-label { font-size: var(--fs-size-tiny); font-weight: 500; color: var(--text-muted); text-transform: uppercase; letter-spacing: var(--fs-tracking-tiny); }
.stat-tile-value { font-size: 1.6rem; font-weight: 500; line-height: 1.1; font-variant-numeric: tabular-nums; }
.stat-tile-hint { font-size: 0.78rem; color: var(--text-dim); }
.stat-ok .stat-tile-value { color: var(--green); }
.stat-warn .stat-tile-value { color: var(--yellow); }
.stat-crit .stat-tile-value { color: var(--red); }
.stat-info .stat-tile-value { color: var(--blue); }
.stat-dim .stat-tile-value { color: var(--text-dim); }
.stat-strip { display: grid; grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr)); gap: 0.75rem; margin-bottom: 1rem; }
/* "Needs attention": what is wrong, first on a page. */
.problem-card { border-color: color-mix(in srgb, var(--fs-error) 45%, var(--border)); }
.problem-list { list-style: none; display: flex; flex-direction: column; gap: 0.45rem; }
.problem-list li { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.25rem 0.6rem; overflow-wrap: anywhere; }
.problem-detail { color: var(--text-muted); font-size: 0.82rem; }
/* Breadcrumbs */
/* Breadcrumb renders as a kicker line tucked directly above the page header
@@ -256,6 +321,8 @@ body.dash-editing .widget-drawer { transform:translateY(0); }
.pill { display:inline-block; width:7px; height:22px; border-radius:2px; cursor:default; transition:opacity .1s; }
.pill:hover { opacity:.75; }
.pill-empty { background:var(--bg-elevated); }
.pill-up { background:var(--green); }
.pill-down { background:var(--red); }
.ping-cur { min-width:72px; text-align:right; font-size:0.85rem; font-variant-numeric:tabular-nums; }
.ping-cur-good { color:var(--green); }
.ping-cur-warn { color:var(--yellow); }
+6 -2
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
from quart import Blueprint, current_app, render_template, request
from sqlalchemy import func, select
from steward.alerts.service import active_alerts
from steward.auth.middleware import require_role
from steward.core.status import collect_status, sparkline_svg
from steward.models.hosts import Host
@@ -27,15 +28,18 @@ async def index():
@status_bp.get("/rows")
@require_role(UserRole.viewer)
async def rows():
"""HTMX fragment — the full status list + summary, refreshed on poll."""
"""HTMX fragment — what is wrong (down monitors, active alerts), then the
full status list, refreshed on poll."""
async with current_app.db_sessionmaker() as db:
entries = await collect_status(db)
alerts = await active_alerts(db)
for e in entries:
if e.spark:
e.spark_svg = sparkline_svg(e.spark)
up, down, pending = _summarize(entries)
return await render_template(
"status/rows.html", entries=entries, up=up, down=down, pending=pending
"status/rows.html", entries=entries, up=up, down=down, pending=pending,
alerts=alerts, problems=[e for e in entries if e.status == "down"],
)
+67 -3
View File
@@ -17,6 +17,70 @@
</nav>
{%- endmacro %}
{# Metric threshold coloring moved to the `threshold_style(value, kind)` jinja
global (steward/core/settings.py + app.py), which reads operator-configurable
cutoffs from Settings → Thresholds. Call it as threshold_style(v, 'cpu'). #}
{# A state as a labelled badge: up, down, stale, firing, pending, acknowledged,
no_data, ok, warn, crit, resolved, inactive (formatting.STATES). `state` may
be a string or an enum; `label` replaces the standard wording. #}
{% macro status_badge(state, label=none, title=none) -%}
{%- set tone, text = status_info(state) -%}
<span class="badge badge-{{ tone }}"{% if title %} title="{{ title }}"{% endif %}>{{ label or text }}</span>
{%- endmacro %}
{# A state as a coloured dot. The dot is never the only signal: its label is
read out to a screen reader, and shown beside it when `show_label`. #}
{% macro status_dot(state, label=none, show_label=false) -%}
{%- set tone, text = status_info(state) -%}
<span class="dot dot-{{ tone }}" aria-hidden="true" title="{{ label or text }}"></span>
{%- if show_label %}<span class="dot-label">{{ label or text }}</span>{% else %}<span class="sr-only">{{ label or text }}</span>{% endif %}
{%- endmacro %}
{# Threshold colouring: `threshold_style(value, kind)` (an inline style) and
`status_class(value, kind)` ('ok' / 'warn' / 'crit' / 'unknown') are jinja
globals reading the cutoffs from Settings → Thresholds. #}
{# One headline number. `state` (a STATES name, or a status_class result)
colours the value; `href` makes the whole tile a link. #}
{% macro stat_tile(label, value, state=none, hint=none, href=none) -%}
{%- set tone = status_info(state)[0] if state else none -%}
{%- set body -%}
<span class="stat-tile-label">{{ label }}</span>
<span class="stat-tile-value">{{ value }}</span>
{%- if hint %}<span class="stat-tile-hint">{{ hint }}</span>{% endif %}
{%- endset -%}
{%- if href -%}
<a href="{{ href }}" class="stat-tile{% if tone %} stat-{{ tone }}{% endif %}">{{ body }}</a>
{%- else -%}
<div class="stat-tile{% if tone %} stat-{{ tone }}{% endif %}">{{ body }}</div>
{%- endif -%}
{%- endmacro %}
{# What a list shows when it has nothing in it, with an optional next step. #}
{% macro empty_state(message, action_label=none, action_href=none) -%}
<div class="empty-state">
<p>{{ message }}</p>
{%- if action_label and action_href %}<a class="btn btn-ghost btn-sm" href="{{ action_href }}">{{ action_label }}</a>{% endif %}
</div>
{%- endmacro %}
{# The page's <h1>, with its actions to the right:
{% call page_header("Alerts") %}<a class="btn" href="…">New rule</a>{% endcall %} #}
{% macro page_header(title, subtitle=none) -%}
<div class="page-header">
<div>
<h1 class="page-title">{{ title }}</h1>
{%- if subtitle %}<p class="page-subtitle">{{ subtitle }}</p>{% endif %}
</div>
{%- if caller is defined %}<div class="page-actions">{{ caller() }}</div>{% endif %}
</div>
{%- endmacro %}
{# A button that shows only an icon. The label is required: it is the button's
accessible name and its tooltip, and rendering one without it raises. #}
{% macro icon_button(icon, label, href=none, cls="btn-ghost") -%}
{%- set name = required(label, "icon_button label") -%}
{%- if href -%}
<a class="btn btn-sm btn-icon {{ cls }}" href="{{ href }}" aria-label="{{ name }}" title="{{ name }}">{{ icon }}</a>
{%- else -%}
<button type="{{ kwargs.get('type', 'button') }}" class="btn btn-sm btn-icon {{ cls }}" aria-label="{{ name }}" title="{{ name }}"
{%- if kwargs.get('confirm') %} data-confirm="{{ kwargs.confirm }}"{% endif %}>{{ icon }}</button>
{%- endif -%}
{%- endmacro %}
+1 -1
View File
@@ -10,6 +10,6 @@
<button type="button"
class="range-btn{% if current_range == key %} active{% endif %}"
data-range="{{ key }}"
onclick="setTimeRange('{{ key }}')">{{ key }}</button>
onclick="setTimeRange(this.dataset.range)">{{ key }}</button>
{% endfor %}
</div>
+1 -1
View File
@@ -59,7 +59,7 @@
<td class="td-actions">
{% if row.state == 'active' %}
<form method="post" action="/ai-access/tokens/{{ t.id }}/revoke" style="margin:0;"
onsubmit='return confirm({{ ("Revoke “" ~ t.name ~ "”? Any client using it stops working immediately.")|tojson }})'>{{ csrf_field() }}
data-confirm="Revoke “{{ t.name }}”? Any client using it stops working immediately.">{{ csrf_field() }}
<button type="submit" class="btn btn-danger btn-sm">Revoke</button>
</form>
{% endif %}
+4 -7
View File
@@ -1,6 +1,5 @@
{# alerts/_history_rows.html — the history table; swapped in on a range or rule change #}
{% set state_badge = {"firing": "badge-red", "pending": "badge-yellow", "acknowledged": "badge-gold",
"resolved": "badge-green", "inactive": "badge-dim"} %}
{% from "_macros.html" import status_badge, empty_state %}
{% if events %}
<div class="card-flush" style="overflow-x:auto;">
<table class="table">
@@ -25,9 +24,9 @@
<div style="font-size:0.78rem;color:var(--text-dim);">{{ ev.rule.source_module }}/{{ ev.rule.resource_name }}</div>
</td>
<td style="white-space:nowrap;">
<span class="badge {{ state_badge.get(ev.from_state, 'badge-dim') }}">{{ ev.from_state }}</span>
{{ status_badge(ev.from_state) }}
<span style="color:var(--text-dim);">→</span>
<span class="badge {{ state_badge.get(ev.to_state, 'badge-dim') }}">{{ ev.to_state }}</span>
{{ status_badge(ev.to_state) }}
{% for n in ev.notes %}
<div style="font-size:0.78rem;color:var(--text-muted);margin-top:0.25rem;white-space:normal;">{{ n }}</div>
{% endfor %}
@@ -69,7 +68,5 @@
</p>
{% endif %}
{% else %}
<p style="color:var(--text-muted);">
No alert changed state in the last {{ current_range }}{% if chosen %} for {{ chosen.name }}{% endif %}.
</p>
<div class="card">{{ empty_state("No alert changed state in the last " ~ current_range ~ ((" for " ~ chosen.name) if chosen else "") ~ ".") }}</div>
{% endif %}
+1 -1
View File
@@ -12,7 +12,7 @@
<select name="resource_name" required disabled>
<option value="">No data yet — run monitors first</option>
</select>
<p style="font-size:0.78rem;color:var(--text-muted);margin-top:0.25rem;">
<p class="field-hint">
Resource names populate from live metric data. Start the scheduler and try again.
</p>
{% endif %}
+4 -12
View File
@@ -1,12 +1,10 @@
{% extends "base.html" %}
{% from "_macros.html" import crumbs %}
{% from "_macros.html" import crumbs, page_header %}
{% block title %}Alert History — Steward{% endblock %}
{% block breadcrumb %}{{ crumbs([("Alerts", "/alerts/"), ("History", "")]) }}{% endblock %}
{% block content %}
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:0.75rem;margin-bottom:1.25rem;">
<h1 class="page-title" style="margin-bottom:0;">Alert History</h1>
<div style="display:flex;align-items:center;gap:0.75rem;flex-wrap:wrap;">
<select id="history-rule" name="rule" aria-label="Rule"
{% call page_header("Alert history", "Every change of state, newest first: the value against the rule's condition, whether the email and webhook went out, and the Ansible run an action started.") %}
<select id="history-rule" name="rule" aria-label="Rule" class="input" style="width:auto;"
hx-get="/alerts/history" hx-target="#history-rows" hx-include="#time-range"
hx-trigger="change" hx-push-url="false"
onchange="var u=new URL(window.location); this.value ? u.searchParams.set('rule', this.value) : u.searchParams.delete('rule'); history.replaceState({}, '', u);">
@@ -16,13 +14,7 @@
{% endfor %}
</select>
{% include "_time_range.html" %}
</div>
</div>
<p style="color:var(--text-muted);font-size:0.88rem;margin-bottom:1.25rem;">
Every change of state, newest first: what the value was against the rule's condition, whether
the email and webhook went out, and the Ansible run an action started.
</p>
{% endcall %}
<div id="history-rows"
hx-get="/alerts/history" hx-trigger="rangeChange from:body"
+13 -22
View File
@@ -1,7 +1,12 @@
{% extends "base.html" %}
{% block title %}Alerts — Steward{% endblock %}
{% from "_macros.html" import page_header, status_badge, empty_state %}
{% block content %}
<h1 class="page-title">Alerts</h1>
{% call page_header("Alerts") %}
<a class="btn btn-ghost" href="/alerts/history">History</a>
<a class="btn btn-ghost" href="/alerts/maintenance">Maintenance</a>
<a class="btn" href="/alerts/rules/new">New rule</a>
{% endcall %}
{% if active %}
<h2 class="section-title" style="margin-bottom:1rem;">Active</h2>
@@ -20,18 +25,11 @@
{% for state, rule in active %}
<tr>
<td>
{% if state.state.value == 'firing' %}
<span class="badge badge-red">FIRING</span>
{% elif state.state.value == 'acknowledged' %}
<span class="badge badge-yellow">ACK</span>
{% elif state.state.value == 'pending' %}
<span class="badge badge-dim">PENDING</span>
{% endif %}
{{ status_badge(state.state) }}
{% if state.no_data_since %}
<span class="badge badge-yellow"
title="No sample since {{ state.last_sample_at.strftime('%Y-%m-%d %H:%M UTC') if state.last_sample_at else 'the rule started watching' }}">NO DATA</span>
{{ status_badge('no_data', title="No sample since " ~ (state.last_sample_at.strftime('%Y-%m-%d %H:%M UTC') if state.last_sample_at else 'the rule started watching')) }}
<div style="font-size:0.72rem;color:var(--text-dim);margin-top:0.2rem;">
since {{ state.no_data_since.strftime("%Y-%m-%d %H:%M") }} UTC
since {{ state.no_data_since|ago }}
</div>
{% endif %}
</td>
@@ -51,17 +49,10 @@
</table>
</div>
{% else %}
<p style="color:var(--text-muted);margin-bottom:2rem;">No active alerts.</p>
<div class="card" style="margin-bottom:2rem;">{{ empty_state("No active alerts. Every rule is quiet.") }}</div>
{% endif %}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem;">
<h2 class="section-title">Rules</h2>
<div style="display:flex;gap:0.5rem;">
<a class="btn btn-ghost" href="/alerts/history">History</a>
<a class="btn btn-ghost" href="/alerts/maintenance">Maintenance</a>
<a class="btn" href="/alerts/rules/new">New Rule</a>
</div>
</div>
<h2 class="section-title" style="margin-bottom:1rem;">Rules</h2>
{% if rules %}
<div class="card-flush">
<table class="table">
@@ -99,7 +90,7 @@
</form>
<form method="post" action="/alerts/rules/{{ rule.id }}/delete" style="display:inline;">{{ csrf_field() }}
<button type="submit" class="btn btn-sm btn-danger"
onclick="return confirm('Delete rule {{ rule.name }}?')">Delete</button>
data-confirm="Delete rule {{ rule.name }}?">Delete</button>
</form>
</td>
</tr>
@@ -108,6 +99,6 @@
</table>
</div>
{% else %}
<p style="color:var(--text-muted);">No alert rules configured.</p>
<div class="card">{{ empty_state("No alert rules yet.", "New rule", "/alerts/rules/new") }}</div>
{% endif %}
{% endblock %}
+9 -24
View File
@@ -1,20 +1,11 @@
{% extends "base.html" %}
{% from "_macros.html" import crumbs %}
{% from "_macros.html" import crumbs, page_header, status_badge, empty_state %}
{% block title %}Maintenance Windows — Steward{% endblock %}
{% block breadcrumb %}{{ crumbs([("Alerts", "/alerts/"), ("Maintenance", "")]) }}{% endblock %}
{% block content %}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;">
<h1 class="page-title" style="margin-bottom:0;">Maintenance Windows</h1>
<div style="display:flex;gap:0.5rem;">
<a class="btn" href="/alerts/maintenance/new">New Window</a>
</div>
</div>
<p style="color:var(--text-muted);font-size:0.88rem;margin-bottom:1.5rem;">
During an active window, alerts in its scope still change state and show on the Alerts page,
but send no notifications and run no Ansible actions. Each event a window mutes says so in
the alert's history. An alert that is still firing when the window ends notifies then, and runs its action.
</p>
{% call page_header("Maintenance windows", "During an active window, alerts in its scope still change state and show on the Alerts page, but send no notifications and run no Ansible actions. Each event a window mutes says so in the alert's history. An alert that is still firing when the window ends notifies then, and runs its action.") %}
<a class="btn" href="/alerts/maintenance/new">New window</a>
{% endcall %}
{% if windows %}
<div class="card-flush">
@@ -51,18 +42,14 @@
{{ w.end_at.strftime("%Y-%m-%d %H:%M") }} UTC
</td>
<td style="text-align:center;">
{% if is_active %}
<span class="badge badge-yellow">active</span>
{% elif is_upcoming %}
<span class="badge badge-dim">upcoming</span>
{% else %}
<span style="color:var(--text-dim);font-size:0.8rem;">expired</span>
{% endif %}
{% if is_active %}{{ status_badge('warn', 'Active') }}
{% elif is_upcoming %}{{ status_badge('pending', 'Upcoming') }}
{% else %}{{ status_badge('inactive', 'Expired') }}{% endif %}
</td>
<td class="td-actions">
<form method="post" action="/alerts/maintenance/{{ w.id }}/delete" style="display:inline;">{{ csrf_field() }}
<button type="submit" class="btn btn-sm btn-danger"
onclick="return confirm('Delete window &quot;{{ w.name }}&quot;?')">Delete</button>
data-confirm="Delete window &quot;{{ w.name }}&quot;?">Delete</button>
</form>
</td>
</tr>
@@ -71,8 +58,6 @@
</table>
</div>
{% else %}
<div class="card" style="text-align:center;padding:3rem;">
<p style="color:var(--text-muted);">No maintenance windows configured.</p>
</div>
<div class="card">{{ empty_state("No maintenance windows yet.", "New window", "/alerts/maintenance/new") }}</div>
{% endif %}
{% endblock %}
@@ -49,7 +49,7 @@
<label>Resource Name</label>
<input type="text" name="scope_resource" id="scope-resource"
placeholder="Exact resource name (e.g. my-server)">
<p style="font-size:0.78rem;color:var(--text-muted);margin-top:0.25rem;">
<p class="field-hint">
Leave blank to mute all resources within the selected source.
</p>
</div>
+5 -5
View File
@@ -47,7 +47,7 @@
<select name="resource_name" required disabled>
<option value="">No data yet — run monitors first</option>
</select>
<p style="font-size:0.78rem;color:var(--text-muted);margin-top:0.25rem;">
<p class="field-hint">
Resource names populate from live metric data. Start the scheduler and try again.
</p>
{% endif %}
@@ -94,7 +94,7 @@
<label>Consecutive OK checks before it clears</label>
<input type="number" name="consecutive_ok_required" min="1"
value="{{ rule.consecutive_ok_required if rule else 1 }}">
<p style="font-size:0.75rem;color:var(--text-muted);margin-top:0.2rem;">
<p class="field-hint">
A firing or acknowledged alert resolves only after this many checks in a row are back
within the threshold. An acknowledgement holds until then.</p>
</div>
@@ -105,7 +105,7 @@
{% if not rule or rule.notify_no_data %}checked{% endif %}>
Notify when this metric stops reporting
</label>
<p style="font-size:0.75rem;color:var(--text-muted);margin-top:0.2rem;">
<p class="field-hint">
Silence is always shown as NO DATA on the Alerts page: no sample for three of the
metric's usual gaps (at least 5 minutes). This also sends a notification when it starts
and when data resumes. A firing alert that goes silent stays firing.</p>
@@ -147,7 +147,7 @@
} %}
{% for src, pairs in descs.items() %}
<div>
<div style="font-size:0.78rem;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.05em;margin-bottom:0.2rem;">{{ src }}</div>
<div style="font-size:0.78rem;font-weight:500;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.05em;margin-bottom:0.2rem;">{{ src }}</div>
{% for metric, desc in pairs %}
<div style="display:grid;grid-template-columns:160px 1fr;gap:0.5rem;font-size:0.78rem;padding:0.15rem 0;">
<code>{{ metric }}</code>
@@ -163,7 +163,7 @@
{% if session.get("user_role") == "admin" %}
{% set act = rule.ansible_action if rule and rule.ansible_action else None %}
<div style="margin:1.25rem 0;padding:1rem;border:1px solid var(--border-mid);border-radius:6px;background:var(--bg-elevated);">
<label style="display:flex;align-items:center;gap:0.5rem;font-weight:600;">
<label style="display:flex;align-items:center;gap:0.5rem;font-weight:500;">
<input type="checkbox" name="ansible_enabled" {% if act %}checked{% endif %}>
On firing → run an Ansible playbook
</label>
+1 -1
View File
@@ -28,7 +28,7 @@
{% if view_editable and session.user_role == 'admin' %}
<a href="/ansible/playbooks/edit/{{ view_source }}/{{ view_path }}" class="btn btn-sm">Edit</a>
<form method="post" action="/ansible/playbooks/delete" style="display:inline;"
onsubmit="return confirm('Delete {{ view_path }}?');">{{ csrf_field() }}
data-confirm="Delete {{ view_path }}?">{{ csrf_field() }}
<input type="hidden" name="source_name" value="{{ view_source }}">
<input type="hidden" name="playbook_path" value="{{ view_path }}">
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
@@ -40,7 +40,7 @@
<a href="/ansible/inventory/groups/{{ grp.id }}" class="btn btn-sm btn-ghost">Edit</a>
<form method="post" action="/ansible/inventory/groups/{{ grp.id }}/delete" style="display:inline;">{{ csrf_field() }}
<button type="submit" class="btn btn-sm btn-danger"
onclick="return confirm('Delete group {{ grp.name }}?')">×</button>
data-confirm="Delete group {{ grp.name }}?">×</button>
</form>
</td>
</tr>
@@ -49,7 +49,7 @@
<a href="/ansible/inventory/targets/{{ tgt.id }}" class="btn btn-sm btn-ghost">Edit</a>
<form method="post" action="/ansible/inventory/targets/{{ tgt.id }}/delete" style="display:inline;">{{ csrf_field() }}
<button type="submit" class="btn btn-sm btn-danger"
onclick="return confirm('Delete target {{ tgt.name }}?')">×</button>
data-confirm="Delete target {{ tgt.name }}?">×</button>
</form>
</td>
</tr>
@@ -54,7 +54,7 @@
{% if editing %}
<form method="post" action="/ansible/playbooks/delete" style="margin-top:1rem;"
onsubmit="return confirm('Delete {{ playbook_path }}?');">{{ csrf_field() }}
data-confirm="Delete {{ playbook_path }}?">{{ csrf_field() }}
<input type="hidden" name="source_name" value="{{ source_name }}">
<input type="hidden" name="playbook_path" value="{{ playbook_path }}">
<button type="submit" class="btn btn-danger btn-sm">Delete playbook</button>
+1 -1
View File
@@ -7,7 +7,7 @@
<h1 class="page-title" style="margin-bottom:0;">Run Detail</h1>
<div style="margin-left:auto;display:flex;gap:0.5rem;">
{% if run.status.value in ("running", "queued") and session.user_role in ("operator", "admin") %}
<form method="post" action="/ansible/runs/{{ run.id }}/cancel" onsubmit="return confirm('Cancel this run?');">{{ csrf_field() }}
<form method="post" action="/ansible/runs/{{ run.id }}/cancel" data-confirm="Cancel this run?">{{ csrf_field() }}
<button type="submit" class="btn btn-sm btn-danger">Cancel run</button>
</form>
{% endif %}
+1 -1
View File
@@ -62,7 +62,7 @@
</form>
<a href="?edit={{ s.id }}#form" class="btn btn-sm btn-ghost">Edit</a>
<form method="post" action="/ansible/schedules/{{ s.id }}/delete" style="display:inline;"
onsubmit="return confirm('Delete schedule {{ s.name }}?');">{{ csrf_field() }}
data-confirm="Delete schedule {{ s.name }}?">{{ csrf_field() }}
<button class="btn btn-sm btn-danger" type="submit">Delete</button>
</form>
</td>
+7
View File
@@ -10,6 +10,13 @@
document.addEventListener('htmx:configRequest', function (ev) {
ev.detail.headers['X-CSRF-Token'] = document.querySelector('meta[name="csrf-token"]').content;
});
// A form (or its submit button) with data-confirm="…" asks before it
// submits. The prompt is attribute text read through dataset, never
// parsed as JavaScript, so a name inside it cannot run as script (#5099).
document.addEventListener('submit', function (ev) {
var ask = (ev.submitter && ev.submitter.dataset.confirm) || ev.target.dataset.confirm;
if (ask && !window.confirm(ask)) { ev.preventDefault(); ev.stopPropagation(); }
}, true);
</script>
<title>{% block title %}Steward{% endblock %}</title>
{# Every asset is served by Steward itself: it runs with no outbound network (rule 164). #}
+1 -1
View File
@@ -57,7 +57,7 @@
{% if my_dashboards | length > 1 %}
<form method="post" action="/dashboards/{{ dash.id }}/delete" style="margin:0;"
onsubmit="return confirm('Delete \'{{ dash.name }}\'?')">{{ csrf_field() }}
data-confirm="Delete '{{ dash.name }}'?">{{ csrf_field() }}
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
</form>
{% endif %}
+3 -1
View File
@@ -1,5 +1,7 @@
{% extends "base.html" %}
{% from "_macros.html" import crumbs %}
{% block title %}Share: {{ dashboard.name }} — Steward{% endblock %}
{% block breadcrumb %}{{ crumbs([("Dashboard", "/"), (dashboard.name, "/d/" ~ dashboard.id), ("Share", "")]) }}{% endblock %}
{% block content %}
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:1.5rem;">
<h1 class="page-title" style="margin-bottom:0;">Share: {{ dashboard.name }}</h1>
@@ -32,7 +34,7 @@
</div>
</div>
<form method="post" action="/d/{{ dashboard.id }}/share/revoke/{{ token.id }}"
style="margin:0;" onsubmit="return confirm('Revoke this share link?')">{{ csrf_field() }}
style="margin:0;" data-confirm="Revoke this share link?">{{ csrf_field() }}
<button type="submit" class="btn btn-danger btn-sm">Revoke</button>
</form>
</div>
@@ -1,5 +1,7 @@
{% extends "base.html" %}
{% from "_macros.html" import crumbs %}
{% block title %}Share Link Created — Steward{% endblock %}
{% block breadcrumb %}{{ crumbs([("Dashboard", "/"), (dashboard.name, "/d/" ~ dashboard.id), ("Share", "/d/" ~ dashboard.id ~ "/share"), ("Link created", "")]) }}{% endblock %}
{% block content %}
<div style="max-width:640px;">
<h1 class="page-title">Share Link Created</h1>
+20 -69
View File
@@ -7,81 +7,34 @@
<script src="{{ url_for('static', filename='vendor/htmx-2.0.4.min.js') }}"></script>
<script src="{{ url_for('static', filename='vendor/chart-4.4.4.umd.min.js') }}"></script>
<link rel="stylesheet" href="{{ url_for('static', filename='fonts/fonts.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='theme.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='steward.css') }}">
{# The palette, type and widget classes come from steward.css like every
other page; only the read-only page's own frame and its grid live here. #}
<style>
:root {
--bg: #07071a; --bg-card: #0f0f24; --bg-elevated: #17172e;
--border: #1e1e3e; --border-mid: #28284c;
--text: #d2cef0; --text-muted: #6858a0; --text-dim: #282860;
--accent: #7c5ef4; --gold: #c9a84c;
--green: #26d96b; --green-dim: #0e3a1e;
--yellow: #e6b818; --red: #e83848; --red-dim: #3a0e14;
--orange: #e07828;
--font-serif: 'Fraunces', Georgia, serif;
.share-header { background: var(--bg-card); padding: 0 1.5rem; display: flex; align-items: center; justify-content: space-between; gap: 1rem; border-bottom: 1px solid var(--border-mid); min-height: 48px; }
.share-name { font-family: var(--fs-font-display); font-weight: 500; font-size: 1.15rem; color: var(--gold); overflow-wrap: anywhere; }
.share-main { max-width: 1600px; }
/* Same grid as the authenticated view, rendered from x/y/w/h. */
.dash-grid { display: grid; grid-template-columns: repeat(12, 1fr); grid-auto-rows: 70px; gap: 1rem; }
.dash-grid > .dash-cell { margin-bottom: 0; min-width: 0; overflow: hidden; display: flex; flex-direction: column; }
.dash-cell .dash-cell-body { flex: 1; min-height: 0; overflow: auto; }
@media (max-width: 820px) {
.dash-grid { grid-template-columns: 1fr; grid-auto-rows: auto; }
.dash-grid > .dash-cell { grid-column: 1 / -1 !important; grid-row: auto !important; }
.dash-cell .dash-cell-body { overflow: visible; }
.share-main { padding: 1rem; }
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, -apple-system, sans-serif; background: var(--bg); color: var(--text); line-height: 1.5; }
a { color: var(--accent); text-decoration: none; }
code { font-family: ui-monospace, monospace; font-size: 0.875em; background: var(--bg-elevated); padding: 0.1em 0.35em; border-radius: 3px; }
.card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 1.25rem; margin-bottom: 1rem; }
.card-flush { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; margin-bottom: 1rem; }
.section-title { font-family: var(--font-serif); font-size: 0.8rem; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.06em; }
.table { width: 100%; border-collapse: collapse; }
.table th { text-align: left; padding: 0.6rem 1rem; color: var(--text-muted); font-weight: 500; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.04em; border-bottom: 1px solid var(--border-mid); }
.table td { padding: 0.7rem 1rem; border-bottom: 1px solid var(--border); font-size: 0.9rem; }
.table tbody tr:last-child td { border-bottom: none; }
.table tbody tr:hover td { background: var(--bg-elevated); }
.badge { display: inline-block; padding: 0.2em 0.55em; border-radius: 4px; font-size: 0.75rem; font-weight: 600; letter-spacing: 0.03em; }
.badge-green { background: var(--green-dim); color: var(--green); }
.badge-red { background: var(--red-dim); color: #ff8090; }
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.dot-up { background: var(--green); box-shadow: 0 0 4px var(--green); }
.dot-down { background: var(--red); }
.dot-warn { background: var(--yellow); }
.dot-dim { background: var(--text-dim); }
.ping-card { padding: 0.75rem 1.25rem; }
.ping-row { display:flex; align-items:center; gap:1rem; padding:0.45rem 0; border-bottom:1px solid var(--border); }
.ping-row:last-child { border-bottom:none; }
.ping-meta { min-width:120px; max-width:180px; flex-shrink:1; overflow:hidden; }
.ping-name { font-size:0.875rem; font-weight:500; display:block; overflow-wrap:anywhere; }
.ping-addr { display:block; color:var(--text-muted); font-size:0.75rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.ping-pills { display:flex; gap:2px; flex:1; min-width:0; overflow:hidden; align-items:center; }
.pill { display:inline-block; width:7px; height:22px; border-radius:2px; }
.pill-empty { background:var(--bg-elevated); }
.ping-cur { min-width:72px; text-align:right; font-size:0.85rem; font-variant-numeric:tabular-nums; }
.ping-cur-good { color:var(--green); } .ping-cur-warn { color:var(--yellow); }
.ping-cur-bad { color:var(--orange); } .ping-cur-down { color:var(--red); font-weight:bold; }
.ping-cur-nd { color:var(--text-dim); }
.widget-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:0.75rem; }
.widget-title { font-family:var(--font-serif); font-size:0.8rem; font-weight:600; color:var(--text-muted); text-transform:uppercase; letter-spacing:0.06em; }
/* Same Grafana-style grid as the authenticated view, rendered from x/y/w/h. */
.dash-grid { display:grid; grid-template-columns:repeat(12,1fr); grid-auto-rows:70px; gap:1rem; }
.dash-grid > .dash-cell { margin-bottom:0; min-width:0; overflow:hidden; display:flex; flex-direction:column; }
.dash-cell .dash-cell-body { flex:1; min-height:0; overflow:auto; }
.dash-cell-body { container-type:inline-size; }
.host-blocks { display:grid; grid-template-columns:1fr; gap:0 1.5rem; }
@container (min-width:520px) { .host-blocks { grid-template-columns:1fr 1fr; } }
@container (min-width:900px) { .host-blocks { grid-template-columns:1fr 1fr 1fr; } }
@media (max-width:820px) {
.dash-grid { grid-template-columns:1fr; grid-auto-rows:auto; }
.dash-grid > .dash-cell { grid-column:1 / -1 !important; grid-row:auto !important; }
.dash-cell .dash-cell-body { overflow:visible; }
}
.stat-val { font-size: 2rem; font-weight: 700; line-height: 1; }
.stat-label { font-size: 0.8rem; color: var(--text-muted); margin-left: 0.3rem; }
header { background: var(--bg-card); padding: 0 1.5rem; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--border-mid); height: 48px; font-family: var(--font-serif); }
main { padding: 1.5rem 2rem; max-width: 1600px; margin: 0 auto; }
</style>
</head>
<body>
<header>
<span style="font-weight:500;font-size:1.15rem;color:var(--gold);">
{{ dashboard.name }}
</span>
<header class="share-header">
<span class="share-name">{{ dashboard.name }}</span>
<span style="font-size:0.78rem;color:var(--text-muted);">Steward · read-only view</span>
</header>
<main>
<main class="share-main">
{% if widgets %}
<div class="dash-grid">
{% for item in widgets %}
@@ -101,9 +54,7 @@
{% endfor %}
</div>
{% else %}
<div style="color:var(--text-muted);text-align:center;padding:4rem;font-size:0.9rem;">
This dashboard has no widgets.
</div>
<div class="empty-state">This dashboard has no widgets.</div>
{% endif %}
</main>
+10 -12
View File
@@ -16,15 +16,13 @@
<a href="/hosts/{{ host.id }}/edit" class="btn btn-ghost btn-sm">Edit</a>
{% if session.user_role == 'admin' %}
<form method="post" action="/hosts/{{ host.id }}/delete" style="margin:0;"
onsubmit="return confirm('Delete host {{ host.name }}? Monitors and history are removed.');">{{ csrf_field() }}
data-confirm="Delete host {{ host.name }}? Monitors and history are removed.">{{ csrf_field() }}
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
</form>
{% endif %}
</div>
</div>
{% set inp = "width:100%;padding:0.4rem 0.65rem;background:var(--bg);border:1px solid var(--border-mid);border-radius:4px;color:var(--text);font-size:0.88rem;" %}
{% set lbl = "font-size:0.75rem;color:var(--text-muted);display:block;margin-bottom:0.2rem;" %}
{# ── Live vitals strip (host_agent fragment) — full width on top, polled ────── #}
<div id="hv-strip"
@@ -78,7 +76,7 @@
<div style="display:flex;gap:0.35rem;flex-shrink:0;">
<a href="/monitors/{{ m.id }}/edit" class="btn btn-sm btn-ghost" style="padding:0.2rem 0.5rem;font-size:0.72rem;">Edit</a>
<form method="post" action="/monitors/{{ m.id }}/delete" style="display:inline;"
onsubmit="return confirm('Delete monitor {{ m.name }}?');">{{ csrf_field() }}
data-confirm="Delete monitor {{ m.name }}?">{{ csrf_field() }}
<button type="submit" class="btn btn-sm" style="padding:0.2rem 0.5rem;font-size:0.72rem;color:var(--red);">✕</button>
</form>
</div>
@@ -98,24 +96,24 @@
<input type="hidden" name="host_id" value="{{ host.id }}">
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:0.6rem;align-items:end;">
<div>
<label style="{{ lbl }}">Type</label>
<select name="type" id="mtype" onchange="mtoggle(this.value)" style="{{ inp }}">
<label class="field-label">Type</label>
<select name="type" id="mtype" onchange="mtoggle(this.value)" class="input">
{% for t in monitor_types %}
<option value="{{ t.value }}" {% if t.value == 'icmp' %}selected{% endif %}>{{ t.value | upper }}</option>
{% endfor %}
</select>
</div>
<div>
<label style="{{ lbl }}">Name</label>
<input type="text" name="name" placeholder="optional" autocomplete="off" style="{{ inp }}">
<label class="field-label">Name</label>
<input type="text" name="name" placeholder="optional" autocomplete="off" class="input">
</div>
<div>
<label style="{{ lbl }}">Target</label>
<input type="text" name="target" value="{{ host.address }}" autocomplete="off" required style="{{ inp }}">
<label class="field-label">Target</label>
<input type="text" name="target" value="{{ host.address }}" autocomplete="off" required class="input">
</div>
<div>
<label style="{{ lbl }}">Interval (s, 0=global)</label>
<input type="number" name="check_interval_seconds" value="0" min="0" style="{{ inp }}">
<label class="field-label">Interval (s, 0=global)</label>
<input type="number" name="check_interval_seconds" value="0" min="0" class="input">
</div>
{{ type_fields() }}
</div>
+1 -1
View File
@@ -80,7 +80,7 @@
<a href="/hosts/{{ host.id }}/edit" class="btn btn-sm btn-ghost" style="margin-right:0.5rem;">Edit</a>
<form method="post" action="/hosts/{{ host.id }}/delete" style="display:inline;">{{ csrf_field() }}
<button type="submit" class="btn btn-sm btn-danger"
onclick="return confirm('Delete {{ host.name }}?')">Delete</button>
data-confirm="Delete {{ host.name }}?">Delete</button>
</form>
</td>
</tr>
+12 -14
View File
@@ -1,45 +1,43 @@
{# Shared type-specific monitor form fields. Every field is rendered; a tiny
script (mtoggle) shows only the ones relevant to the selected type. #}
{% macro type_fields(config={}) %}
{% set inp = "width:100%;padding:0.4rem 0.65rem;background:var(--bg);border:1px solid var(--border-mid);border-radius:4px;color:var(--text);font-size:0.88rem;" %}
{% set lbl = "font-size:0.75rem;color:var(--text-muted);display:block;margin-bottom:0.2rem;" %}
{# tcp #}
<div class="mfield" data-types="tcp">
<label style="{{ lbl }}">Port</label>
<input type="number" name="port" value="{{ config.get('port', 80) }}" min="1" max="65535" style="{{ inp }}">
<label class="field-label">Port</label>
<input type="number" name="port" value="{{ config.get('port', 80) }}" min="1" max="65535" class="input">
</div>
{# dns #}
<div class="mfield" data-types="dns">
<label style="{{ lbl }}">Expected IP <span style="color:var(--text-dim);">(optional)</span></label>
<label class="field-label">Expected IP <span style="color:var(--text-dim);">(optional)</span></label>
<input type="text" name="expected_ip" value="{{ config.get('expected_ip') or '' }}"
placeholder="e.g. 192.0.2.10" autocomplete="off" style="{{ inp }}">
placeholder="e.g. 192.0.2.10" autocomplete="off" class="input">
</div>
{# http #}
<div class="mfield" data-types="http">
<label style="{{ lbl }}">Method</label>
<select name="method" style="{{ inp }}">
<label class="field-label">Method</label>
<select name="method" class="input">
{% for opt in ["GET", "HEAD", "POST"] %}
<option value="{{ opt }}" {% if config.get('method', 'GET') == opt %}selected{% endif %}>{{ opt }}</option>
{% endfor %}
</select>
</div>
<div class="mfield" data-types="http">
<label style="{{ lbl }}">Expected status</label>
<label class="field-label">Expected status</label>
<input type="number" name="expected_status" value="{{ config.get('expected_status', 200) }}"
min="100" max="599" style="{{ inp }}">
min="100" max="599" class="input">
</div>
<div class="mfield" data-types="http">
<label style="{{ lbl }}">Timeout (s)</label>
<label class="field-label">Timeout (s)</label>
<input type="number" name="timeout_seconds" value="{{ config.get('timeout_seconds', 10) }}"
min="1" max="60" style="{{ inp }}">
min="1" max="60" class="input">
</div>
<div class="mfield" data-types="http">
<label style="{{ lbl }}">Content match <span style="color:var(--text-dim);">(substring)</span></label>
<label class="field-label">Content match <span style="color:var(--text-dim);">(substring)</span></label>
<input type="text" name="content_match" value="{{ config.get('content_match', '') }}"
placeholder="optional" autocomplete="off" style="{{ inp }}">
placeholder="optional" autocomplete="off" class="input">
</div>
<div class="mfield" data-types="http" style="display:flex;flex-direction:column;gap:0.3rem;justify-content:flex-end;">
<label style="display:flex;align-items:center;gap:0.4rem;font-size:0.82rem;cursor:pointer;">
+8 -10
View File
@@ -8,27 +8,25 @@
<h1 class="page-title" style="margin-bottom:0;">Edit Monitor</h1>
</div>
{% set inp = "width:100%;padding:0.4rem 0.65rem;background:var(--bg);border:1px solid var(--border-mid);border-radius:4px;color:var(--text);font-size:0.88rem;" %}
{% set lbl = "font-size:0.75rem;color:var(--text-muted);display:block;margin-bottom:0.2rem;" %}
<div class="card" style="max-width:760px;">
<form method="post" action="/monitors/{{ monitor.id }}/edit" style="display:grid;gap:0.6rem;">{{ csrf_field() }}
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:0.6rem;align-items:end;">
<div>
<label style="{{ lbl }}">Type</label>
<input type="text" value="{{ monitor.type | upper }}" disabled style="{{ inp }}opacity:0.7;">
<label class="field-label">Type</label>
<input type="text" value="{{ monitor.type | upper }}" disabled class="input">
</div>
<div>
<label style="{{ lbl }}">Name</label>
<input type="text" name="name" value="{{ monitor.name }}" autocomplete="off" style="{{ inp }}">
<label class="field-label">Name</label>
<input type="text" name="name" value="{{ monitor.name }}" autocomplete="off" class="input">
</div>
<div>
<label style="{{ lbl }}">Target <span style="color:var(--red);">*</span></label>
<input type="text" name="target" value="{{ monitor.target }}" autocomplete="off" required style="{{ inp }}">
<label class="field-label">Target <span style="color:var(--red);">*</span></label>
<input type="text" name="target" value="{{ monitor.target }}" autocomplete="off" required class="input">
</div>
<div>
<label style="{{ lbl }}">Interval (s, 0=global)</label>
<input type="number" name="check_interval_seconds" value="{{ monitor.check_interval_seconds }}" min="0" style="{{ inp }}">
<label class="field-label">Interval (s, 0=global)</label>
<input type="number" name="check_interval_seconds" value="{{ monitor.check_interval_seconds }}" min="0" class="input">
</div>
{{ type_fields(config) }}
</div>
+8 -10
View File
@@ -10,8 +10,6 @@
{% include "_time_range.html" %}
</div>
{% set inp = "width:100%;padding:0.4rem 0.65rem;background:var(--bg);border:1px solid var(--border-mid);border-radius:4px;color:var(--text);font-size:0.88rem;" %}
{% set lbl = "font-size:0.75rem;color:var(--text-muted);display:block;margin-bottom:0.2rem;" %}
{# ── Add monitor ──────────────────────────────────────────────────────────── #}
<div class="card" style="margin-bottom:1.25rem;">
@@ -19,24 +17,24 @@
<form method="post" action="/monitors/add" style="display:grid;gap:0.6rem;">{{ csrf_field() }}
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:0.6rem;align-items:end;">
<div>
<label style="{{ lbl }}">Type</label>
<select name="type" id="mtype" onchange="mtoggle(this.value)" style="{{ inp }}">
<label class="field-label">Type</label>
<select name="type" id="mtype" onchange="mtoggle(this.value)" class="input">
{% for t in types %}
<option value="{{ t.value }}" {% if t.value == 'icmp' %}selected{% endif %}>{{ t.value | upper }}</option>
{% endfor %}
</select>
</div>
<div>
<label style="{{ lbl }}">Name</label>
<input type="text" name="name" placeholder="optional" autocomplete="off" style="{{ inp }}">
<label class="field-label">Name</label>
<input type="text" name="name" placeholder="optional" autocomplete="off" class="input">
</div>
<div>
<label style="{{ lbl }}">Target <span style="color:var(--red);">*</span></label>
<input type="text" name="target" placeholder="host, IP, or URL" autocomplete="off" required style="{{ inp }}">
<label class="field-label">Target <span style="color:var(--red);">*</span></label>
<input type="text" name="target" placeholder="host, IP, or URL" autocomplete="off" required class="input">
</div>
<div>
<label style="{{ lbl }}">Interval (s, 0=global)</label>
<input type="number" name="check_interval_seconds" value="0" min="0" style="{{ inp }}">
<label class="field-label">Interval (s, 0=global)</label>
<input type="number" name="check_interval_seconds" value="0" min="0" class="input">
</div>
{{ type_fields() }}
</div>
+1 -1
View File
@@ -59,7 +59,7 @@
{{ "Pause" if m.enabled else "Resume" }}</button>
</form>
<form method="post" action="/monitors/{{ m.id }}/delete" style="display:inline;"
onsubmit="return confirm('Delete monitor {{ m.name }}?');">{{ csrf_field() }}
data-confirm="Delete monitor {{ m.name }}?">{{ csrf_field() }}
<button type="submit" class="btn btn-sm" style="padding:0.2rem 0.5rem;font-size:0.72rem;color:var(--red);">✕</button>
</form>
</div>
@@ -46,7 +46,7 @@
{% endif %}
<button class="btn btn-ghost btn-sm" style="font-size:0.78rem;"
onclick="toggleAnsibleEdit({{ idx }})">Edit</button>
onclick="toggleAnsibleEdit({{ idx|int }})">Edit</button>
<button class="btn btn-danger btn-sm" style="font-size:0.78rem;"
hx-post="/settings/ansible/sources/{{ idx }}/remove"
@@ -72,7 +72,7 @@
<div class="form-group" style="margin:0;">
<label style="font-size:0.8rem;">Type</label>
<select name="type" id="ansible-edit-type-{{ idx }}"
onchange="updateAnsibleEditFields({{ idx }})">
onchange="updateAnsibleEditFields({{ idx|int }})">
<option value="local" {% if src.type == 'local' %}selected{% endif %}>local</option>
<option value="git" {% if src.type == 'git' %}selected{% endif %}>git</option>
</select>
@@ -118,7 +118,7 @@
<div style="display:flex;gap:0.5rem;margin-top:0.75rem;">
<button type="submit" class="btn btn-sm">Save</button>
<button type="button" class="btn btn-ghost btn-sm"
onclick="toggleAnsibleEdit({{ idx }})">Cancel</button>
onclick="toggleAnsibleEdit({{ idx|int }})">Cancel</button>
</div>
</form>
</div>
@@ -18,7 +18,7 @@
</div>
<div style="display:flex;gap:0.4rem;flex-shrink:0;">
<button class="btn btn-ghost btn-sm" style="font-size:0.78rem;"
onclick="toggleRepoEdit({{ idx }})">Edit</button>
onclick="toggleRepoEdit({{ idx|int }})">Edit</button>
<button class="btn btn-danger btn-sm" style="font-size:0.78rem;"
hx-post="/settings/plugins/repos/{{ idx }}/remove"
hx-target="#plugin-repos"
@@ -47,7 +47,7 @@
<div style="display:flex;gap:0.5rem;margin-top:0.75rem;">
<button type="submit" class="btn btn-sm">Save</button>
<button type="button" class="btn btn-ghost btn-sm"
onclick="toggleRepoEdit({{ idx }})">Cancel</button>
onclick="toggleRepoEdit({{ idx|int }})">Cancel</button>
</div>
</form>
</div>
+1 -1
View File
@@ -39,7 +39,7 @@
<span style="color:var(--text-muted);font-size:0.78rem;">Click to select — add to a host's <code>authorized_keys</code> to authorize Steward manually.</span>
</div>
<form method="post" action="/settings/ansible/generate-key"
onsubmit="return confirm('Regenerate the managed key? Already-provisioned hosts will reject Steward until you re-provision them.');">{{ csrf_field() }}
data-confirm="Regenerate the managed key? Already-provisioned hosts will reject Steward until you re-provision them.">{{ csrf_field() }}
<button type="submit" class="btn btn-ghost btn-sm">Regenerate key</button>
</form>
{% else %}
+2
View File
@@ -1,6 +1,8 @@
{# steward/templates/settings/general.html #}
{% extends "base.html" %}
{% from "_macros.html" import crumbs %}
{% block title %}Settings — General — Steward{% endblock %}
{% block breadcrumb %}{{ crumbs([("Settings", "/settings/"), ("General", "")]) }}{% endblock %}
{% block content %}
{% set active_tab = "general" %}
{% include "settings/_tabs.html" %}
+2 -2
View File
@@ -54,7 +54,7 @@
<div class="section-title">Monitoring capabilities</div>
<form method="post" action="/settings/plugins/restart/"
hx-post="/settings/plugins/restart/" hx-target="#restart-banner" hx-swap="outerHTML"
onsubmit="return confirm('Restart the app now? This will briefly interrupt all monitoring.')"
data-confirm="Restart the app now? This will briefly interrupt all monitoring."
style="margin:0;">{{ csrf_field() }}
<button type="submit" class="btn btn-ghost btn-sm" style="font-size:0.78rem;">Restart App</button>
</form>
@@ -134,7 +134,7 @@
</div>
{% if orphan.removable %}
<form method="post" action="/settings/plugins/orphans/{{ orphan.name }}/remove/"
onsubmit="return confirm('Remove stored settings for &quot;{{ orphan.name }}&quot;? Its saved configuration, including any credentials, will be discarded. This cannot be undone.');"
data-confirm="Remove stored settings for &quot;{{ orphan.name }}&quot;? Its saved configuration, including any credentials, will be discarded. This cannot be undone."
style="margin:0;flex-shrink:0;">{{ csrf_field() }}
<button type="submit" class="btn btn-danger btn-sm" style="font-size:0.78rem;">Remove</button>
</form>
@@ -1,6 +1,8 @@
{# steward/templates/settings/thresholds.html — tunable degraded/critical cutoffs #}
{% extends "base.html" %}
{% from "_macros.html" import crumbs %}
{% block title %}Settings — Thresholds & Retention — Steward{% endblock %}
{% block breadcrumb %}{{ crumbs([("Settings", "/settings/"), ("Thresholds & retention", "")]) }}{% endblock %}
{% block content %}
{% set active_tab = "thresholds" %}
{% include "settings/_tabs.html" %}
+53 -41
View File
@@ -1,38 +1,52 @@
{# status/rows.html — unified monitor list + summary, refreshed on poll #}
{% macro uptime_cell(pct) %}
{%- if pct is none -%}<span style="color:var(--text-dim);">—</span>
{%- elif pct >= 99.9 -%}<span style="color:var(--green);">{{ "%.2f"|format(pct) }}%</span>
{%- elif pct >= 99 -%}<span style="color:var(--yellow);">{{ "%.2f"|format(pct) }}%</span>
{%- elif pct >= 95 -%}<span style="color:var(--orange);">{{ "%.2f"|format(pct) }}%</span>
{%- else -%}<span style="color:var(--red);">{{ "%.2f"|format(pct) }}%</span>
{%- endif -%}
{% endmacro %}
{# status/rows.html — what is wrong first (down monitors, active alerts), then
every monitor; refreshed on poll #}
{% from "_macros.html" import status_badge, status_dot, stat_tile, empty_state %}
{% macro uptime_cell(pct) -%}
{%- if pct is none -%}<span class="tone-unknown">—</span>
{%- else -%}<span class="tone-{{ status_class(pct, 'uptime') }}">{{ "%.2f"|format(pct) }}%</span>{%- endif -%}
{%- endmacro %}
{% macro since(e) -%}
{%- if e.since -%}{{ "for at least " if e.since_at_least else "since " }}{{ e.since|ago }}{%- endif -%}
{%- endmacro %}
{# ── Summary ─────────────────────────────────────────────────────────────── #}
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(130px,1fr));gap:0.75rem;margin-bottom:1rem;">
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.3rem;">Up</div>
<span class="stat-val" style="color:var(--green);">{{ up }}</span>
</div>
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.3rem;">Down</div>
<span class="stat-val" style="color:{% if down %}var(--red){% else %}var(--text-dim){% endif %};">{{ down }}</span>
</div>
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.3rem;">Pending</div>
<span class="stat-val" style="color:{% if pending %}var(--yellow){% else %}var(--text-dim){% endif %};">{{ pending }}</span>
</div>
{# ── What is wrong ───────────────────────────────────────────────────────── #}
{% if problems or alerts %}
<section class="card problem-card" aria-labelledby="status-problems">
<h2 id="status-problems" class="section-title" style="margin-bottom:0.6rem;">Needs attention</h2>
<ul class="problem-list">
{% for e in problems %}
<li>
{{ status_badge('down') }}
{% if e.detail_url %}<a href="{{ e.detail_url }}">{{ e.name }}</a>{% else %}<strong>{{ e.name }}</strong>{% endif %}
<span class="problem-detail">{{ e.kind|upper }} · {{ e.target }} · down {{ since(e) }}</span>
</li>
{% endfor %}
{% for state, rule in alerts %}
<li>
{{ status_badge('no_data' if state.no_data_since and state.state.value == 'inactive' else state.state) }}
<a href="/alerts/history?rule={{ rule.id }}">{{ rule.name }}</a>
<span class="problem-detail">{{ rule.resource_name }} · {{ rule.metric_name }} {{ rule.operator.value }} {{ rule.threshold }} · since {{ (state.no_data_since if state.state.value == 'inactive' else state.last_transition_at)|ago }}</span>
</li>
{% endfor %}
</ul>
</section>
{% endif %}
{# ── Counts ──────────────────────────────────────────────────────────────── #}
<div class="stat-strip">
{{ stat_tile("Down", down, state="down" if down else "inactive") }}
{{ stat_tile("Active alerts", alerts|length, state="firing" if alerts else "inactive", href="/alerts/") }}
{{ stat_tile("Pending", pending, state="pending" if pending else "inactive") }}
{{ stat_tile("Up", up, state="up") }}
</div>
{% if not entries %}
<div class="card empty">
No monitors configured yet. Add <a href="/hosts/">hosts</a> (ping / DNS) or HTTP monitors to populate the board.
</div>
<div class="card">{{ empty_state("No monitors yet. Add a host (ping or DNS) or an HTTP monitor to fill the board.", "Add a monitor", "/monitors/") }}</div>
{% else %}
<div class="card-flush">
{# header row #}
<div style="display:flex;align-items:center;gap:1rem;padding:0.55rem 1rem;border-bottom:1px solid var(--border-mid);font-size:0.72rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.04em;">
<span style="width:130px;flex-shrink:0;">Monitor</span>
<span style="width:11rem;flex-shrink:0;">Monitor</span>
<span style="flex:1;min-width:0;">Recent checks</span>
<span style="width:92px;text-align:right;flex-shrink:0;">Latest</span>
<span style="width:54px;text-align:center;flex-shrink:0;">24h</span>
@@ -44,13 +58,13 @@
{% for e in entries %}
<div style="display:flex;align-items:center;gap:1rem;padding:0.55rem 1rem;border-bottom:1px solid var(--border);">
{# name + kind #}
<div style="width:130px;flex-shrink:0;overflow:hidden;">
<div style="display:flex;align-items:center;gap:0.4rem;">
<span class="dot {% if e.status == 'up' %}dot-up{% elif e.status == 'down' %}dot-down{% else %}dot-dim{% endif %}"></span>
{% if e.detail_url %}<a href="{{ e.detail_url }}" style="font-size:0.85rem;font-weight:500;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ e.name }}</a>
{% else %}<span style="font-size:0.85rem;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ e.name }}</span>{% endif %}
<div style="width:11rem;flex-shrink:0;min-width:0;">
<div style="display:flex;align-items:baseline;gap:0.4rem;">
{{ status_dot(e.status) }}
{% if e.detail_url %}<a href="{{ e.detail_url }}" style="font-size:0.85rem;font-weight:500;color:var(--text);overflow-wrap:anywhere;">{{ e.name }}</a>
{% else %}<span style="font-size:0.85rem;font-weight:500;overflow-wrap:anywhere;">{{ e.name }}</span>{% endif %}
</div>
<div style="color:var(--text-dim);font-size:0.7rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;padding-left:0.85rem;">
<div style="color:var(--text-dim);font-size:0.7rem;overflow-wrap:anywhere;padding-left:0.85rem;">
<span style="text-transform:uppercase;letter-spacing:0.04em;">{{ e.kind }}</span> · {{ e.target }}
</div>
</div>
@@ -60,17 +74,17 @@
{% set pad = 30 - e.heartbeat|length %}
{% if pad > 0 %}{% for _ in range(pad) %}<span class="pill pill-empty" title="No data"></span>{% endfor %}{% endif %}
{% for hb in e.heartbeat %}
<span class="pill" style="background:{% if hb.state == 'down' %}#6a1515{% else %}#1a6632{% endif %};" title="{{ hb.title }}"></span>
<span class="pill pill-{{ 'down' if hb.state == 'down' else 'up' }}" title="{{ hb.title }}"></span>
{% endfor %}
</div>
{# latest latency + sparkline #}
<div style="width:92px;text-align:right;flex-shrink:0;font-size:0.8rem;font-variant-numeric:tabular-nums;">
{% if e.status == 'down' %}<span style="color:var(--red);font-weight:bold;">DOWN</span>
{% if e.status == 'down' %}{{ status_badge('down') }}
{% elif e.latency_ms is not none %}
{% if e.spark_svg %}<span style="opacity:0.8;">{{ e.spark_svg|safe }}</span><br>{% endif %}
<span style="color:var(--text-muted);">{{ "%.0f"|format(e.latency_ms) }} ms</span>
{% elif e.status == 'up' %}<span style="color:var(--green);">UP</span>
{% elif e.status == 'up' %}{{ status_badge('up') }}
{% else %}<span style="color:var(--text-dim);">—</span>{% endif %}
</div>
@@ -81,11 +95,9 @@
{# TLS expiry countdown #}
<span style="width:64px;text-align:right;flex-shrink:0;font-size:0.78rem;font-variant-numeric:tabular-nums;">
{% if e.tls_days is none %}<span style="color:var(--text-dim);">—</span>
{% elif e.tls_days < 0 %}<span style="color:var(--red);font-weight:bold;" title="Certificate expired">expired</span>
{% elif e.tls_days < 14 %}<span style="color:var(--red);" title="TLS certificate expiry">{{ e.tls_days|round|int }}d</span>
{% elif e.tls_days < 30 %}<span style="color:var(--yellow);" title="TLS certificate expiry">{{ e.tls_days|round|int }}d</span>
{% else %}<span style="color:var(--text-muted);" title="TLS certificate expiry">{{ e.tls_days|round|int }}d</span>{% endif %}
{% if e.tls_days is none %}<span class="tone-unknown">—</span>
{% elif e.tls_days < 0 %}<span class="tone-crit" title="Certificate expired">expired</span>
{% else %}<span class="tone-{{ status_class(e.tls_days, 'tls_days') }}" title="TLS certificate expires in {{ e.tls_days|round|int }} days">{{ e.tls_days|round|int }}d</span>{% endif %}
</span>
</div>
{% endfor %}
+12
View File
@@ -0,0 +1,12 @@
"""Where Steward's templates live, for the guards that read every one."""
import pathlib
import steward
REPO = pathlib.Path(steward.__file__).parent.parent
def template_files() -> list[pathlib.Path]:
"""Every first-party .html template: core and each plugin's."""
roots = [REPO / "steward" / "templates", *(REPO / "plugins").glob("*/templates")]
return sorted(p for root in roots for p in root.rglob("*.html"))
+1 -8
View File
@@ -1,16 +1,14 @@
"""The shared format filters and status_class (#5093): one way to write a size,
a rate, a span and a moment, and one grading against Settings → Thresholds."""
import pathlib
from datetime import datetime, timedelta, timezone
import jinja2
import pytest
import steward
from steward.core import formatting as f
from steward.core.settings import threshold_style_for, to_thresholds_cfg
from tests._templates import REPO as _REPO, template_files as _templates
_REPO = pathlib.Path(steward.__file__).parent.parent
TH = to_thresholds_cfg({})
NOW = datetime(2026, 10, 6, 18, 0, 0, tzinfo=timezone.utc)
@@ -88,11 +86,6 @@ def test_threshold_style_is_graded_by_status_class():
# ── every template compiles against the app's filters ────────────────────────
def _templates():
roots = [_REPO / "steward" / "templates", *(_REPO / "plugins").glob("*/templates")]
return sorted(p for root in roots for p in root.rglob("*.html"))
async def test_compiling_catches_an_unknown_filter(app):
with pytest.raises(jinja2.TemplateAssertionError, match="no_such"):
app.jinja_env.compile("{{ 1|no_such }}")
+80
View File
@@ -0,0 +1,80 @@
"""The status and layout macros in _macros.html (#5094), rendered through the
app's own Jinja environment so its globals (status_info, required) are the
real ones."""
import enum
import pytest
from steward.core.formatting import STATES, status_info
async def _render(app, source: str, **ctx) -> str:
async with app.app_context():
tpl = app.jinja_env.from_string('{% from "_macros.html" import status_badge, status_dot, '
'stat_tile, empty_state, page_header, icon_button %}' + source)
out = await tpl.render_async(**ctx) if app.jinja_env.is_async else tpl.render(**ctx)
return " ".join(out.split())
def test_every_state_a_page_shows_has_a_tone_and_a_label():
named = ["up", "down", "stale", "firing", "pending", "acknowledged", "no_data", "ok", "warn", "crit"]
assert set(named) <= set(STATES)
assert {tone for tone, _ in STATES.values()} == {"ok", "warn", "crit", "info", "dim"}
assert status_info("down")[0] == status_info("firing")[0] == "crit"
class _State(enum.Enum):
firing = "firing"
@pytest.mark.parametrize("state, tone, label", [
("firing", "crit", "Firing"), (_State.firing, "crit", "Firing"), ("UP", "ok", "Up"),
("no_data", "warn", "No data"), ("acknowledged", "info", "Acknowledged"),
(None, "dim", "Unknown"), ("half_open", "dim", "Half open"), # unmapped: dim, its own name
])
def test_status_info(state, tone, label):
assert status_info(state) == (tone, label)
async def test_status_badge(app):
assert await _render(app, "{{ status_badge('firing') }}") == '<span class="badge badge-crit">Firing</span>'
out = await _render(app, "{{ status_badge(s, 'Muted', title=t) }}", s=_State.firing, t='a "b"')
assert out == '<span class="badge badge-crit" title="a &#34;b&#34;">Muted</span>'
async def test_a_status_dot_always_carries_its_label(app):
out = await _render(app, "{{ status_dot('down') }}")
assert 'class="dot dot-crit" aria-hidden="true"' in out and '<span class="sr-only">Down</span>' in out
out = await _render(app, "{{ status_dot('up', show_label=true) }}")
assert '<span class="dot-label">Up</span>' in out and "sr-only" not in out
async def test_stat_tile(app):
out = await _render(app, "{{ stat_tile('Firing', 3, state='crit', href='/alerts/?a=1&b=2') }}")
assert out.startswith('<a href="/alerts/?a=1&amp;b=2" class="stat-tile stat-crit">')
assert '<span class="stat-tile-value">3</span>' in out and out.endswith("</a>")
out = await _render(app, "{{ stat_tile('<Hosts>', 12, hint='2 stale') }}")
assert out.startswith('<div class="stat-tile">') and "&lt;Hosts&gt;" in out and "2 stale" in out
async def test_empty_state(app):
out = await _render(app, "{{ empty_state('No rules yet.', 'New rule', '/alerts/rules/new') }}")
assert "<p>No rules yet.</p>" in out and 'href="/alerts/rules/new">New rule</a>' in out
assert "<a" not in await _render(app, "{{ empty_state('Nothing.') }}")
async def test_page_header_with_and_without_actions(app):
out = await _render(app, "{% call page_header('Alerts', 'All rules') %}<a class=\"btn\">New</a>{% endcall %}")
assert '<h1 class="page-title">Alerts</h1>' in out and '<p class="page-subtitle">All rules</p>' in out
assert '<div class="page-actions"><a class="btn">New</a></div>' in out
assert "page-actions" not in await _render(app, "{{ page_header('Alerts') }}")
async def test_icon_button_names_itself_and_refuses_to_render_without_a_label(app):
out = await _render(app, "{{ icon_button('×', 'Delete rule web01', type='submit', cls='btn-danger', confirm='Delete?') }}")
assert 'type="submit"' in out and 'aria-label="Delete rule web01"' in out and 'data-confirm="Delete?"' in out
out = await _render(app, "{{ icon_button('✎', 'Edit', href='/x') }}")
assert out.startswith('<a class="btn btn-sm btn-icon btn-ghost" href="/x" aria-label="Edit"')
for source in ("{{ icon_button('×') }}", "{{ icon_button('×', '') }}", "{{ icon_button('×', ' ') }}"):
with pytest.raises(ValueError, match="icon_button label is required"):
await _render(app, source)
+70
View File
@@ -0,0 +1,70 @@
"""The Status page says what is wrong, and since when, before anything else
(#5120)."""
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
import pytest
from quart import render_template
from steward.core.status import StatusEntry, streak_start
from tests.test_style_tokens import hex_colours
T0 = datetime(2026, 10, 6, 18, 0, tzinfo=timezone.utc)
def _checks(*ups):
return [SimpleNamespace(is_up=up, checked_at=T0 + timedelta(minutes=i)) for i, up in enumerate(ups)]
@pytest.mark.parametrize("ups, since, at_least", [
((), None, False),
((True,), 0, True),
((False, False, False), 0, True), # down for every check we have
((True, True, False, False), 2, False), # went down at the third check
((False, True, True, False), 3, False),
])
def test_streak_start(ups, since, at_least):
checks = _checks(*ups)
assert streak_start(checks) == ((checks[since].checked_at if since is not None else None), at_least)
async def _rows(app, entries, alerts):
async with app.test_request_context("/status/rows"):
return await render_template(
"status/rows.html", entries=entries, alerts=alerts,
problems=[e for e in entries if e.status == "down"],
up=sum(e.status == "up" for e in entries), down=sum(e.status == "down" for e in entries), pending=0)
async def test_the_problems_come_first_and_say_since_when(app):
now = datetime.now(timezone.utc)
entries = [
StatusEntry(kind="http", key="1", name="shop-api", target="https://shop/x", status="down",
since=now - timedelta(minutes=12), uptime={"24h": 93.0}, tls_days=2.0),
StatusEntry(kind="icmp", key="2", name="nas", target="10.0.0.5", status="up",
since=now - timedelta(days=3), since_at_least=True, uptime={"24h": 100.0}),
]
state = SimpleNamespace(state=SimpleNamespace(value="firing"), no_data_since=None,
last_transition_at=now - timedelta(minutes=4))
rule = SimpleNamespace(id="r1", name="CPU high", resource_name="web01", metric_name="cpu_pct",
operator=SimpleNamespace(value=">"), threshold=90)
page = await _rows(app, entries, [(state, rule)])
attention = page.index("Needs attention")
assert attention < page.index("Recent checks") # before the full list
problem_card = page[attention:page.index("</section>")]
assert "shop-api" in problem_card and "nas" not in problem_card
assert "down since <time" in problem_card and ">12m ago</time>" in problem_card
assert "CPU high" in problem_card and "cpu_pct &gt; 90" in problem_card and ">4m ago</time>" in problem_card
assert '<span class="badge badge-crit">Firing</span>' in problem_card
# Uptime and TLS coloured by Settings → Thresholds, not by literals.
assert '<span class="tone-crit">93.00%</span>' in page and '<span class="tone-ok">100.00%</span>' in page
assert 'class="tone-crit" title="TLS certificate expires in 2 days">2d' in page
assert not hex_colours(page), hex_colours(page)
async def test_a_quiet_board_has_no_problem_card(app):
entries = [StatusEntry(kind="icmp", key="2", name="nas", target="10.0.0.5", status="up")]
page = await _rows(app, entries, [])
assert "Needs attention" not in page and "nas" in page
assert "No monitors yet" in await _rows(app, [], [])
+56
View File
@@ -0,0 +1,56 @@
"""Every page below a top-level section shows its trail back up (rule 134).
The breadcrumb block is empty by default, so a nested page that forgets it
ships with no way back up and nothing says so. Every full page (one that
extends base.html) must fill the block, unless it is a section's own top
page, a logged-out page or an error page: those are named below, so adding
one is a decision someone makes in this file.
"""
import re
from tests._templates import REPO, template_files
TOP_LEVEL = {
"steward/templates/dashboard/index.html",
"steward/templates/status/index.html",
"steward/templates/hosts/list.html",
"steward/templates/monitors/index.html",
"steward/templates/alerts/list.html",
"steward/templates/ansible/index.html",
"steward/templates/audit/list.html",
"steward/templates/ai_access/index.html",
"plugins/docker/templates/docker/index.html",
"plugins/snmp/templates/snmp/index.html",
"plugins/traefik/templates/traefik/index.html",
"plugins/unifi/templates/unifi/index.html",
# Logged out, or no page to go back up from.
"steward/templates/auth/login.html",
"steward/templates/auth/setup.html",
"steward/templates/errors/404.html",
"steward/templates/errors/500.html",
"steward/templates/errors/database_unavailable.html",
}
_EXTENDS_BASE = re.compile(r"""\{%-?\s*extends\s+["']base\.html["']""")
_BLOCK = re.compile(r"\{%-?\s*block\s+breadcrumb\s*-?%\}(.*?)\{%-?\s*endblock", re.S)
def needs_crumbs_and_lacks_them(text: str) -> bool:
block = _BLOCK.search(text)
return bool(_EXTENDS_BASE.search(text)) and not (block and "crumbs(" in block.group(1))
def test_the_guard_can_fail():
assert needs_crumbs_and_lacks_them('{% extends "base.html" %}{% block content %}x{% endblock %}')
assert needs_crumbs_and_lacks_them('{% extends "base.html" %}{% block breadcrumb %}{% endblock %}')
assert not needs_crumbs_and_lacks_them(
'{% extends "base.html" %}{% block breadcrumb %}{{ crumbs([("Hosts", "/hosts/"), ("x", "")]) }}{% endblock %}')
assert not needs_crumbs_and_lacks_them("<tr><td>a fragment</td></tr>")
def test_every_nested_page_has_a_trail():
pages = {str(p.relative_to(REPO)): p for p in template_files()}
assert TOP_LEVEL <= set(pages), sorted(TOP_LEVEL - set(pages)) # a renamed page leaves no stale entry
missing = sorted(name for name, p in pages.items()
if name not in TOP_LEVEL and needs_crumbs_and_lacks_them(p.read_text()))
assert not missing, f"add {{% block breadcrumb %}}{{{{ crumbs([...]) }}}} (rule 134): {missing}"
+43
View File
@@ -0,0 +1,43 @@
"""No template puts a rendered value into inline JavaScript (#5099).
An onclick/onsubmit attribute is HTML-decoded before its JavaScript is
parsed, so autoescaping does not protect a name placed inside it: a monitor
called x');fetch(…);// ran as script when an admin clicked Delete. A
prompt goes in data-confirm="…" (base.html asks with it); any other value a
handler needs is an integer, rendered with |int.
"""
import re
from tests._templates import REPO as _REPO, template_files as _templates
_HANDLER = re.compile(r"""\bon[a-z]+\s*=\s*("[^"]*"|'[^']*')""", re.I)
_VALUE = re.compile(r"\{\{(.*?)\}\}", re.S)
def unsafe_handler_values(text: str) -> list[str]:
"""Each {{ … }} inside an inline handler that is not an |int."""
return [m.group(0) for h in _HANDLER.finditer(text) for m in _VALUE.finditer(h.group(1))
if not re.search(r"\|\s*int\s*$", m.group(1).strip())]
def test_the_guard_can_fail():
page = """
<button onclick="return confirm('Delete rule {{ rule.name }}?')">x</button>
<form onsubmit='return confirm({{ msg|tojson }})'>
<button onclick="toggle({{ idx|int }})">ok</button>
<button data-confirm="Delete {{ rule.name }}?" data-range="{{ key }}">ok</button>
"""
assert unsafe_handler_values(page) == ["{{ rule.name }}", "{{ msg|tojson }}"]
def test_no_template_renders_a_value_into_an_inline_handler():
files = _templates()
assert len(files) > 100
found = {str(p.relative_to(_REPO)): hits for p in files if (hits := unsafe_handler_values(p.read_text()))}
assert not found, f"use data-confirm, or render an integer with |int: {found}"
def test_base_asks_before_a_data_confirm_form_submits():
base = (_REPO / "steward" / "templates" / "base.html").read_text()
assert "dataset.confirm" in base and "window.confirm(ask)" in base
assert any("data-confirm=" in p.read_text() for p in _templates())
+30
View File
@@ -0,0 +1,30 @@
"""The public share view draws with the same stylesheet as every other page
(#5095): no palette of its own, so it can never drift from the tokens again."""
from types import SimpleNamespace
from quart import render_template
from tests.test_style_tokens import hex_colours
async def _share_view(app, widgets) -> str:
async with app.test_request_context("/share/t0ken"):
return await render_template(
"dashboard/share_view.html", dashboard=SimpleNamespace(name="Ops <wall>"),
widgets=widgets, raw_token="t0ken", poll_interval=30)
async def test_the_share_view_uses_the_shared_stylesheet(app):
page = await _share_view(app, [])
assert "/static/theme.css" in page and "/static/steward.css" in page
assert not hex_colours(page), hex_colours(page)
assert "Ops &lt;wall&gt;" in page and "<title>Ops &lt;wall&gt; — Steward</title>" in page
assert '<div class="empty-state">This dashboard has no widgets.</div>' in page
async def test_each_widget_loads_through_the_share_token(app):
gw = SimpleNamespace(id=7, grid_x=0, grid_y=2, grid_w=6, grid_h=3)
item = SimpleNamespace(db=gw, title="Hosts", hx_url="/w/hosts?id=7", defn=SimpleNamespace(poll=True))
page = await _share_view(app, [item])
assert 'hx-get="/w/hosts?id=7&s=t0ken"' in page
assert "grid-column:1 / span 6;grid-row:3 / span 3;" in page and "every 30s" in page
+2 -6
View File
@@ -11,6 +11,7 @@ import pathlib
import re
import steward
from tests._templates import template_files as _templates
_REPO = pathlib.Path(steward.__file__).parent.parent
_STATIC = _REPO / "steward" / "static"
@@ -21,12 +22,7 @@ _USED = re.compile(r"var\(\s*(--[\w-]+)")
# string -- but not an HTML attribute's value (href="#abc" is a link to an id).
_HEX = re.compile(r"""(?:[:(,]\s*|(?<![\w-]=)['"])#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3})(?![0-9a-zA-Z_-])""")
HEX_CEILING = 75 # measured 2026-10-06 (#5092); lower it as pages move onto tokens, never raise it
def _templates():
for root in [_REPO / "steward" / "templates", *(_REPO / "plugins").glob("*/templates")]:
yield from root.rglob("*.html")
HEX_CEILING = 58 # 75 at #5092, 58 once the share view used steward.css (#5095); only lower it
def _sheets():