commit e8a87e0d7d7cfdcc0c062ba5e03bcce313db9442
parent 72277aa96ab12512fa01214ea9c75699452166ed
Author: Asher Morgan <asher@ashermorgan.net>
Date: Sun, 30 Aug 2026 12:29:38 -0700
Add optional playbook web pages
Diffstat:
7 files changed, 177 insertions(+), 5 deletions(-)
diff --git a/aoh/client.py b/aoh/client.py
@@ -11,7 +11,7 @@ import time
from urllib.error import HTTPError
from urllib.request import Request, urlopen
-API = '{{ API_URL }}' # Substitution performed by Flask
+API = '{{ API_URL }}' #{# Substitution performed by Flask #}
PASSWORD_PROMPTS = {
'aoh_password': 'AoH password: ',
diff --git a/aoh/playbook.py b/aoh/playbook.py
@@ -26,6 +26,7 @@ class Playbook:
'jinja': bool,
'allow_opts': list, # We'll just assume that elements are strings
'block_opts': list,
+ 'web_description': str,
}
for key, _type in _types.items():
@@ -43,6 +44,7 @@ class Playbook:
self.jinja = dict.get('jinja', False)
self.allow_opts = dict.get('allow_opts', [])
self.block_opts = dict.get('block_opts', [])
+ self.web_description = dict.get('web_description')
self.path = os.path.abspath(os.path.join(PLAYBOOKS_DIR, self.path))
self.config = os.path.abspath(os.path.join(PLAYBOOKS_DIR, self.config))
diff --git a/aoh/server.py b/aoh/server.py
@@ -1,6 +1,6 @@
import os
-from flask import Flask, render_template, request, session
+from flask import Flask, abort, render_template, request, session
from flask_apscheduler import APScheduler
from .playbook import get_playbook
@@ -10,7 +10,7 @@ from .security import *
_RUNNERS = {}
_GC_INTERVAL = 60 # 1 minute
-app = Flask(__name__, template_folder=os.path.dirname(__file__))
+app = Flask(__name__)
app.secret_key = os.urandom(16)
scheduler = APScheduler()
@@ -28,11 +28,17 @@ def gc():
_RUNNERS.pop(id, None)
+@app.context_processor
+def inject_stage_and_region():
+ return {
+ 'API_URL': os.getenv('AOH_ORIGIN', request.host_url[:-1])
+ }
+
+
@app.get('/run')
@app.get('/run.py')
def install():
- api_url = os.getenv('AOH_ORIGIN', request.host_url[:-1])
- return render_template('client.py', API_URL=api_url)
+ return render_template('client.py')
@app.post('/runners/<path:path>')
@@ -88,3 +94,14 @@ def runner_update(path, short_id):
_RUNNERS.pop(id, None)
return res
+
+
+@app.get('/')
+@app.get('/<path:path>')
+def web(path='main.yml'):
+ playbook = get_playbook(path)
+ if not playbook or playbook.web_description is None:
+ return abort(404)
+
+ return render_template('home.html', description=playbook.web_description,
+ playbook=playbook.name)
diff --git a/aoh/static/copy.js b/aoh/static/copy.js
@@ -0,0 +1,23 @@
+// Feather icons
+const COPY_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>';
+const DONE_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>'
+
+// Show copy button only if browser supports JavaScript and clipboard API
+function show_copy_btn() {
+ if ('clipboard' in navigator) {
+ document.getElementById('copy').innerHTML = COPY_SVG;
+ document.getElementById('copy').hidden = false;
+ }
+}
+
+// Copy command to clipboard
+function copy(cmd) {
+ navigator.clipboard.writeText(cmd);
+
+ document.getElementById('copy').disabled = true;
+ document.getElementById('copy').innerHTML = DONE_SVG;
+ setTimeout(() => {
+ document.getElementById('copy').innerHTML = COPY_SVG;
+ document.getElementById('copy').disabled = false;
+ }, 1000);
+}
diff --git a/aoh/static/global.css b/aoh/static/global.css
@@ -0,0 +1,97 @@
+/* Colorschemes */
+:root {
+ --bg1: #fff;
+ --bg2: #eee;
+ --bg3: #e6e6e6;
+ --fg2: #444;
+ --fg1: #000;
+}
+@media only screen and (prefers-color-scheme: dark) {
+ :root {
+ --bg1: #111;
+ --bg2: #222;
+ --bg3: #2a2a2a;
+ --fg2: #bbb;
+ --fg1: #ddd;
+ }
+}
+
+/* Page setup */
+* {
+ margin: 0;
+ border: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+body {
+ color: var(--fg2);
+ background-color: var(--bg1);
+
+ font-family: sans-serif;
+}
+main {
+ padding: 1rem;
+ min-height: 100vh;
+
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+}
+
+/* Description */
+h1 {
+ margin-bottom: 2rem;
+
+ color: var(--fg2);
+
+ font-size: 2rem;
+ font-weight: 100;
+ text-align: center;
+}
+
+/* Command container */
+#cmd-bar {
+ padding: 1rem 2rem;
+ border-radius: 1.5rem;
+
+ background-color: var(--bg2);
+ box-shadow: 0 0 2rem #60686c;
+
+ display: flex;
+ align-items: center;
+ gap: 2rem;
+}
+
+/* Command text */
+#cmd {
+ color: var(--fg1);
+
+ font-size: 1.5rem;
+ font-family: 'Menlo', 'Cascadia Code', 'Lucida Console', monospace;
+}
+#cmd::before {
+ content: '$ ';
+}
+
+/* Command copy button */
+#copy {
+ width: 3rem;
+ height: 3rem;
+ margin: -0.5rem;
+ border-radius: 0.5rem;
+ padding: 0.5rem;
+
+ background-color: var(--bg2);
+
+ cursor: pointer;
+}
+#copy:hover:not(:disabled) {
+ background-color: var(--bg3);
+}
+#copy svg {
+ width: 100%;
+ height: 100%;
+
+ color: var(--fg1);
+}
diff --git a/aoh/templates/client.py b/aoh/templates/client.py
@@ -0,0 +1 @@
+../client.py
+\ No newline at end of file
diff --git a/aoh/templates/home.html b/aoh/templates/home.html
@@ -0,0 +1,31 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ {% if description %}
+ <meta name="description" content="{{ description }}">
+ {% endif %}
+
+ <title>{{ playbook }}</title>
+
+ <link rel="stylesheet" href="{{ url_for('static', filename='global.css') }}">
+ <script src="{{ url_for('static', filename='copy.js') }}"></script>
+ </head>
+
+ <body onload="show_copy_btn()">
+ <main>
+ {% if description %}
+ <h1>{{ description }}</h1>
+ {% endif %}
+
+ {% set cmd = 'curl %s/run | python3' % API_URL %}
+ {% if playbook != 'main.yml' %}
+ {% set cmd = cmd + ' - %s' % playbook %}
+ {% endif %}
+ <div id="cmd-bar">
+ <code id="cmd">{{ cmd }}</code>
+ <button id="copy" onclick="copy('{{ cmd }}')" hidden></button>
+ </div>
+ </main>
+ </body>
+</html>