feat(plugins): Traefik problems first, UniFi/SNMP/fleet on shared shapes, audit names its entities, templates on tokens (#5126)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m5s
CI / unit (push) Successful in 1m19s
CI / publish (push) Successful in 57s

Traefik: routers are graded from their latest scrape (router_state: crit on
any real 5xx share, warn on p95 past the latency critical cutoff or a high
4xx share). Failing and slow routers lead as cards that say why; healthy
ones fold into one table that stays open or shut across polls. Proxy
counts are stat tiles; certificates are graded by Settings -> Thresholds
(tls_days).

UniFi: WAN health is a stat strip (latency graded by the thresholds),
devices carry status dots, event categories and firewall actions are
badges, the alarm count reuses .count-badge (renamed from .nav-count).
SNMP: each device wears a status badge and its readings are stat tiles.

Agent fleet: the table comes first, with a Status column (Reporting /
Silent / Never reported, by the host page's stale_after_seconds), silent
agents first, hosts linked, last seen as |ago.

Audit: entries name their entity (one query per type, steward/audit/
service.py) and link it; something deleted since keeps the name its entry
recorded and says "(deleted)".

Tokens: charts read colours through chart-colors.js (stewardColor resolves
a token to the rgb the browser computed; Chart.js defaults take the page's
text and border colours), so host, history, Traefik and SNMP charts drop
their hex. Template hex colours reach 0 (HEX_CEILING = 0), the last rgba
pills go, and heavy weights (600/700/bold, 60 of them) become 500, the
house pair being 400/500; a guard keeps templates there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 18:25:09 -04:00
co-authored by Claude Opus 5.5
parent c2a79c8bb0
commit 414c942cbf
60 changed files with 686 additions and 391 deletions
+16 -10
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import json
from quart import Blueprint, render_template, request, current_app
from sqlalchemy import select
from steward.audit.service import entity_labels
from steward.auth.middleware import require_role
from steward.models.audit import AuditEvent
from steward.models.users import UserRole
@@ -23,16 +24,21 @@ async def list_events():
result = await db.execute(stmt)
events = result.scalars().all()
# Parse detail_json for display
parsed = []
for e in events:
detail = {}
if e.detail_json:
try:
detail = json.loads(e.detail_json)
except (ValueError, TypeError):
detail = {"raw": e.detail_json}
parsed.append({"event": e, "detail": detail})
# Parse detail_json for display
parsed = []
for e in events:
detail = {}
if e.detail_json:
try:
detail = json.loads(e.detail_json)
except (ValueError, TypeError):
detail = {"raw": e.detail_json}
parsed.append({"event": e, "detail": detail})
labels = await entity_labels(db, parsed)
for item in parsed:
e = item["event"]
item["entity"] = labels.get((e.entity_type, str(e.entity_id))) if e.entity_id else None
return await render_template(
"audit/list.html",
+84
View File
@@ -0,0 +1,84 @@
"""Name the thing each audit entry is about.
An audit row stores an entity type and id; the page shows the entity's
current name, linked to its page, in one query per type for the whole page.
An entity deleted since keeps whatever name the entry's own detail recorded.
"""
from __future__ import annotations
from sqlalchemy import select
from steward.ansible.inventory_gen import run_title, scope_labels
from steward.models.alerts import AlertRule, MaintenanceWindow
from steward.models.ansible import AnsibleRun
from steward.models.ansible_inventory import AnsibleGroup, AnsibleTarget
from steward.models.ansible_schedule import AnsibleSchedule
from steward.models.api_tokens import ApiToken
from steward.models.hosts import Host
from steward.models.monitors import Monitor
# entity_type -> (model, its page for an id). A type named by a value rather
# than a row (plugin, ansible_playbook) is its own label.
_NAMED = {
"host": (Host, "/hosts/{}"),
"monitor": (Monitor, "/monitors/{}"),
"alert_rule": (AlertRule, "/alerts/history?rule={}"),
"maintenance_window": (MaintenanceWindow, "/alerts/maintenance"),
"ansible_target": (AnsibleTarget, "/ansible/inventory/targets/{}"),
"ansible_group": (AnsibleGroup, "/ansible/inventory/groups/{}"),
"ansible_schedule": (AnsibleSchedule, "/ansible/schedules"),
"api_token": (ApiToken, "/ai-access/"),
}
_SELF_NAMED = {"plugin": "/settings/plugins/", "ansible_playbook": None}
def _recorded_name(detail: dict) -> str | None:
"""A name the entry recorded when it was written, for a deleted entity."""
for key in ("name", "new_name", "username", "playbook"):
if detail.get(key):
return str(detail[key])
return None
async def entity_labels(db, items: list[dict]) -> dict[tuple[str, str], dict]:
"""{(entity_type, entity_id): {"label", "url", "gone"}} for the audit rows
in `items` ({"event", "detail"}), batched one query per type."""
ids: dict[str, set[str]] = {}
for it in items:
e = it["event"]
if e.entity_type and e.entity_id:
ids.setdefault(e.entity_type, set()).add(str(e.entity_id))
labels: dict[tuple[str, str], dict] = {}
for etype, wanted in ids.items():
if etype in _NAMED:
model, page = _NAMED[etype]
key_col = model.id
keys = [int(i) for i in wanted if i.isdigit()] if etype == "api_token" else list(wanted)
if not keys:
continue
for row_id, name in (await db.execute(
select(key_col, model.name).where(key_col.in_(keys)))).all():
labels[(etype, str(row_id))] = {"label": name, "url": page.format(row_id), "gone": False}
elif etype == "ansible_run":
runs = (await db.execute(
select(AnsibleRun).where(AnsibleRun.id.in_(list(wanted))))).scalars().all()
scopes = await scope_labels(db, [r.inventory_scope for r in runs])
for r in runs:
labels[(etype, r.id)] = {
"label": run_title(r.playbook_path, scopes.get(r.inventory_scope, r.inventory_scope)),
"url": f"/ansible/runs/{r.id}", "gone": False}
elif etype in _SELF_NAMED:
for i in wanted:
labels[(etype, i)] = {"label": i, "url": _SELF_NAMED[etype], "gone": False}
# What a lookup did not find was deleted since: keep the name the entry recorded.
for it in items:
e = it["event"]
if not (e.entity_type and e.entity_id):
continue
key = (e.entity_type, str(e.entity_id))
if key not in labels:
recorded = _recorded_name(it["detail"] or {})
labels[key] = {"label": recorded or f"{str(e.entity_id)[:8]}", "url": None, "gone": True}
return labels
+23
View File
@@ -0,0 +1,23 @@
// Design tokens as colours a <canvas> can paint with. A chart is drawn on a
// canvas, which can't read var(--green), so each token is resolved through a
// hidden element to the rgb() the browser computed for it.
//
// Loaded after the stylesheets (a script waits for the sheets above it), so the
// tokens are defined by the time this runs. It also gives every Chart.js chart
// the page's text and grid colours, so a chart config only names its series.
(function () {
function stewardColor(name) {
var probe = document.createElement('span');
probe.style.display = 'none';
probe.style.color = 'var(' + name + ')';
document.documentElement.appendChild(probe);
var value = getComputedStyle(probe).color;
probe.remove();
return value;
}
window.stewardColor = stewardColor;
if (window.Chart) {
Chart.defaults.color = stewardColor('--text-muted');
Chart.defaults.borderColor = stewardColor('--border');
}
})();
+2 -2
View File
@@ -86,8 +86,8 @@ code { font-family: var(--fs-font-mono); font-size: 0.875em; background: var(--b
color: var(--gold); border-left-color: var(--gold);
background: color-mix(in srgb, var(--gold) 8%, transparent);
}
/* How many alerts need attention, beside the Alerts link. */
.nav-count {
/* A small count that needs attention: alerts beside the Alerts link, UniFi alarms. */
.count-badge {
margin-left: auto; padding: 0 0.45em; border-radius: var(--fs-radius-sm);
background: var(--red-dim); color: var(--red); font-size: 0.75rem; font-weight: 500;
font-variant-numeric: tabular-nums;
+3 -3
View File
@@ -16,13 +16,13 @@
</div>
<details open>
<summary style="cursor:pointer;font-weight:600;">Claude Code</summary>
<summary style="cursor:pointer;font-weight:500;">Claude Code</summary>
<pre style="{{ pre }}">claude mcp add --transport http steward {{ mcp_url }} \
--header "Authorization: Bearer {{ tok }}"</pre>
</details>
<details style="margin-top:0.75rem;">
<summary style="cursor:pointer;font-weight:600;">Claude Desktop</summary>
<summary style="cursor:pointer;font-weight:500;">Claude Desktop</summary>
<div style="font-size:0.8rem;color:var(--text-muted);margin-top:0.4rem;line-height:1.5;">
Add to <code>claude_desktop_config.json</code>. It bridges through
<code>mcp-remote</code>, which needs Node.js.
@@ -40,7 +40,7 @@
</details>
<details style="margin-top:0.75rem;">
<summary style="cursor:pointer;font-weight:600;">Open WebUI and other clients</summary>
<summary style="cursor:pointer;font-weight:500;">Open WebUI and other clients</summary>
<div style="font-size:0.8rem;color:var(--text-muted);margin-top:0.4rem;line-height:1.5;">
Add an MCP server using the <strong>Streamable HTTP</strong> transport, with the URL
above and bearer authentication set to the token. In Open WebUI this is an external
+1 -1
View File
@@ -7,7 +7,7 @@
<h1 class="page-title">Token created</h1>
<div class="card" style="border-color:var(--gold);margin-bottom:1rem;">
<div style="font-size:0.8rem;font-weight:600;color:var(--gold);text-transform:uppercase;letter-spacing:0.06em;margin-bottom:0.75rem;">
<div style="font-size:0.8rem;font-weight:500;color:var(--gold);text-transform:uppercase;letter-spacing:0.06em;margin-bottom:0.75rem;">
Copy this token now. It can't be shown again.
</div>
+1 -1
View File
@@ -42,7 +42,7 @@
{% set t = row.token %}
<tr {% if row.state != 'active' %}style="opacity:0.55;"{% endif %}>
<td>
<div style="font-weight:600;">{{ t.name }}</div>
<div style="font-weight:500;">{{ t.name }}</div>
<div style="font-family:ui-monospace,monospace;font-size:0.75rem;color:var(--text-muted);">{{ t.prefix }}…</div>
</td>
<td><span class="badge badge-dim">{{ t.role.value }}</span></td>
@@ -24,7 +24,7 @@
</label>
{% endif %}
{% if variables %}
<div style="margin:0.5rem 0 0.25rem;font-size:0.82rem;color:var(--text-muted);font-weight:600;">
<div style="margin:0.5rem 0 0.25rem;font-size:0.82rem;color:var(--text-muted);font-weight:500;">
Playbook variables
</div>
<p style="color:var(--text-dim);font-size:0.76rem;margin:0 0 0.6rem;">
+1 -1
View File
@@ -42,7 +42,7 @@
<span style="color:var(--red);" title="{{ s.last_error }}">launch error</span>
{% elif row.last_run %}
{% set sc = {"queued":"var(--text-dim)","running":"var(--yellow)","success":"var(--green)","failed":"var(--red)","interrupted":"var(--orange)","cancelled":"var(--orange)"}.get(row.last_run.status.value, "var(--text-muted)") %}
<a href="/ansible/runs/{{ row.last_run.id }}" style="color:{{ sc }};font-weight:600;">{{ row.last_run.status.value }}</a>
<a href="/ansible/runs/{{ row.last_run.id }}" style="color:{{ sc }};font-weight:500;">{{ row.last_run.status.value }}</a>
<span style="color:var(--text-dim);">· {{ s.last_run_at.strftime("%m-%d %H:%M") }}</span>
{% else %}
<span style="color:var(--text-dim);">never</span>
+15 -26
View File
@@ -1,25 +1,25 @@
{% extends "base.html" %}
{% block title %}Audit Log — Steward{% endblock %}
{% from "_macros.html" import page_header, empty_state %}
{% block title %}Audit log — Steward{% 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;">Audit Log</h1>
{% call page_header("Audit log", "Who changed what, newest first.") %}
<form method="get" style="display:flex;gap:0.5rem;align-items:center;">
<input type="text" name="action" value="{{ action_filter }}"
placeholder="Filter by action…"
placeholder="Filter by action…" aria-label="Filter by action"
style="width:200px;font-size:0.85rem;">
<button type="submit" class="btn btn-ghost btn-sm">Filter</button>
{% if action_filter %}
<a href="/audit/" class="btn btn-ghost btn-sm">Clear</a>
{% endif %}
</form>
</div>
{% endcall %}
{% if events %}
<div class="card-flush">
<table class="table">
<thead>
<tr>
<th style="min-width:160px;">Time (UTC)</th>
<th>When</th>
<th>User</th>
<th>Action</th>
<th>Entity</th>
@@ -31,30 +31,21 @@
{% set e = item.event %}
{% set d = item.detail %}
<tr>
<td style="font-family:ui-monospace,monospace;font-size:0.8rem;white-space:nowrap;color:var(--text-muted);">
{{ e.timestamp.strftime("%Y-%m-%d %H:%M:%S") }}
</td>
<td style="white-space:nowrap;" title="{{ e.timestamp.strftime('%Y-%m-%d %H:%M:%S UTC') }}">{{ e.timestamp|ago }}</td>
<td style="font-size:0.85rem;">{{ e.username }}</td>
<td><span class="mono">{{ e.action }}</span></td>
<td>
{% set cat = e.action.split('.')[0] %}
<span style="
font-size:0.78rem;font-family:ui-monospace,monospace;
padding:0.15rem 0.4rem;border-radius:3px;
background:{% if cat == 'auth' %}var(--bg-elevated){% elif cat == 'plugin' %}var(--gold-dim){% elif cat == 'alert' %}var(--red-dim){% elif cat == 'host' %}var(--green-dim){% elif cat == 'settings' %}var(--bg-elevated){% else %}var(--bg-elevated){% endif %};
color:{% if cat == 'auth' %}var(--accent){% elif cat == 'plugin' %}var(--gold){% elif cat == 'alert' %}var(--red){% elif cat == 'host' %}var(--green){% elif cat == 'settings' %}var(--text-muted){% else %}var(--text-muted){% endif %};">
{{ e.action }}
</span>
</td>
<td style="font-size:0.82rem;color:var(--text-muted);">
{% if e.entity_type %}
<span style="color:var(--text-dim);">{{ e.entity_type }}/</span>{{ e.entity_id or "" }}
{% endif %}
{% if item.entity %}
<span class="field-hint">{{ e.entity_type|replace('_', ' ') }}</span>
{% if item.entity.url %}<a href="{{ item.entity.url }}">{{ item.entity.label }}</a>{% else %}{{ item.entity.label }}{% endif %}
{% if item.entity.gone %}<span class="field-hint">(deleted)</span>{% endif %}
{% elif e.entity_type %}<span class="field-hint">{{ e.entity_type|replace('_', ' ') }}</span>{% endif %}
</td>
<td style="font-size:0.8rem;color:var(--text-muted);max-width:340px;">
{% if d %}
{% for k, v in d.items() %}
<span style="color:var(--text-dim);">{{ k }}:</span>
<span style="font-family:ui-monospace,monospace;">{{ v }}</span>
<span class="mono">{{ v }}</span>
{% if not loop.last %} &nbsp;·&nbsp; {% endif %}
{% endfor %}
{% endif %}
@@ -71,8 +62,6 @@
{% endif %}
</p>
{% else %}
<div class="card" style="text-align:center;padding:3rem;">
<p style="color:var(--text-muted);">No audit events recorded yet.</p>
</div>
<div class="card">{{ empty_state("No audit events recorded yet.") }}</div>
{% endif %}
{% endblock %}
+2 -1
View File
@@ -25,6 +25,7 @@
<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') }}">
<script src="{{ url_for('static', filename='chart-colors.js') }}"></script>
{% block head %}{% endblock %}
</head>
<body>
@@ -62,7 +63,7 @@
<div class="nav-group">
<div class="nav-group-label">Monitoring</div>
<a href="/monitors/" {% if _p.startswith('/monitors') %}class="active" aria-current="page"{% endif %}>Monitors</a>
<a href="/alerts/" {% if _p.startswith('/alerts') %}class="active" aria-current="page"{% endif %}>Alerts{% if active_alert_count %} <span class="nav-count" title="{{ active_alert_count }} alert{{ 's' if active_alert_count != 1 }} need{{ 's' if active_alert_count == 1 }} attention">{{ active_alert_count }}</span>{% endif %}</a>
<a href="/alerts/" {% if _p.startswith('/alerts') %}class="active" aria-current="page"{% endif %}>Alerts{% if active_alert_count %} <span class="count-badge" title="{{ active_alert_count }} alert{{ 's' if active_alert_count != 1 }} need{{ 's' if active_alert_count == 1 }} attention">{{ active_alert_count }}</span>{% endif %}</a>
</div>
<div class="nav-group">
<div class="nav-group-label">Automation</div>
@@ -20,7 +20,7 @@
<summary style="display:flex;align-items:center;justify-content:space-between;gap:0.6rem;
padding:0.7rem 0.85rem;cursor:pointer;list-style:none;user-select:none;">
<div style="flex:1;min-width:0;">
<div style="font-weight:600;font-size:0.85rem;">{{ w.label }}</div>
<div style="font-weight:500;font-size:0.85rem;">{{ w.label }}</div>
<div style="font-size:0.74rem;color:var(--text-muted);margin-top:0.1rem;">{{ w.description }}</div>
</div>
<span class="btn btn-sm" style="flex-shrink:0;pointer-events:none;">Add ▾</span>
+3 -3
View File
@@ -22,7 +22,7 @@
<div style="display:flex;align-items:center;gap:0.75rem;">
<div style="flex:1;min-width:0;">
<div style="display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap;">
<a href="/d/{{ dash.id }}" style="font-weight:600;font-size:0.9rem;">{{ dash.name }}</a>
<a href="/d/{{ dash.id }}" style="font-weight:500;font-size:0.9rem;">{{ dash.name }}</a>
{% if dash.is_default %}
<span class="badge badge-gold" style="font-size:0.68rem;">Your Default</span>
{% endif %}
@@ -36,7 +36,7 @@
<details style="position:relative;">
<summary class="btn btn-ghost btn-sm" style="cursor:pointer;list-style:none;">Rename</summary>
<div style="position:absolute;right:0;top:calc(100% + 4px);background:var(--bg-card);border:1px solid var(--border-mid);border-radius:6px;padding:0.75rem;z-index:50;width:220px;box-shadow:0 4px 16px rgba(0,0,0,0.4);">
<div style="position:absolute;right:0;top:calc(100% + 4px);background:var(--bg-card);border:1px solid var(--border-mid);border-radius:6px;padding:0.75rem;z-index:50;width:220px;box-shadow:var(--fs-shadow-3);">
<form method="post" action="/dashboards/{{ dash.id }}/rename" style="display:flex;gap:0.4rem;">{{ csrf_field() }}
<input type="text" name="name" value="{{ dash.name }}"
style="flex:1;font-size:0.85rem;padding:0.3rem 0.5rem;" required>
@@ -83,7 +83,7 @@
<div style="display:flex;align-items:center;gap:0.75rem;">
<div style="flex:1;min-width:0;">
<div style="display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap;">
<a href="/d/{{ dash.id }}" style="font-weight:600;font-size:0.9rem;">{{ dash.name }}</a>
<a href="/d/{{ dash.id }}" style="font-weight:500;font-size:0.9rem;">{{ dash.name }}</a>
{% if dash.owner_id is none %}
<span class="badge badge-dim" style="font-size:0.68rem;">System</span>
{% endif %}
+1 -1
View File
@@ -20,7 +20,7 @@
<div class="card" style="margin-bottom:0;padding:0.85rem 1rem;{% if expired %}opacity:0.5;{% endif %}">
<div style="display:flex;align-items:center;gap:0.75rem;">
<div style="flex:1;min-width:0;">
<div style="font-weight:600;font-size:0.875rem;">
<div style="font-weight:500;font-size:0.875rem;">
{{ token.label or "Unnamed link" }}
{% if expired %}<span class="badge badge-red" style="font-size:0.68rem;">Expired</span>{% endif %}
</div>
@@ -7,7 +7,7 @@
<h1 class="page-title">Share Link Created</h1>
<div class="card" style="border-color:var(--gold);margin-bottom:1rem;">
<div style="font-size:0.8rem;font-weight:600;color:var(--gold);text-transform:uppercase;letter-spacing:0.06em;margin-bottom:0.75rem;">
<div style="font-size:0.8rem;font-weight:500;color:var(--gold);text-transform:uppercase;letter-spacing:0.06em;margin-bottom:0.75rem;">
Copy this link now — it cannot be shown again
</div>
@@ -9,6 +9,7 @@
<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') }}">
<script src="{{ url_for('static', filename='chart-colors.js') }}"></script>
{# 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>
+1 -1
View File
@@ -33,7 +33,7 @@
{% set hs = host_status.get(host.id) %}
{% set ut = uptime.get(host.id, {}) %}
<tr>
<td><a href="/hosts/{{ host.id }}" style="font-weight:600;">{{ host.name }}</a></td>
<td><a href="/hosts/{{ host.id }}" style="font-weight:500;">{{ host.name }}</a></td>
<td style="color:var(--text-muted);">{{ host.address }}</td>
<td style="text-align:center;">
{% if not hs %}
+2 -2
View File
@@ -16,9 +16,9 @@
style="flex-shrink:0;"
title="{% if not hs %}no monitors{% else %}{{ hs.state }}{% endif %}"></span>
{% if session.user_id %}
<a href="/hosts/{{ host.id }}" style="font-weight:600;font-size:0.85rem;overflow-wrap:anywhere;">{{ host.name }}</a>
<a href="/hosts/{{ host.id }}" style="font-weight:500;font-size:0.85rem;overflow-wrap:anywhere;">{{ host.name }}</a>
{% else %}
<span style="font-weight:600;font-size:0.85rem;overflow-wrap:anywhere;">{{ host.name }}</span>
<span style="font-weight:500;font-size:0.85rem;overflow-wrap:anywhere;">{{ host.name }}</span>
{% endif %}
</div>
{# Line 2 — monitors + agent metrics; wraps instead of truncating #}
+3 -3
View File
@@ -7,15 +7,15 @@
<div style="display:flex;gap:1rem;margin-bottom:0.65rem;flex-wrap:wrap;">
{% if up %}
<div><span style="font-size:1.4rem;font-weight:700;line-height:1;color:var(--green);">{{ up }}</span>
<div><span style="font-size:1.4rem;font-weight:500;line-height:1;color:var(--green);">{{ up }}</span>
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">up</span></div>
{% endif %}
{% if down %}
<div><span style="font-size:1.4rem;font-weight:700;line-height:1;color:var(--red);">{{ down }}</span>
<div><span style="font-size:1.4rem;font-weight:500;line-height:1;color:var(--red);">{{ down }}</span>
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">down</span></div>
{% endif %}
{% if pending %}
<div><span style="font-size:1.4rem;font-weight:700;line-height:1;color:var(--text-dim);">{{ pending }}</span>
<div><span style="font-size:1.4rem;font-weight:500;line-height:1;color:var(--text-dim);">{{ pending }}</span>
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">pending</span></div>
{% endif %}
</div>
@@ -12,7 +12,7 @@
<div id="ansible-source-view-{{ idx }}"
style="display:flex;align-items:center;gap:0.75rem;padding:0.85rem 1rem;">
<span style="flex-shrink:0;font-size:0.7rem;font-weight:600;text-transform:uppercase;
<span style="flex-shrink:0;font-size:0.7rem;font-weight:500;text-transform:uppercase;
letter-spacing:0.06em;padding:0.15em 0.5em;border-radius:3px;
background:{% if src.type == 'git' %}var(--fs-accent-faint){% else %}var(--bg-elevated){% endif %};
color:{% if src.type == 'git' %}var(--accent){% else %}var(--text-muted){% endif %};">
@@ -20,7 +20,7 @@
</span>
<div style="flex:1;min-width:0;">
<div style="font-weight:600;font-size:0.9rem;">{{ src.name }}</div>
<div style="font-weight:500;font-size:0.9rem;">{{ src.name }}</div>
<div style="font-size:0.78rem;color:var(--text-muted);margin-top:0.1rem;
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">
{% if src.type == 'git' %}
@@ -136,7 +136,7 @@
<details class="card" style="padding:0;overflow:hidden;">
<summary style="display:flex;align-items:center;gap:0.75rem;padding:0.85rem 1rem;
cursor:pointer;list-style:none;user-select:none;">
<span style="flex:1;font-weight:600;font-size:0.875rem;color:var(--text);">Add Source</span>
<span style="flex:1;font-weight:500;font-size:0.875rem;color:var(--text);">Add Source</span>
<span class="btn btn-sm" style="pointer-events:none;">New ▾</span>
</summary>
@@ -20,7 +20,7 @@
<div style="display:flex;align-items:flex-start;gap:1rem;">
<div style="flex:1;min-width:0;">
<div style="display:flex;align-items:center;gap:0.6rem;flex-wrap:wrap;">
<span style="font-weight:600;color:var(--text);font-size:0.9rem;">{{ plugin.name }}</span>
<span style="font-weight:500;color:var(--text);font-size:0.9rem;">{{ plugin.name }}</span>
<span style="font-size:0.75rem;color:var(--text-muted);">v{{ plugin.version }}</span>
{% if update_available %}
@@ -12,7 +12,7 @@
<div id="plugin-repo-view-{{ idx }}"
style="display:flex;align-items:center;gap:0.75rem;padding:0.85rem 1rem;">
<div style="flex:1;min-width:0;">
<div style="font-weight:600;font-size:0.88rem;">{{ repo.name or repo.url }}</div>
<div style="font-weight:500;font-size:0.88rem;">{{ repo.name or repo.url }}</div>
<div style="font-size:0.77rem;color:var(--text-muted);margin-top:0.1rem;
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ repo.url }}</div>
</div>
@@ -65,7 +65,7 @@
<details class="card" style="padding:0;overflow:hidden;">
<summary style="display:flex;align-items:center;gap:0.75rem;padding:0.85rem 1rem;
cursor:pointer;list-style:none;user-select:none;">
<span style="flex:1;font-weight:600;font-size:0.875rem;color:var(--text);">Add Repository</span>
<span style="flex:1;font-weight:500;font-size:0.875rem;color:var(--text);">Add Repository</span>
<span class="btn btn-sm" style="pointer-events:none;">New ▾</span>
</summary>
<form hx-post="/settings/plugins/repos/add"
@@ -50,7 +50,7 @@
name="plugin.{{ plugin._dir }}.enabled"
id="plugin-{{ plugin._dir }}-enabled"
{% if plugin._enabled %}checked{% endif %}>
<label for="plugin-{{ plugin._dir }}-enabled" style="margin:0;font-weight:600;cursor:pointer;">
<label for="plugin-{{ plugin._dir }}-enabled" style="margin:0;font-weight:500;cursor:pointer;">
Enable this plugin
</label>
</div>
+2 -2
View File
@@ -22,7 +22,7 @@
{% endif %}
<div style="flex:1;min-width:0;">
<div style="display:flex;align-items:baseline;gap:0.5rem;flex-wrap:wrap;">
<span style="font-weight:600;font-size:0.9rem;color:var(--text);">{{ plugin.get('name', plugin._dir) }}</span>
<span style="font-weight:500;font-size:0.9rem;color:var(--text);">{{ plugin.get('name', plugin._dir) }}</span>
<span style="font-size:0.75rem;color:var(--text-muted);">v{{ plugin.get('version', '?') }}</span>
{% if fail_reason %}
<span style="font-size:0.72rem;padding:0.1em 0.45em;border-radius:3px;
@@ -109,7 +109,7 @@
title="{{ orphan.reason or 'Configured but not installed' }}"></span>
<div style="flex:1;min-width:0;">
<div style="display:flex;align-items:baseline;gap:0.5rem;flex-wrap:wrap;">
<span style="font-weight:600;font-size:0.9rem;color:var(--text);">{{ orphan.name }}</span>
<span style="font-weight:500;font-size:0.9rem;color:var(--text);">{{ orphan.name }}</span>
{% if orphan.reason %}
<span style="font-size:0.72rem;padding:0.1em 0.45em;border-radius:3px;
background:color-mix(in srgb,var(--red) 15%,var(--bg));color:var(--red);">Failed to load</span>
+2 -2
View File
@@ -12,12 +12,12 @@
<label>{{ label }} <span style="color:var(--text-muted);font-size:0.8rem;">({{ unit }})</span></label>
<div style="display:flex;gap:1.25rem;flex-wrap:wrap;margin-top:0.25rem;">
<span style="display:flex;align-items:center;gap:0.4rem;">
<span style="color:var(--yellow);font-size:0.8rem;font-weight:600;">warn</span>
<span style="color:var(--yellow);font-size:0.8rem;font-weight:500;">warn</span>
<input type="number" name="{{ warn_field }}" step="{{ step }}"
value="{{ settings[warn_key] }}" style="max-width:110px;">
</span>
<span style="display:flex;align-items:center;gap:0.4rem;">
<span style="color:var(--red);font-size:0.8rem;font-weight:600;">crit</span>
<span style="color:var(--red);font-size:0.8rem;font-weight:500;">crit</span>
<input type="number" name="{{ crit_field }}" step="{{ step }}"
value="{{ settings[crit_key] }}" style="max-width:110px;">
</span>
+1 -1
View File
@@ -31,7 +31,7 @@
<span title="{{ e.name }}" style="{{ nm_style }}">{{ e.name }}</span>
{% endif %}
<span style="color:var(--text-dim);font-size:0.7rem;text-transform:uppercase;">{{ e.kind }}</span>
<span style="color:{% if e.status == 'down' %}var(--red){% else %}var(--yellow){% endif %};font-size:0.75rem;font-weight:600;">{{ e.status }}</span>
<span style="color:{% if e.status == 'down' %}var(--red){% else %}var(--yellow){% endif %};font-size:0.75rem;font-weight:500;">{{ e.status }}</span>
</div>
{% endfor %}
{% if problems|length > 8 %}