Ansible schedule form: structured playbook-variable fields + first-item dropdown defaults #2

Merged
bvandeusen merged 126 commits from dev into main 2026-06-30 23:44:04 -04:00
2 changed files with 135 additions and 40 deletions
Showing only changes of commit 95ebdf7045 - Show all commits
+113 -40
View File
@@ -51,37 +51,76 @@ code { font-family: ui-monospace, monospace; font-size: 0.875em; background: var
radial-gradient(ellipse 50% 40% at 20% 80%, rgba(200, 168, 64, 0.025) 0%, transparent 65%);
}
/* 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;
position: relative;
z-index: 10;
/* ── App shell: persistent left sidebar + content column ───────────────────── */
.app-shell { display: flex; min-height: 100vh; position: relative; z-index: 1; }
.sidebar {
width: 220px; flex-shrink: 0; background: var(--bg-card);
border-right: 1px solid var(--border-mid);
display: flex; flex-direction: column;
position: sticky; top: 0; height: 100vh; overflow-y: auto; z-index: 20;
}
nav .brand {
font-family: var(--font-serif);
font-weight: 700;
font-size: 1.1rem;
margin-right: 2rem;
letter-spacing: 0.01em;
display: flex;
align-items: center;
gap: 0.45rem;
color: var(--gold);
text-decoration: none;
.sidebar .brand {
font-family: var(--font-serif); font-weight: 700; font-size: 1.15rem;
letter-spacing: 0.01em; color: var(--gold); text-decoration: none;
display: flex; align-items: center; gap: 0.5rem;
padding: 0.85rem 1.25rem; border-bottom: 1px solid var(--border);
}
nav .brand svg { flex-shrink: 0; }
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; }
.sidebar .brand svg { flex-shrink: 0; }
.side-nav { flex: 1; padding: 0.75rem 0; }
.nav-group { margin-bottom: 0.9rem; }
.nav-group-label {
font-size: 0.66rem; text-transform: uppercase; letter-spacing: 0.09em;
color: var(--text-dim); font-weight: 600; padding: 0 1.25rem; margin-bottom: 0.3rem;
}
.side-nav a {
display: flex; align-items: center; gap: 0.5rem;
padding: 0.4rem 1.25rem; font-size: 0.9rem; color: var(--text-muted);
border-left: 2px solid transparent; text-decoration: none;
transition: color .12s, background .12s, border-color .12s;
}
.side-nav a:hover { color: var(--text); background: var(--bg-elevated); }
.side-nav a.active {
color: var(--gold); border-left-color: var(--gold);
background: color-mix(in srgb, var(--gold) 8%, transparent);
}
.side-user { border-top: 1px solid var(--border); padding: 0.7rem 1.25rem; }
.side-user a {
color: var(--text-muted); font-size: 0.84rem; text-decoration: none;
display: flex; align-items: center; gap: 0.4rem;
}
.side-user a:hover { color: var(--text); }
.app-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.topbar { display: none; } /* slim mobile bar; shown only under 900px */
.nav-scrim { display: none; }
/* Layout */
main { padding: 1.5rem 2rem; max-width: 1600px; 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%; box-sizing: border-box; position: relative; z-index: 1; }
/* Responsive: sidebar slides off-canvas under 900px, toggled by ☰ */
@media (max-width: 900px) {
.sidebar {
position: fixed; left: 0; top: 0; bottom: 0; height: 100%;
transform: translateX(-100%); transition: transform .2s ease;
box-shadow: 0 0 30px rgba(0,0,0,0.5);
}
body.nav-open .sidebar { transform: translateX(0); }
.topbar {
display: flex; align-items: center; gap: 0.75rem; height: 48px; padding: 0 1rem;
background: var(--bg-card); border-bottom: 1px solid var(--border-mid);
position: sticky; top: 0; z-index: 15;
}
.sidebar-toggle {
background: none; border: none; color: var(--text-muted); font-size: 1.3rem;
cursor: pointer; line-height: 1; padding: 0.25rem;
}
.topbar .brand-sm {
font-family: var(--font-serif); color: var(--gold); font-weight: 700;
font-size: 1.05rem; text-decoration: none;
}
body.nav-open .nav-scrim {
display: block; position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 18;
}
}
/* Alerts */
.alert { padding: 0.75rem 1rem; border-radius: 6px; margin-bottom: 1rem; font-size: 0.9rem; }
@@ -224,8 +263,10 @@ body.dash-editing .widget-drawer { transform:translateY(0); }
<div id="candle-glow"></div>
{% set _p = request.path %}
<div class="app-shell">
{% if session.user_id is defined %}
<nav>
<aside class="sidebar">
<a href="/" class="brand">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<circle cx="12" cy="12" r="10.5" fill="none" stroke="#c8a840" stroke-width="1.5"/>
@@ -237,18 +278,45 @@ body.dash-editing .widget-drawer { transform:translateY(0); }
</svg>
Steward
</a>
<a href="/">Dashboard</a>
<a href="/status">Status</a>
<a href="/hosts/">Hosts</a>
<a href="/monitors/">Monitors</a>
<a href="/alerts/">Alerts</a>
<a href="/ansible/">Ansible</a>
{% if session.user_role == 'admin' %}
<a href="/settings/">Settings</a>
<a href="/audit/">Audit</a>
{% endif %}
<a href="/logout" class="nav-end">{{ session.username }}</a>
</nav>
<nav class="side-nav" aria-label="Primary">
<div class="nav-group">
<div class="nav-group-label">Overview</div>
<a href="/" {% if _p == '/' %}class="active" aria-current="page"{% endif %}>Dashboard</a>
<a href="/status" {% if _p.startswith('/status') %}class="active" aria-current="page"{% endif %}>Status</a>
</div>
<div class="nav-group">
<div class="nav-group-label">Infrastructure</div>
<a href="/hosts/" {% if _p.startswith('/hosts') %}class="active" aria-current="page"{% endif %}>Hosts</a>
{# plugin links land here in slice 2 (get_nav hook) #}
</div>
<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</a>
</div>
<div class="nav-group">
<div class="nav-group-label">Automation</div>
<a href="/ansible/" {% if _p.startswith('/ansible') %}class="active" aria-current="page"{% endif %}>Ansible</a>
</div>
{% if session.user_role == 'admin' %}
<div class="nav-group">
<div class="nav-group-label">Admin</div>
<a href="/settings/" {% if _p.startswith('/settings') %}class="active" aria-current="page"{% endif %}>Settings</a>
<a href="/audit/" {% if _p.startswith('/audit') %}class="active" aria-current="page"{% endif %}>Audit</a>
</div>
{% endif %}
</nav>
<div class="side-user">
<a href="/logout" title="Log out">⏻ {{ session.username }}</a>
</div>
</aside>
{% endif %}
<div class="app-main">
{% if session.user_id is defined %}
<div class="topbar">
<button class="sidebar-toggle" onclick="document.body.classList.toggle('nav-open')" aria-label="Toggle navigation"></button>
<a href="/" class="brand-sm">Steward</a>
</div>
{% endif %}
<script>
@@ -314,6 +382,11 @@ function setTimeRange(val) {
{% block breadcrumb %}{% endblock %}
{% block content %}{% endblock %}
</main>
</div>{# .app-main #}
</div>{# .app-shell #}
{% if session.user_id is defined %}
<div class="nav-scrim" onclick="document.body.classList.remove('nav-open')"></div>
{% endif %}
{% block extra_scripts %}{% endblock %}
+22
View File
@@ -0,0 +1,22 @@
"""Syntax-parse every first-party template so a broken tag fails the unit lane.
The app only renders a handful of templates in unit tests (e.g. the login page),
so a Jinja syntax error elsewhere would otherwise ship green. env.parse() checks
syntax without resolving extends/includes/imports — enough to catch an unbalanced
or mistyped tag across the whole template tree (e.g. the base.html layout).
"""
import pathlib
import jinja2
import steward
_ROOT = pathlib.Path(steward.__file__).parent / "templates"
def test_all_steward_templates_parse():
env = jinja2.Environment()
files = sorted(_ROOT.rglob("*.html"))
assert files, "no steward templates found"
for f in files:
env.parse(f.read_text()) # raises TemplateSyntaxError on a bad tag