feat: UI redesign (CSS system, DNS widget, Traefik dashboard widget)

- Replace base.html style block with full CSS design system using custom properties (dark theme, cards, tables, badges, dots, widgets)
- Add DNS blueprint (fablednetmon/dns/) with /dns/ and /dns/rows routes + templates
- Add Traefik /widget route and widget.html fragment for dashboard
- Update dashboard: DNS + Traefik widgets, traefik_enabled config detection
- Update all templates to use new CSS classes (page-title, section-title, card-flush, table, badge-*, dot-*, btn-*)
- Register dns_bp in app.py

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-18 23:37:46 -04:00
parent d29c5b3eab
commit 165a202ba4
22 changed files with 495 additions and 236 deletions
+2
View File
@@ -87,6 +87,7 @@ def create_app(
from .dashboard.routes import dashboard_bp
from .hosts.routes import hosts_bp
from .ping.routes import ping_bp
from .dns.routes import dns_bp
from .alerts.routes import alerts_bp
from .ansible.routes import ansible_bp
from .settings.routes import settings_bp
@@ -95,6 +96,7 @@ def create_app(
app.register_blueprint(dashboard_bp)
app.register_blueprint(hosts_bp)
app.register_blueprint(ping_bp)
app.register_blueprint(dns_bp)
app.register_blueprint(alerts_bp)
app.register_blueprint(ansible_bp)
app.register_blueprint(settings_bp)
+7 -8
View File
@@ -16,8 +16,7 @@ async def index():
# Latest ping per host
latest_ping_subq = (
select(PingResult.host_id, func.max(PingResult.probed_at).label("max_at"))
.group_by(PingResult.host_id)
.subquery()
.group_by(PingResult.host_id).subquery()
)
pr = await db.execute(
select(PingResult).join(
@@ -33,8 +32,7 @@ async def index():
# Latest DNS per host
latest_dns_subq = (
select(DnsResult.host_id, func.max(DnsResult.resolved_at).label("max_at"))
.group_by(DnsResult.host_id)
.subquery()
.group_by(DnsResult.host_id).subquery()
)
dr = await db.execute(
select(DnsResult).join(
@@ -49,14 +47,15 @@ async def index():
total_hosts = (await db.execute(select(func.count()).select_from(Host))).scalar()
plugins = current_app.config.get("PLUGINS", {})
traefik_enabled = plugins.get("traefik", {}).get("enabled", False)
poll_interval = current_app.config.get("MONITORS_POLL_INTERVAL", 60)
return await render_template(
"dashboard/index.html",
ping_up=ping_up,
ping_down=ping_down,
dns_ok=dns_ok,
dns_fail=dns_fail,
ping_up=ping_up, ping_down=ping_down,
dns_ok=dns_ok, dns_fail=dns_fail,
total_hosts=total_hosts,
traefik_enabled=traefik_enabled,
poll_interval=poll_interval,
)
View File
+52
View File
@@ -0,0 +1,52 @@
from __future__ import annotations
from quart import Blueprint, current_app, render_template
from sqlalchemy import select, func
from fablednetmon.auth.middleware import require_role
from fablednetmon.models.users import UserRole
from fablednetmon.models.hosts import Host
from fablednetmon.models.monitors import DnsResult
dns_bp = Blueprint("dns", __name__, url_prefix="/dns")
async def _latest_dns(db, host_ids: list[str]) -> dict:
if not host_ids:
return {}
subq = (
select(DnsResult.host_id, func.max(DnsResult.resolved_at).label("max_at"))
.where(DnsResult.host_id.in_(host_ids))
.group_by(DnsResult.host_id)
.subquery()
)
pr = await db.execute(
select(DnsResult).join(
subq,
(DnsResult.host_id == subq.c.host_id) & (DnsResult.resolved_at == subq.c.max_at),
)
)
return {r.host_id: r for r in pr.scalars()}
@dns_bp.get("/")
@require_role(UserRole.viewer)
async def index():
async with current_app.db_sessionmaker() as db:
result = await db.execute(
select(Host).where(Host.dns_enabled.is_(True)).order_by(Host.name)
)
hosts = result.scalars().all()
latest = await _latest_dns(db, [h.id for h in hosts])
poll_interval = current_app.config.get("MONITORS_POLL_INTERVAL", 60)
return await render_template("dns/index.html", hosts=hosts, latest=latest, poll_interval=poll_interval)
@dns_bp.get("/rows")
@require_role(UserRole.viewer)
async def rows():
async with current_app.db_sessionmaker() as db:
result = await db.execute(
select(Host).where(Host.dns_enabled.is_(True)).order_by(Host.name)
)
hosts = result.scalars().all()
latest = await _latest_dns(db, [h.id for h in hosts])
return await render_template("dns/rows.html", hosts=hosts, latest=latest)
+39 -39
View File
@@ -1,40 +1,40 @@
{% extends "base.html" %}
{% block title %}Alerts — FabledNetMon{% endblock %}
{% block content %}
<h1 style="color:#c0c0ff;margin-bottom:1.5rem;">Alerts</h1>
<h1 class="page-title">Alerts</h1>
{% if active %}
<h2 style="color:#8080ff;margin-bottom:1rem;font-size:1rem;">Active</h2>
<div class="card" style="padding:0;margin-bottom:2rem;">
<table style="width:100%;border-collapse:collapse;">
<h2 class="section-title" style="margin-bottom:1rem;">Active</h2>
<div class="card-flush" style="margin-bottom:2rem;">
<table class="table">
<thead>
<tr style="border-bottom:1px solid #2a2a4a;">
<th style="text-align:left;padding:0.75rem 1rem;color:#8080a0;font-weight:normal;">State</th>
<th style="text-align:left;padding:0.75rem 1rem;color:#8080a0;font-weight:normal;">Rule</th>
<th style="text-align:left;padding:0.75rem 1rem;color:#8080a0;font-weight:normal;">Resource</th>
<th style="text-align:left;padding:0.75rem 1rem;color:#8080a0;font-weight:normal;">Metric</th>
<th style="padding:0.75rem 1rem;"></th>
<tr>
<th>State</th>
<th>Rule</th>
<th>Resource</th>
<th>Metric</th>
<th></th>
</tr>
</thead>
<tbody>
{% for state, rule in active %}
<tr style="border-bottom:1px solid #1a1a3a;">
<td style="padding:0.75rem 1rem;">
<tr>
<td>
{% if state.state.value == 'firing' %}
<span style="color:#ff6060;font-weight:bold;">FIRING</span>
<span class="badge badge-red">FIRING</span>
{% elif state.state.value == 'acknowledged' %}
<span style="color:#ffa040;">ACK</span>
<span class="badge badge-yellow">ACK</span>
{% else %}
<span style="color:#a0a0a0;">PENDING</span>
<span class="badge badge-dim">PENDING</span>
{% endif %}
</td>
<td style="padding:0.75rem 1rem;">{{ rule.name }}</td>
<td style="padding:0.75rem 1rem;color:#a0a0c0;">{{ rule.resource_name }}</td>
<td style="padding:0.75rem 1rem;color:#a0a0c0;">{{ rule.metric_name }} {{ rule.operator.value }} {{ rule.threshold }}</td>
<td style="padding:0.75rem 1rem;text-align:right;">
<td>{{ rule.name }}</td>
<td style="color:var(--text-muted);">{{ rule.resource_name }}</td>
<td style="color:var(--text-muted);">{{ rule.metric_name }} {{ rule.operator.value }} {{ rule.threshold }}</td>
<td class="td-actions">
{% if state.state.value == 'firing' %}
<form method="post" action="/alerts/{{ rule.id }}/acknowledge" style="display:inline;">
<button type="submit" class="btn" style="background:#605010;">Acknowledge</button>
<button type="submit" class="btn btn-sm btn-warn">Acknowledge</button>
</form>
{% endif %}
</td>
@@ -44,40 +44,40 @@
</table>
</div>
{% else %}
<p style="color:#606080;margin-bottom:2rem;">No active alerts.</p>
<p style="color:var(--text-muted);margin-bottom:2rem;">No active alerts.</p>
{% endif %}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem;">
<h2 style="color:#8080ff;font-size:1rem;">Rules</h2>
<h2 class="section-title">Rules</h2>
<a class="btn" href="/alerts/rules/new">New Rule</a>
</div>
{% if rules %}
<div class="card" style="padding:0;">
<table style="width:100%;border-collapse:collapse;">
<div class="card-flush">
<table class="table">
<thead>
<tr style="border-bottom:1px solid #2a2a4a;">
<th style="text-align:left;padding:0.75rem 1rem;color:#8080a0;font-weight:normal;">Name</th>
<th style="text-align:left;padding:0.75rem 1rem;color:#8080a0;font-weight:normal;">Condition</th>
<th style="text-align:left;padding:0.75rem 1rem;color:#8080a0;font-weight:normal;">Consec.</th>
<th style="text-align:left;padding:0.75rem 1rem;color:#8080a0;font-weight:normal;">Enabled</th>
<th style="padding:0.75rem 1rem;"></th>
<tr>
<th>Name</th>
<th>Condition</th>
<th>Consec.</th>
<th>Enabled</th>
<th></th>
</tr>
</thead>
<tbody>
{% for rule in rules %}
<tr style="border-bottom:1px solid #1a1a3a;">
<td style="padding:0.75rem 1rem;">{{ rule.name }}</td>
<td style="padding:0.75rem 1rem;color:#a0a0c0;">
<tr>
<td>{{ rule.name }}</td>
<td style="color:var(--text-muted);">
{{ rule.source_module }}/{{ rule.resource_name }}:{{ rule.metric_name }}
{{ rule.operator.value }} {{ rule.threshold }}
</td>
<td style="padding:0.75rem 1rem;color:#a0a0c0;">{{ rule.consecutive_failures_required }}</td>
<td style="padding:0.75rem 1rem;">
{% if rule.enabled %}<span style="color:#60c060;">yes</span>{% else %}<span style="color:#606060;">no</span>{% endif %}
<td style="color:var(--text-muted);">{{ rule.consecutive_failures_required }}</td>
<td>
{% if rule.enabled %}<span class="badge badge-green">yes</span>{% else %}<span class="badge badge-dim">no</span>{% endif %}
</td>
<td style="padding:0.75rem 1rem;text-align:right;">
<td class="td-actions">
<form method="post" action="/alerts/rules/{{ rule.id }}/delete" style="display:inline;">
<button type="submit" style="background:none;border:none;color:#ff6060;cursor:pointer;"
<button type="submit" class="btn btn-sm btn-danger"
onclick="return confirm('Delete rule {{ rule.name }}?')">Delete</button>
</form>
</td>
@@ -87,6 +87,6 @@
</table>
</div>
{% else %}
<p style="color:#606080;">No alert rules configured.</p>
<p style="color:var(--text-muted);">No alert rules configured.</p>
{% endif %}
{% endblock %}
@@ -2,7 +2,7 @@
{% block title %}New Alert Rule — FabledNetMon{% endblock %}
{% block content %}
<div style="max-width:560px;margin:2rem auto;">
<h1 style="color:#c0c0ff;margin-bottom:1.5rem;">New Alert Rule</h1>
<h1 class="page-title">New Alert Rule</h1>
<div class="card">
<form method="post" action="/alerts/rules">
<div class="form-group">
@@ -24,7 +24,7 @@
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;">
<div class="form-group">
<label>Operator</label>
<select name="operator" style="width:100%;padding:0.5rem 0.75rem;background:#0f0f1e;border:1px solid #3a3a5a;border-radius:4px;color:#e0e0e0;">
<select name="operator">
{% for val, label in operators %}
<option value="{{ val }}">{{ label }}</option>
{% endfor %}
@@ -40,8 +40,8 @@
<input type="number" name="consecutive_failures_required" value="1" min="1">
</div>
<div style="display:flex;gap:1rem;margin-top:1.5rem;">
<button type="submit">Create Rule</button>
<a href="/alerts/" style="padding:0.5rem 1rem;color:#a0a0c0;text-decoration:none;">Cancel</a>
<button type="submit" class="btn">Create Rule</button>
<a href="/alerts/" class="btn btn-ghost">Cancel</a>
</div>
</form>
</div>
+18 -19
View File
@@ -2,14 +2,14 @@
{% block title %}Browse Playbooks — FabledNetMon{% endblock %}
{% block content %}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;">
<h1 style="color:#c0c0ff;">Browse Playbooks</h1>
<h1 class="page-title" style="margin-bottom:0;">Browse Playbooks</h1>
<a href="/ansible/" style="color:#a0a0ff;font-size:0.9rem;">← Run History</a>
</div>
{% if view_contents is defined %}
<div class="card">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem;">
<h3 style="color:#8080ff;">{{ view_path }}</h3>
<h3 class="section-title">{{ view_path }}</h3>
<a href="/ansible/browse" style="color:#a0a0ff;font-size:0.85rem;">← Back to browse</a>
</div>
<pre style="background:#0a0a1a;padding:1rem;border-radius:4px;overflow-x:auto;font-size:0.8rem;color:#c0c0c0;max-height:600px;overflow-y:auto;">{{ view_contents }}</pre>
@@ -26,7 +26,7 @@
{% for sd in source_data %}
<div class="card" style="margin-bottom:1.5rem;">
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:1rem;">
<h3 style="color:#8080ff;">{{ sd.source.name }}</h3>
<h3 class="section-title">{{ sd.source.name }}</h3>
<span style="color:#404060;font-size:0.8rem;background:#12122a;padding:0.2rem 0.5rem;border-radius:3px;">{{ sd.source.type }}</span>
<span style="color:#505070;font-size:0.8rem;">{{ sd.source.path }}</span>
</div>
@@ -34,25 +34,25 @@
{% if not sd.playbooks %}
<p style="color:#606080;font-size:0.9rem;">No playbooks found at this path.</p>
{% else %}
<table style="width:100%;border-collapse:collapse;margin-bottom:1rem;">
<table class="table" style="margin-bottom:1rem;">
<thead>
<tr style="background:#12122a;">
<th style="padding:0.5rem 0.75rem;text-align:left;color:#a0a0c0;font-weight:normal;font-size:0.82rem;">Playbook</th>
<th style="padding:0.5rem 0.75rem;text-align:left;color:#a0a0c0;font-weight:normal;font-size:0.82rem;width:120px;"></th>
<tr>
<th>Playbook</th>
<th style="width:120px;"></th>
</tr>
</thead>
<tbody>
{% for pb in sd.playbooks %}
<tr style="border-top:1px solid #1a1a3a;">
<td style="padding:0.5rem 0.75rem;color:#c0c0e0;font-size:0.88rem;font-family:monospace;">{{ pb }}</td>
<td style="padding:0.5rem 0.75rem;">
<a href="/ansible/browse/{{ sd.source.name }}/{{ pb }}" style="color:#6060c0;font-size:0.82rem;margin-right:0.75rem;">View</a>
<tr>
<td style="font-family:ui-monospace,monospace;font-size:0.88rem;">{{ pb }}</td>
<td class="td-actions">
<a href="/ansible/browse/{{ sd.source.name }}/{{ pb }}" class="btn btn-sm btn-ghost" style="margin-right:0.5rem;">View</a>
<a href="#run-form-{{ sd.source.name }}"
onclick="document.getElementById('run-form-{{ sd.source.name }}').style.display='block';
document.getElementById('run-playbook-{{ sd.source.name }}').value='{{ pb }}';
document.getElementById('run-playbook-display-{{ sd.source.name }}').value='{{ pb }}';
return false;"
style="color:#a0a0ff;font-size:0.82rem;">Run</a>
class="btn btn-sm btn-ghost">Run</a>
</td>
</tr>
{% endfor %}
@@ -60,19 +60,19 @@
</table>
{# Run trigger form for this source #}
<div id="run-form-{{ sd.source.name }}" style="display:none;background:#12122a;border-radius:4px;padding:1rem;border:1px solid #2a2a4a;">
<h4 style="color:#8080ff;margin-bottom:0.75rem;">Trigger Run</h4>
<div id="run-form-{{ sd.source.name }}" style="display:none;background:var(--bg-elevated);border-radius:6px;padding:1rem;border:1px solid var(--border-mid);">
<h4 class="section-title" style="margin-bottom:0.75rem;">Trigger Run</h4>
<form hx-post="/ansible/runs" hx-target="#run-result-{{ sd.source.name }}" hx-swap="innerHTML">
<input type="hidden" name="source_name" value="{{ sd.source.name }}">
<input type="hidden" id="run-playbook-{{ sd.source.name }}" name="playbook_path" value="">
<div class="form-group">
<label>Playbook</label>
<input type="text" id="run-playbook-display-{{ sd.source.name }}"
readonly style="color:#8080a0;" value="">
readonly style="color:var(--text-muted);" value="">
</div>
<div class="form-group">
<label>Inventory</label>
<select name="inventory_path" style="width:100%;padding:0.5rem 0.75rem;background:#0f0f1e;border:1px solid #3a3a5a;border-radius:4px;color:#e0e0e0;font-size:1rem;">
<select name="inventory_path">
{% for inv in sd.inventories %}
<option value="{{ inv }}">{{ inv }}</option>
{% endfor %}
@@ -81,8 +81,7 @@
</div>
<div class="form-group">
<label>Or enter inventory path manually</label>
<input type="text" id="manual-inv-{{ sd.source.name }}" placeholder="inventories/production/hosts"
style="color:#c0c0e0;">
<input type="text" id="manual-inv-{{ sd.source.name }}" placeholder="inventories/production/hosts">
</div>
<div style="display:flex;gap:0.75rem;align-items:center;">
<button type="button" class="btn"
@@ -90,7 +89,7 @@
if(manual){this.closest('form').querySelector('[name=inventory_path]').value=manual;}
htmx.trigger(this.closest('form'),'submit');">Execute</button>
<a href="#" onclick="document.getElementById('run-form-{{ sd.source.name }}').style.display='none';return false;"
style="color:#606080;font-size:0.9rem;">Cancel</a>
class="btn btn-ghost btn-sm">Cancel</a>
</div>
</form>
<div id="run-result-{{ sd.source.name }}" style="margin-top:1rem;"></div>
+18 -18
View File
@@ -2,37 +2,37 @@
{% block title %}Ansible Runs — FabledNetMon{% endblock %}
{% block content %}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;">
<h1 style="color:#c0c0ff;">Ansible Runs</h1>
<h1 class="page-title" style="margin-bottom:0;">Ansible Runs</h1>
<a href="/ansible/browse" class="btn">Browse Playbooks</a>
</div>
{% if not runs %}
<div class="card">
<p style="color:#606080;">No runs yet. <a href="/ansible/browse" style="color:#a0a0ff;">Browse playbooks</a> to trigger a run.</p>
<p style="color:var(--text-muted);">No runs yet. <a href="/ansible/browse">Browse playbooks</a> to trigger a run.</p>
</div>
{% else %}
<div class="card" style="padding:0;overflow:hidden;">
<table style="width:100%;border-collapse:collapse;">
<div class="card-flush">
<table class="table">
<thead>
<tr style="background:#12122a;">
<th style="padding:0.75rem 1rem;text-align:left;color:#a0a0c0;font-weight:normal;font-size:0.85rem;">Playbook</th>
<th style="padding:0.75rem 1rem;text-align:left;color:#a0a0c0;font-weight:normal;font-size:0.85rem;">Source</th>
<th style="padding:0.75rem 1rem;text-align:left;color:#a0a0c0;font-weight:normal;font-size:0.85rem;">Status</th>
<th style="padding:0.75rem 1rem;text-align:left;color:#a0a0c0;font-weight:normal;font-size:0.85rem;">Started</th>
<th style="padding:0.75rem 1rem;text-align:left;color:#a0a0c0;font-weight:normal;font-size:0.85rem;"></th>
<tr>
<th>Playbook</th>
<th>Source</th>
<th>Status</th>
<th>Started</th>
<th></th>
</tr>
</thead>
<tbody>
{% for run in runs %}
{% set status_color = {"running": "#a0a000", "success": "#40a040", "failed": "#a04040", "interrupted": "#806040"}.get(run.status.value, "#808080") %}
<tr style="border-top:1px solid #2a2a4a;">
<td style="padding:0.75rem 1rem;color:#e0e0e0;font-size:0.9rem;">{{ run.playbook_path }}</td>
<td style="padding:0.75rem 1rem;color:#8080a0;font-size:0.9rem;">{{ run.source_name }}</td>
<td style="padding:0.75rem 1rem;">
{% set status_color = {"running": "var(--yellow)", "success": "var(--green)", "failed": "var(--red)", "interrupted": "var(--orange)"}.get(run.status.value, "var(--text-muted)") %}
<tr>
<td style="font-size:0.9rem;">{{ run.playbook_path }}</td>
<td style="color:var(--text-muted);font-size:0.9rem;">{{ run.source_name }}</td>
<td>
<span style="color:{{ status_color }};font-size:0.85rem;font-weight:bold;">{{ run.status.value.upper() }}</span>
</td>
<td style="padding:0.75rem 1rem;color:#606080;font-size:0.85rem;">{{ run.started_at.strftime("%Y-%m-%d %H:%M") }}</td>
<td style="padding:0.75rem 1rem;">
<a href="/ansible/runs/{{ run.id }}" style="color:#a0a0ff;font-size:0.85rem;">View</a>
<td style="color:var(--text-muted);font-size:0.85rem;">{{ run.started_at.strftime("%Y-%m-%d %H:%M") }}</td>
<td class="td-actions">
<a href="/ansible/runs/{{ run.id }}" class="btn btn-sm btn-ghost">View</a>
</td>
</tr>
{% endfor %}
@@ -3,7 +3,7 @@
{% block content %}
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem;">
<a href="/ansible/" style="color:#6060c0;font-size:0.9rem;">← Runs</a>
<h1 style="color:#c0c0ff;">Run Detail</h1>
<h1 class="page-title" style="margin-bottom:0;">Run Detail</h1>
</div>
{% set status_color = {"running": "#a0a000", "success": "#40a040", "failed": "#a04040", "interrupted": "#806040"}.get(run.status.value, "#808080") %}
+2 -2
View File
@@ -3,7 +3,7 @@
{% block content %}
<div style="max-width:400px;margin:4rem auto;">
<div class="card">
<h2 style="margin-bottom:1.5rem;color:#c0c0ff;">Sign In</h2>
<h2 class="page-title">Sign In</h2>
<form method="post" action="/login">
<div class="form-group">
<label>Username</label>
@@ -13,7 +13,7 @@
<label>Password</label>
<input type="password" name="password" required>
</div>
<button type="submit">Sign In</button>
<button type="submit" class="btn">Sign In</button>
</form>
</div>
</div>
+3 -3
View File
@@ -3,8 +3,8 @@
{% block content %}
<div style="max-width:400px;margin:4rem auto;">
<div class="card">
<h2 style="margin-bottom:0.5rem;color:#c0c0ff;">First-Run Setup</h2>
<p style="margin-bottom:1.5rem;color:#8080a0;font-size:0.9rem;">Create the initial admin account.</p>
<h2 class="page-title" style="margin-bottom:0.5rem;">First-Run Setup</h2>
<p style="margin-bottom:1.5rem;color:var(--text-muted);font-size:0.9rem;">Create the initial admin account.</p>
<form method="post" action="/setup">
<div class="form-group">
<label>Username</label>
@@ -18,7 +18,7 @@
<label>Password</label>
<input type="password" name="password" required>
</div>
<button type="submit">Create Admin Account</button>
<button type="submit" class="btn">Create Admin Account</button>
</form>
</div>
</div>
+125 -38
View File
@@ -6,43 +6,129 @@
<title>{% block title %}FabledNetMon{% endblock %}</title>
<script src="https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js"></script>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #0f0f0f; color: #e0e0e0; }
nav { background: #1a1a2e; padding: 0.75rem 1.5rem; display: flex; align-items: center; gap: 1rem; border-bottom: 1px solid #2a2a4a; }
nav a { color: #a0a0ff; text-decoration: none; font-size: 0.9rem; }
nav .brand { font-weight: bold; color: #c0c0ff; font-size: 1.1rem; margin-right: auto; }
main { padding: 1.5rem; max-width: 1200px; margin: 0 auto; }
.alert { padding: 0.75rem 1rem; border-radius: 4px; margin-bottom: 1rem; }
.alert-error { background: #3a1a1a; border: 1px solid #8b2020; color: #ffaaaa; }
.card { background: #1a1a2e; border: 1px solid #2a2a4a; border-radius: 6px; padding: 1.25rem; margin-bottom: 1rem; }
.form-group { margin-bottom: 1rem; }
label { display: block; margin-bottom: 0.3rem; font-size: 0.9rem; color: #a0a0c0; }
input[type=text], input[type=email], input[type=password] {
width: 100%; padding: 0.5rem 0.75rem; background: #0f0f1e; border: 1px solid #3a3a5a;
border-radius: 4px; color: #e0e0e0; font-size: 1rem;
}
button[type=submit], .btn {
padding: 0.5rem 1.25rem; background: #4040a0; color: #fff; border: none;
border-radius: 4px; cursor: pointer; font-size: 0.95rem;
}
button[type=submit]:hover, .btn:hover { background: #5050c0; }
/* Ping pills */
.ping-card { padding: 0.75rem 1.25rem; }
.ping-row { display:flex; align-items:center; gap:1rem; padding:0.45rem 0; border-bottom:1px solid #161626; }
.ping-row:last-child { border-bottom:none; }
.ping-meta { min-width:160px; flex-shrink:0; }
.ping-name { font-size:0.9rem; }
.ping-addr { display:block; color:#505070; font-size:0.75rem; }
.ping-pills { display:flex; gap:2px; flex:1; align-items:center; }
.pill { display:inline-block; width:7px; height:22px; border-radius:2px; cursor:default; transition:opacity .1s; }
.pill:hover { opacity:.75; }
.pill-empty { background:#1a1a28; }
.ping-cur { min-width:72px; text-align:right; font-size:0.85rem; font-variant-numeric:tabular-nums; }
.ping-cur-good { color:#22aa44; }
.ping-cur-warn { color:#ccaa20; }
.ping-cur-bad { color:#dd6020; }
.ping-cur-down { color:#cc2020; font-weight:bold; }
.ping-cur-nd { color:#404060; }
:root {
--bg: #09091a;
--bg-card: #111126;
--bg-elevated: #17173a;
--border: #21213d;
--border-mid: #2a2a4a;
--text: #d4d8f0;
--text-muted: #6870a0;
--text-dim: #30305a;
--accent: #5b5ef4;
--accent-hover: #7274f8;
--green: #26d96b;
--green-dim: #0e3a1e;
--yellow: #e6b818;
--yellow-dim: #3a3000;
--orange: #e07828;
--orange-dim: #3a1e00;
--red: #e83848;
--red-dim: #3a0e14;
}
* { 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; }
a:hover { color: var(--accent-hover); }
code { font-family: ui-monospace, monospace; font-size: 0.875em; background: var(--bg-elevated); padding: 0.1em 0.35em; border-radius: 3px; }
/* Nav */
nav { background: var(--bg-card); padding: 0 1.5rem; display: flex; align-items: center; gap: 0; border-bottom: 1px solid var(--border-mid); height: 48px; }
nav .brand { font-weight: 700; color: #a0a8f8; font-size: 1rem; margin-right: 2rem; letter-spacing: -0.02em; }
nav a { color: var(--text-muted); font-size: 0.875rem; padding: 0 0.875rem; height: 48px; display: flex; align-items: center; border-bottom: 2px solid transparent; transition: color .15s, border-color .15s; }
nav a:hover { color: var(--text); border-bottom-color: var(--border-mid); }
nav a.nav-end { margin-left: auto; }
/* Layout */
main { padding: 1.5rem; max-width: 1280px; margin: 0 auto; }
/* Alerts */
.alert { padding: 0.75rem 1rem; border-radius: 6px; margin-bottom: 1rem; font-size: 0.9rem; }
.alert-error { background: var(--red-dim); border: 1px solid #6a1820; color: #ffb0b8; }
.alert-success { background: var(--green-dim); border: 1px solid #1a5a28; color: #90f0b0; }
/* 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; }
/* Typography */
.page-title { font-size: 1.5rem; font-weight: 600; color: #b0b8f8; margin-bottom: 1.5rem; letter-spacing: -0.02em; }
.section-title { font-size: 0.8rem; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.06em; }
/* Tables */
.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); }
.table .td-actions { text-align: right; white-space: nowrap; }
/* Forms */
.form-group { margin-bottom: 1rem; }
label { display: block; margin-bottom: 0.3rem; font-size: 0.85rem; color: var(--text-muted); }
input[type=text], input[type=email], input[type=password], input[type=number], input[type=url], select, textarea {
width: 100%; padding: 0.5rem 0.75rem; background: var(--bg); border: 1px solid var(--border-mid);
border-radius: 5px; color: var(--text); font-size: 0.9rem; font-family: inherit;
transition: border-color .15s;
}
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent); }
select { cursor: pointer; }
textarea { resize: vertical; }
/* Buttons */
.btn { display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.45rem 1rem; background: var(--accent); color: #fff; 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: #fff; }
.btn-sm { padding: 0.3rem 0.65rem; font-size: 0.8rem; }
.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: #7a1820; color: #ffb0b0; }
.btn-danger:hover { background: #9a2030; }
.btn-warn { background: var(--yellow-dim); color: var(--yellow); border: 1px solid #6a5000; }
.btn-warn:hover { background: #4a4000; }
/* Badges / Status */
.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; }
.badge-yellow { background: var(--yellow-dim); color: var(--yellow); }
.badge-orange { background: var(--orange-dim); color: var(--orange); }
.badge-dim { background: var(--bg-elevated); color: var(--text-muted); }
.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); }
/* Empty state */
.empty { color: var(--text-muted); font-size: 0.9rem; padding: 2rem; text-align: center; }
/* Stat cards (dashboard) */
.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; }
/* Ping pills */
.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:160px; flex-shrink:0; }
.ping-name { font-size:0.875rem; font-weight: 500; }
.ping-addr { display:block; color:var(--text-muted); font-size:0.75rem; }
.ping-pills { display:flex; gap:2px; flex:1; align-items:center; }
.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); }
.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 headers */
.widget-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:0.75rem; }
.widget-title { font-size:0.8rem; font-weight:600; color:var(--text-muted); text-transform:uppercase; letter-spacing:0.06em; }
.widget-link { font-size:0.8rem; color:var(--text-muted); }
.widget-link:hover { color:var(--text); }
</style>
{% block head %}{% endblock %}
</head>
@@ -53,12 +139,13 @@
<a href="/">Dashboard</a>
<a href="/hosts/">Hosts</a>
<a href="/ping/">Ping</a>
<a href="/dns/">DNS</a>
<a href="/alerts/">Alerts</a>
<a href="/ansible/">Ansible</a>
{% if session.user_role == 'admin' %}
<a href="/settings/">Settings</a>
{% endif %}
<a href="/logout">Logout ({{ session.username }})</a>
<a href="/logout" class="nav-end">{{ session.username }}</a>
</nav>
{% endif %}
<main>
+63 -36
View File
@@ -1,56 +1,83 @@
{% extends "base.html" %}
{% block title %}Dashboard — FabledNetMon{% endblock %}
{% block content %}
<h1 style="margin-bottom:1.5rem;color:#c0c0ff;">Dashboard</h1>
<h1 class="page-title">Dashboard</h1>
{# ── Summary row ──────────────────────────────────────────────────────────── #}
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:1rem;margin-bottom:1.5rem;">
<div class="card">
<h3 style="color:#8080ff;margin-bottom:0.5rem;font-size:0.85rem;text-transform:uppercase;letter-spacing:.05em;">Hosts</h3>
<span style="font-size:1.8rem;font-weight:bold;color:#c0c0e0;">{{ total_hosts }}</span>
<span style="color:#505070;font-size:0.8rem;margin-left:0.3rem;">configured</span>
<p style="margin-top:0.4rem;"><a href="/hosts/" style="color:#6060a0;font-size:0.8rem;">Manage →</a></p>
{# ── Summary strip ───────────────────────────────────────────────────────── #}
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:0.75rem;margin-bottom:1.5rem;">
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.5rem;">Hosts</div>
<span class="stat-val">{{ total_hosts }}</span>
<span class="stat-label">total</span>
<div style="margin-top:0.5rem;"><a href="/hosts/" style="font-size:0.8rem;">Manage →</a></div>
</div>
<div class="card">
<h3 style="color:#8080ff;margin-bottom:0.5rem;font-size:0.85rem;text-transform:uppercase;letter-spacing:.05em;">Ping</h3>
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.5rem;">Ping</div>
{% if ping_up + ping_down == 0 %}
<p style="color:#505070;font-size:0.85rem;"><a href="/hosts/" style="color:#a0a0ff;">Enable ping</a> on a host.</p>
<span style="color:var(--text-muted);font-size:0.85rem;">No hosts</span>
{% else %}
<div style="display:flex;gap:1rem;align-items:baseline;">
<div><span style="font-size:1.8rem;font-weight:bold;color:#40c040;">{{ ping_up }}</span><span style="color:#505070;font-size:0.8rem;margin-left:0.2rem;">up</span></div>
{% if ping_down %}<div><span style="font-size:1.8rem;font-weight:bold;color:#c04040;">{{ ping_down }}</span><span style="color:#505070;font-size:0.8rem;margin-left:0.2rem;">down</span></div>{% endif %}
</div>
<span class="stat-val" style="color:var(--green);">{{ ping_up }}</span><span class="stat-label">up</span>
{% if ping_down %}&nbsp;<span class="stat-val" style="color:var(--red);">{{ ping_down }}</span><span class="stat-label">down</span>{% endif %}
{% endif %}
</div>
<div class="card">
<h3 style="color:#8080ff;margin-bottom:0.5rem;font-size:0.85rem;text-transform:uppercase;letter-spacing:.05em;">DNS</h3>
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.5rem;">DNS</div>
{% if dns_ok + dns_fail == 0 %}
<p style="color:#505070;font-size:0.85rem;"><a href="/hosts/" style="color:#a0a0ff;">Enable DNS</a> on a host.</p>
<span style="color:var(--text-muted);font-size:0.85rem;">No hosts</span>
{% else %}
<div style="display:flex;gap:1rem;align-items:baseline;">
<div><span style="font-size:1.8rem;font-weight:bold;color:#40c040;">{{ dns_ok }}</span><span style="color:#505070;font-size:0.8rem;margin-left:0.2rem;">ok</span></div>
{% if dns_fail %}<div><span style="font-size:1.8rem;font-weight:bold;color:#c04040;">{{ dns_fail }}</span><span style="color:#505070;font-size:0.8rem;margin-left:0.2rem;">failed</span></div>{% endif %}
</div>
<span class="stat-val" style="color:var(--green);">{{ dns_ok }}</span><span class="stat-label">ok</span>
{% if dns_fail %}&nbsp;<span class="stat-val" style="color:var(--red);">{{ dns_fail }}</span><span class="stat-label">failed</span>{% endif %}
{% endif %}
</div>
<div class="card">
<h3 style="color:#8080ff;margin-bottom:0.5rem;font-size:0.85rem;text-transform:uppercase;letter-spacing:.05em;">Alerts</h3>
<p style="color:#505070;font-size:0.85rem;"><a href="/alerts/" style="color:#a0a0ff;">Configure rules →</a></p>
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.5rem;">Alerts</div>
<a href="/alerts/" style="font-size:0.85rem;">View rules →</a>
</div>
</div>
{# ── Live ping widget ─────────────────────────────────────────────────────── #}
<div class="card ping-card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.75rem;">
<h2 style="color:#8080ff;font-size:0.9rem;text-transform:uppercase;letter-spacing:.05em;">
Ping — last 30 probes
</h2>
<a href="/ping/" style="color:#6060a0;font-size:0.8rem;">Details &amp; settings →</a>
{# ── Widget grid ─────────────────────────────────────────────────────────── #}
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(400px,1fr));gap:1rem;">
{# Ping widget #}
<div class="card ping-card" style="margin-bottom:0;">
<div class="widget-header">
<span class="widget-title">Ping — last 30 probes</span>
<a href="/ping/" class="widget-link">Details &amp; settings →</a>
</div>
<div id="ping-widget"
hx-get="/ping/rows"
hx-trigger="load, every {{ poll_interval }}s"
hx-swap="innerHTML">
</div>
</div>
<div id="ping-widget"
hx-get="/ping/rows"
hx-trigger="load, every {{ poll_interval }}s"
hx-swap="innerHTML">
{# DNS widget #}
<div class="card ping-card" style="margin-bottom:0;">
<div class="widget-header">
<span class="widget-title">DNS</span>
<a href="/dns/" class="widget-link">Details →</a>
</div>
<div id="dns-widget"
hx-get="/dns/rows"
hx-trigger="load, every {{ poll_interval }}s"
hx-swap="innerHTML">
</div>
</div>
{# Traefik widget — only if plugin enabled #}
{% if traefik_enabled %}
<div class="card ping-card" style="margin-bottom:0;">
<div class="widget-header">
<span class="widget-title">Traefik</span>
<a href="/plugins/traefik/" class="widget-link">Details →</a>
</div>
<div id="traefik-widget"
hx-get="/plugins/traefik/widget"
hx-trigger="load, every {{ poll_interval }}s"
hx-swap="innerHTML">
</div>
</div>
{% endif %}
</div>
{% endblock %}
+16
View File
@@ -0,0 +1,16 @@
{% extends "base.html" %}
{% block title %}DNS Monitor — FabledNetMon{% 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;">DNS Monitor</h1>
<span style="color:var(--text-muted);font-size:0.85rem;">polling every {{ poll_interval }}s</span>
</div>
<div class="card ping-card">
<div id="dns-rows"
hx-get="/dns/rows"
hx-trigger="every {{ poll_interval }}s"
hx-swap="innerHTML">
{% include "dns/rows.html" %}
</div>
</div>
{% endblock %}
+29
View File
@@ -0,0 +1,29 @@
{% if hosts %}
{% for host in hosts %}
{% set d = latest.get(host.id) %}
<div class="ping-row">
<div class="ping-meta">
<span class="ping-name">{{ host.name }}</span>
<span class="ping-addr">{{ host.address }}</span>
</div>
<div style="flex:1;">
{% if d is none %}
<span style="color:var(--text-dim);font-size:0.85rem;"></span>
{% elif d.status.value == 'resolved' %}
<span class="dot dot-up" style="margin-right:0.5rem;"></span>
<span style="font-size:0.85rem;color:var(--green);">{{ d.resolved_ip or 'resolved' }}</span>
{% else %}
<span class="dot dot-down" style="margin-right:0.5rem;"></span>
<span style="font-size:0.85rem;color:var(--red);">Failed</span>
{% endif %}
</div>
<div class="ping-cur">
{% if d %}
<span style="color:var(--text-muted);font-size:0.75rem;">{{ d.resolved_at.strftime('%H:%M:%S') }} UTC</span>
{% endif %}
</div>
</div>
{% endfor %}
{% else %}
<p class="empty" style="padding:0.5rem 0;">No DNS-enabled hosts. <a href="/hosts/">Enable DNS on a host →</a></p>
{% endif %}
+4 -4
View File
@@ -2,7 +2,7 @@
{% block title %}{% if host %}Edit Host{% else %}New Host{% endif %} — FabledNetMon{% endblock %}
{% block content %}
<div style="max-width:560px;margin:2rem auto;">
<h1 style="color:#c0c0ff;margin-bottom:1.5rem;">{% if host %}Edit Host{% else %}Add Host{% endif %}</h1>
<h1 class="page-title">{% if host %}Edit Host{% else %}Add Host{% endif %}</h1>
<div class="card">
<form method="post" action="{% if host %}/hosts/{{ host.id }}{% else %}/hosts/{% endif %}">
<div class="form-group">
@@ -15,7 +15,7 @@
</div>
<div class="form-group">
<label>Probe Type</label>
<select name="probe_type" style="width:100%;padding:0.5rem 0.75rem;background:#0f0f1e;border:1px solid #3a3a5a;border-radius:4px;color:#e0e0e0;">
<select name="probe_type">
{% for pt in probe_types %}
<option value="{{ pt.value }}" {% if host and host.probe_type == pt %}selected{% endif %}>
{{ pt.value | upper }}
@@ -44,8 +44,8 @@
<input type="text" name="dns_expected_ip" value="{{ host.dns_expected_ip if host and host.dns_expected_ip else '' }}">
</div>
<div style="display:flex;gap:1rem;margin-top:1.5rem;">
<button type="submit">{% if host %}Save{% else %}Add Host{% endif %}</button>
<a href="/hosts/" style="padding:0.5rem 1rem;color:#a0a0c0;text-decoration:none;">Cancel</a>
<button type="submit" class="btn">{% if host %}Save{% else %}Add Host{% endif %}</button>
<a href="/hosts/" class="btn btn-ghost">Cancel</a>
</div>
</form>
</div>
+34 -34
View File
@@ -2,72 +2,72 @@
{% block title %}Hosts — FabledNetMon{% endblock %}
{% block content %}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;">
<h1 style="color:#c0c0ff;">Hosts</h1>
<h1 class="page-title" style="margin-bottom:0;">Hosts</h1>
<a class="btn" href="/hosts/new">Add Host</a>
</div>
{% if hosts %}
<div class="card" style="padding:0;">
<table style="width:100%;border-collapse:collapse;">
<div class="card-flush">
<table class="table">
<thead>
<tr style="border-bottom:1px solid #2a2a4a;">
<th style="text-align:left;padding:0.75rem 1rem;color:#8080a0;font-weight:normal;">Name</th>
<th style="text-align:left;padding:0.75rem 1rem;color:#8080a0;font-weight:normal;">Address</th>
<th style="text-align:left;padding:0.75rem 1rem;color:#8080a0;font-weight:normal;">Probe</th>
<th style="text-align:center;padding:0.75rem 1rem;color:#8080a0;font-weight:normal;">Ping</th>
<th style="text-align:center;padding:0.75rem 1rem;color:#8080a0;font-weight:normal;">Latency</th>
<th style="text-align:center;padding:0.75rem 1rem;color:#8080a0;font-weight:normal;">DNS</th>
<th style="padding:0.75rem 1rem;"></th>
<tr>
<th>Name</th>
<th>Address</th>
<th>Probe</th>
<th style="text-align:center;">Ping</th>
<th style="text-align:center;">Latency</th>
<th style="text-align:center;">DNS</th>
<th></th>
</tr>
</thead>
<tbody>
{% for host in hosts %}
{% set ping = latest_pings.get(host.id) %}
{% set dns = latest_dns.get(host.id) %}
<tr style="border-bottom:1px solid #1a1a3a;">
<td style="padding:0.75rem 1rem;">{{ host.name }}</td>
<td style="padding:0.75rem 1rem;color:#a0a0c0;">{{ host.address }}</td>
<td style="padding:0.75rem 1rem;color:#a0a0c0;font-size:0.85rem;">
<tr>
<td>{{ host.name }}</td>
<td style="color:var(--text-muted);">{{ host.address }}</td>
<td style="color:var(--text-muted);font-size:0.85rem;">
{{ host.probe_type.value | upper }}{% if host.probe_type.value == 'tcp' %}:{{ host.probe_port }}{% endif %}
</td>
<td style="padding:0.75rem 1rem;text-align:center;">
<td style="text-align:center;">
{% if not host.ping_enabled %}
<span style="color:#404060;font-size:0.8rem;">off</span>
<span style="color:var(--text-dim);font-size:0.8rem;">off</span>
{% elif ping is none %}
<span style="color:#606080;font-size:0.8rem;"></span>
<span style="color:var(--text-muted);font-size:0.8rem;"></span>
{% elif ping.status.value == 'up' %}
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#40c040;" title="Up"></span>
<span class="dot dot-up" title="Up"></span>
{% else %}
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#c04040;" title="Down"></span>
<span class="dot dot-down" title="Down"></span>
{% endif %}
</td>
<td style="padding:0.75rem 1rem;text-align:center;font-size:0.9rem;">
<td style="text-align:center;font-size:0.9rem;">
{% if ping and ping.response_time_ms is not none %}
{% if ping.response_time_ms < 10 %}
<span style="color:#40c040;">{{ ping.response_time_ms | round(1) }} ms</span>
<span style="color:var(--green);">{{ ping.response_time_ms | round(1) }} ms</span>
{% elif ping.response_time_ms < 100 %}
<span style="color:#c0c040;">{{ ping.response_time_ms | round(1) }} ms</span>
<span style="color:var(--yellow);">{{ ping.response_time_ms | round(1) }} ms</span>
{% else %}
<span style="color:#c08040;">{{ ping.response_time_ms | round(1) }} ms</span>
<span style="color:var(--orange);">{{ ping.response_time_ms | round(1) }} ms</span>
{% endif %}
{% else %}
<span style="color:#404060;"></span>
<span style="color:var(--text-dim);"></span>
{% endif %}
</td>
<td style="padding:0.75rem 1rem;text-align:center;">
<td style="text-align:center;">
{% if not host.dns_enabled %}
<span style="color:#404060;font-size:0.8rem;">off</span>
<span style="color:var(--text-dim);font-size:0.8rem;">off</span>
{% elif dns is none %}
<span style="color:#606080;font-size:0.8rem;"></span>
<span style="color:var(--text-muted);font-size:0.8rem;"></span>
{% elif dns.status.value == 'resolved' %}
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#40c040;" title="{{ dns.resolved_ip }}"></span>
<span class="dot dot-up" title="{{ dns.resolved_ip }}"></span>
{% else %}
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#c04040;" title="Failed"></span>
<span class="dot dot-down" title="Failed"></span>
{% endif %}
</td>
<td style="padding:0.75rem 1rem;text-align:right;">
<a href="/hosts/{{ host.id }}/edit" style="color:#a0a0ff;margin-right:1rem;">Edit</a>
<td class="td-actions">
<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;">
<button type="submit" style="background:none;border:none;color:#ff6060;cursor:pointer;"
<button type="submit" class="btn btn-sm btn-danger"
onclick="return confirm('Delete {{ host.name }}?')">Delete</button>
</form>
</td>
@@ -78,7 +78,7 @@
</div>
{% else %}
<div class="card">
<p style="color:#606080;">No hosts configured yet. <a href="/hosts/new" style="color:#a0a0ff;">Add one.</a></p>
<p style="color:var(--text-muted);">No hosts configured yet. <a href="/hosts/new">Add one.</a></p>
</div>
{% endif %}
{% endblock %}
+4 -6
View File
@@ -2,13 +2,13 @@
{% block title %}Ping Monitor — FabledNetMon{% endblock %}
{% block content %}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;">
<h1 style="color:#c0c0ff;">Ping Monitor</h1>
<h1 class="page-title" style="margin-bottom:0;">Ping Monitor</h1>
<span style="color:#505070;font-size:0.85rem;">polling every {{ poll_interval }}s</span>
</div>
{# ── Threshold settings ─────────────────────────────────────────────────── #}
<div class="card" style="margin-bottom:1.25rem;">
<h2 style="color:#8080ff;font-size:0.9rem;text-transform:uppercase;letter-spacing:0.05em;margin-bottom:1rem;">Latency Thresholds</h2>
<h2 class="section-title" style="margin-bottom:1rem;">Latency Thresholds</h2>
<form method="post" action="/ping/settings"
style="display:flex;flex-wrap:wrap;gap:1rem;align-items:flex-end;">
<div class="form-group" style="margin:0;">
@@ -16,16 +16,14 @@
<span style="display:inline-block;width:10px;height:10px;border-radius:2px;background:#1a6632;vertical-align:middle;margin-right:4px;"></span>
Good below (ms)
</label>
<input type="number" name="good_ms" value="{{ good_ms }}" min="1" max="9999"
style="width:100px;padding:0.4rem 0.6rem;background:#0f0f1e;border:1px solid #3a3a5a;border-radius:4px;color:#e0e0e0;">
<input type="number" name="good_ms" value="{{ good_ms }}" min="1" max="9999" style="width:100px;">
</div>
<div class="form-group" style="margin:0;">
<label style="font-size:0.8rem;">
<span style="display:inline-block;width:10px;height:10px;border-radius:2px;background:#6b5c00;vertical-align:middle;margin-right:4px;"></span>
Warn above (ms)
</label>
<input type="number" name="warn_ms" value="{{ warn_ms }}" min="1" max="9999"
style="width:100px;padding:0.4rem 0.6rem;background:#0f0f1e;border:1px solid #3a3a5a;border-radius:4px;color:#e0e0e0;">
<input type="number" name="warn_ms" value="{{ warn_ms }}" min="1" max="9999" style="width:100px;">
</div>
<button type="submit" class="btn" style="padding:0.4rem 1rem;">Save</button>
<span style="color:#505070;font-size:0.8rem;align-self:center;">
+8 -10
View File
@@ -2,13 +2,13 @@
{% extends "base.html" %}
{% block title %}Settings — FabledNetMon{% endblock %}
{% block content %}
<h1 style="color:#c0c0ff;margin-bottom:1.5rem;">Settings</h1>
<h1 class="page-title">Settings</h1>
<form method="post" action="/settings/">
<div style="display:grid;gap:1.5rem;">
{# ── General ──────────────────────────────────────────────────────────── #}
<div class="card">
<h2 style="color:#8080ff;margin-bottom:1rem;font-size:1rem;">General</h2>
<h2 class="section-title" style="margin-bottom:1rem;">General</h2>
<div class="form-group">
<label>Session lifetime (hours)</label>
<input type="number" name="session.lifetime_hours" min="1" max="720"
@@ -28,7 +28,7 @@
{# ── SMTP ─────────────────────────────────────────────────────────────── #}
<div class="card">
<h2 style="color:#8080ff;margin-bottom:1rem;font-size:1rem;">Email (SMTP)</h2>
<h2 class="section-title" style="margin-bottom:1rem;">Email (SMTP)</h2>
<div class="form-group">
<label>SMTP host</label>
<input type="text" name="smtp.host" value="{{ settings['smtp.host'] }}" placeholder="mail.example.com">
@@ -62,7 +62,7 @@
{# ── Webhook ──────────────────────────────────────────────────────────── #}
<div class="card">
<h2 style="color:#8080ff;margin-bottom:1rem;font-size:1rem;">Webhook</h2>
<h2 class="section-title" style="margin-bottom:1rem;">Webhook</h2>
<div class="form-group">
<label>Webhook URL <span style="color:#606080;font-size:0.8rem;">(leave blank to disable)</span></label>
<input type="text" name="webhook.url" value="{{ settings['webhook.url'] }}"
@@ -70,23 +70,21 @@
</div>
<div class="form-group">
<label>Payload template <span style="color:#606080;font-size:0.8rem;">(Jinja2, must produce valid JSON)</span></label>
<textarea name="webhook.template" rows="3"
style="width:100%;background:#0f0f1e;border:1px solid #3a3a5a;border-radius:4px;color:#e0e0e0;padding:0.5rem;font-family:monospace;font-size:0.85rem;">{{ settings['webhook.template'] }}</textarea>
<textarea name="webhook.template" rows="3" style="font-family:ui-monospace,monospace;font-size:0.85rem;">{{ settings['webhook.template'] }}</textarea>
</div>
</div>
{# ── Ansible Sources ──────────────────────────────────────────────────── #}
<div class="card">
<h2 style="color:#8080ff;margin-bottom:1rem;font-size:1rem;">Ansible Sources</h2>
<h2 class="section-title" style="margin-bottom:1rem;">Ansible Sources</h2>
<p style="color:#606080;font-size:0.85rem;margin-bottom:0.75rem;">JSON array of source objects. Each requires <code>name</code>, <code>type</code> (git or local), and either <code>url</code> or <code>path</code>.</p>
<textarea name="ansible.sources" rows="8"
style="width:100%;background:#0f0f1e;border:1px solid #3a3a5a;border-radius:4px;color:#e0e0e0;padding:0.5rem;font-family:monospace;font-size:0.85rem;">{{ settings['ansible.sources'] | tojson(indent=2) }}</textarea>
<textarea name="ansible.sources" rows="8" style="font-family:ui-monospace,monospace;font-size:0.85rem;">{{ settings['ansible.sources'] | tojson(indent=2) }}</textarea>
</div>
{# ── Plugins ──────────────────────────────────────────────────────────── #}
{% if discovered_plugins %}
<div class="card">
<h2 style="color:#8080ff;margin-bottom:1rem;font-size:1rem;">Plugins</h2>
<h2 class="section-title" style="margin-bottom:1rem;">Plugins</h2>
{% for plugin in discovered_plugins %}
<div style="border:1px solid #2a2a4a;border-radius:4px;padding:1rem;margin-bottom:0.75rem;">
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:0.75rem;">
+23
View File
@@ -68,3 +68,26 @@ async def index():
})
return await render_template("traefik/index.html", router_data=router_data)
@traefik_bp.get("/widget")
@require_role(UserRole.viewer)
async def widget():
"""HTMX dashboard widget fragment."""
async with current_app.db_sessionmaker() as db:
result = await db.execute(
select(TraefikMetric.router_name).distinct().order_by(TraefikMetric.router_name)
)
routers = [row[0] for row in result.all()]
router_data = []
for router in routers:
result = await db.execute(
select(TraefikMetric)
.where(TraefikMetric.router_name == router)
.order_by(TraefikMetric.scraped_at.desc())
.limit(1)
)
latest = result.scalar_one_or_none()
if latest:
router_data.append({"name": router, "latest": latest})
return await render_template("traefik/widget.html", router_data=router_data)
+14 -14
View File
@@ -2,7 +2,7 @@
{% extends "base.html" %}
{% block title %}Traefik — FabledNetMon{% endblock %}
{% block content %}
<h1 style="color:#c0c0ff;margin-bottom:1.5rem;">Traefik Routers</h1>
<h1 class="page-title">Traefik Routers</h1>
{% if not router_data %}
<div class="card">
<p style="color:#606080;">No Traefik metrics yet. Configure <code>metrics_url</code> in <code>config.yaml</code> under <code>plugins.traefik</code>.</p>
@@ -11,39 +11,39 @@
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:1rem;">
{% for r in router_data %}
<div class="card">
<h3 style="color:#8080ff;margin-bottom:0.75rem;font-size:0.95rem;word-break:break-all;">{{ r.name }}</h3>
<table style="width:100%;border-collapse:collapse;font-size:0.85rem;">
<h3 class="section-title" style="margin-bottom:0.75rem;word-break:break-all;">{{ r.name }}</h3>
<table class="table" style="font-size:0.85rem;">
<tr>
<td style="color:#606080;padding:0.2rem 0;">Req/s</td>
<td style="color:#c0c0e0;padding:0.2rem 0.5rem;">{{ "%.2f"|format(r.latest.request_rate) }}</td>
<td style="color:var(--text-muted);padding:0.2rem 0;">Req/s</td>
<td style="padding:0.2rem 0.5rem;">{{ "%.2f"|format(r.latest.request_rate) }}</td>
<td style="padding:0.2rem 0;">{{ r.sparkline_req | safe }}</td>
</tr>
<tr>
<td style="color:#606080;padding:0.2rem 0;">p95 ms</td>
<td style="color:#c0c0e0;padding:0.2rem 0.5rem;">{{ "%.1f"|format(r.latest.latency_p95_ms) }}</td>
<td style="color:var(--text-muted);padding:0.2rem 0;">p95 ms</td>
<td style="padding:0.2rem 0.5rem;">{{ "%.1f"|format(r.latest.latency_p95_ms) }}</td>
<td style="padding:0.2rem 0;">{{ r.sparkline_p95 | safe }}</td>
</tr>
<tr>
<td style="color:#606080;padding:0.2rem 0;">p99 ms</td>
<td style="color:#e0e0e0;padding:0.2rem 0.5rem;">{{ "%.1f"|format(r.latest.latency_p99_ms) }}</td>
<td style="color:var(--text-muted);padding:0.2rem 0;">p99 ms</td>
<td style="padding:0.2rem 0.5rem;">{{ "%.1f"|format(r.latest.latency_p99_ms) }}</td>
<td style="padding:0.2rem 0;"></td>
</tr>
<tr>
<td style="color:#606080;padding:0.2rem 0;">4xx %</td>
<td style="color:{% if r.latest.error_rate_4xx_pct > 1 %}#c0a000{% else %}#40a040{% endif %};padding:0.2rem 0.5rem;">
<td style="color:var(--text-muted);padding:0.2rem 0;">4xx %</td>
<td style="color:{% if r.latest.error_rate_4xx_pct > 1 %}var(--yellow){% else %}var(--green){% endif %};padding:0.2rem 0.5rem;">
{{ "%.1f"|format(r.latest.error_rate_4xx_pct) }}%
</td>
<td style="padding:0.2rem 0;"></td>
</tr>
<tr>
<td style="color:#606080;padding:0.2rem 0;">5xx %</td>
<td style="color:{% if r.latest.error_rate_5xx_pct > 0.1 %}#a04040{% else %}#40a040{% endif %};padding:0.2rem 0.5rem;">
<td style="color:var(--text-muted);padding:0.2rem 0;">5xx %</td>
<td style="color:{% if r.latest.error_rate_5xx_pct > 0.1 %}var(--red){% else %}var(--green){% endif %};padding:0.2rem 0.5rem;">
{{ "%.1f"|format(r.latest.error_rate_5xx_pct) }}%
</td>
<td style="padding:0.2rem 0;">{{ r.sparkline_5xx | safe }}</td>
</tr>
</table>
<div style="color:#404060;font-size:0.75rem;margin-top:0.5rem;">
<div style="color:var(--text-dim);font-size:0.75rem;margin-top:0.5rem;">
Last scraped {{ r.latest.scraped_at.strftime("%H:%M:%S") }} UTC
</div>
</div>
@@ -0,0 +1,29 @@
{% if router_data %}
{% for r in router_data %}
<div class="ping-row">
<div class="ping-meta">
<span class="ping-name" style="font-size:0.8rem;word-break:break-all;">{{ r.name }}</span>
</div>
<div style="flex:1;display:flex;gap:1.5rem;font-size:0.82rem;font-variant-numeric:tabular-nums;">
<span title="Requests/s">
<span style="color:var(--text-muted);">req/s</span>
<span style="margin-left:0.3rem;">{{ "%.2f"|format(r.latest.request_rate) }}</span>
</span>
<span title="p95 latency">
<span style="color:var(--text-muted);">p95</span>
<span style="margin-left:0.3rem;
{% if r.latest.latency_p95_ms > 500 %}color:var(--red){% elif r.latest.latency_p95_ms > 200 %}color:var(--yellow){% else %}color:var(--green){% endif %};">
{{ "%.0f"|format(r.latest.latency_p95_ms) }}ms
</span>
</span>
{% if r.latest.error_rate_5xx_pct > 0 %}
<span title="5xx error rate">
<span style="color:var(--red);">{{ "%.1f"|format(r.latest.error_rate_5xx_pct) }}% 5xx</span>
</span>
{% endif %}
</div>
</div>
{% endfor %}
{% else %}
<p class="empty" style="padding:0.5rem 0;font-size:0.85rem;">No Traefik data yet.</p>
{% endif %}