Add a simple built-in web interface

This moves the existing root "/" to "/api/" and replaces it
with a simple built-in web interface.
It is not meant to be a replacement for ara-web but serves as
a base for generating static reports without needing to install
ara-web and it's dependencies.

Change-Id: I2b86e54757892ab886d94c54736989d18605c6ec
This commit is contained in:
David Moreau Simard 2019-07-12 12:35:44 -04:00
parent 9ecf5db227
commit 5ecbb4c9c6
20 changed files with 373 additions and 3 deletions

View File

@ -21,7 +21,7 @@ from rest_framework.test import APITestCase
class RootTestCase(APITestCase):
def test_root_endpoint(self):
result = self.client.get("/")
result = self.client.get("/api/")
self.assertEqual(set(result.data.keys()), set(["kind", "version", "api"]))
self.assertEqual(result.data["kind"], "ara")
self.assertEqual(result.data["version"], pkg_resources.get_distribution("ara").version)

View File

@ -138,6 +138,7 @@ INSTALLED_APPS = [
"rest_framework",
"django_filters",
"ara.api",
"ara.ui",
"ara.server.apps.AraAdminConfig",
]

View File

@ -33,13 +33,14 @@ class APIRoot(APIView):
"api": list(map(lambda x: urllib.parse.urljoin(
request.build_absolute_uri(), x),
[
"api/v1/",
"v1/",
]))
})
urlpatterns = [
path("", APIRoot.as_view()),
path("", include("ara.ui.urls")),
path("api/", APIRoot.as_view()),
path("api/v1/", include("ara.api.urls")),
path("admin/", admin.site.urls),
]

0
ara/ui/__init__.py Normal file
View File

22
ara/ui/apps.py Normal file
View File

@ -0,0 +1,22 @@
# Copyright (c) 2019 Red Hat, Inc.
#
# This file is part of ARA Records Ansible.
#
# ARA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# ARA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with ARA. If not, see <http://www.gnu.org/licenses/>.
from django.apps import AppConfig
class UiConfig(AppConfig):
name = "ara.ui"

View File

@ -0,0 +1,8 @@
Vendored patternfly assets
==========================
https://unpkg.com/@patternfly/patternfly@2.21.5/patternfly.min.css
https://unpkg.com/@patternfly/patternfly@2.21.5/assets/fonts/overpass-webfont/overpass-semibold.woff2
https://unpkg.com/@patternfly/patternfly@2.21.5/assets/fonts/overpass-webfont/overpass-light.woff2
https://unpkg.com/@patternfly/patternfly@2.21.5/assets/fonts/overpass-webfont/overpass-regular.woff2

2
ara/ui/static/css/patternfly.min.css vendored Normal file

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.0 KiB

View File

@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="en" class="layout-pf">
<head>
<meta charset="UTF-8">
<title>{% block title %}{% endblock %}</title>
{% if page == "index" %}
<link rel="stylesheet" href="static/css/patternfly.min.css">
<link rel="shortcut icon" href="static/images/favicon.ico">
{% else %}
<link rel="stylesheet" href="../static/css/patternfly.min.css">
<link rel="shortcut icon" href="../static/images/favicon.ico">
{% endif %}
{% block head %}
{% endblock %}
</head>
<body>
<div class="pf-c-page">
<header role="banner" class="pf-c-page__header">
<div class="pf-c-page__header-brand">
{% if page == "index" %}
<a class="pf-c-page__header-brand-link" href="">
<img class="pf-c-brand" src="static/images/logo.svg" alt="ARA Records Ansible">
{% else %}
<a class="pf-c-page__header-brand-link" href="../">
<img class="pf-c-brand" src="../static/images/logo.svg" alt="ARA Records Ansible">
{% endif %}
</a>
</div>
<div class="pf-c-page__header-nav">
<nav class="pf-c-nav" aria-label="Global">
<button class="pf-c-nav__scroll-button" aria-label="Scroll left">
<i class="fas fa-angle-left" aria-hidden="true"></i>
</button>
<ul class="pf-c-nav__horizontal-list">
<li class="pf-c-nav__item">
{% if page == "index" %}
<a href="" class="pf-c-nav__link pf-m-current" aria-current="page">Playbooks</a>
{% else %}
<a href="../" class="pf-c-nav__link">Playbooks</a>
{% endif %}
</li>
<li class="pf-c-nav__item">
<a href="https://ara.readthedocs.io" class="pf-c-nav__link" target="_blank">Docs</a>
</li>
</ul>
</nav>
</div>
</header>
<main role="main" class="pf-c-page__main">
<section class="pf-c-page__main-section pf-m-light">
{% block body %}
{% endblock %}
</section>
<section class="pf-c-page__main-section">
</section>
</main>
</div>
</body>
</html>

View File

@ -0,0 +1,13 @@
{% extends "base.html" %}
{% block body %}
<div class="pf-c-card" style="margin: 1em 0;" id="properties">
<div class="pf-c-card__header pf-c-title pf-m-md">
File: {{ file.path }}
</div>
</div>
<div class="pf-c-card" style="margin: 1em 0;" id="properties">
<div class="pf-c-card__body">
<pre>{{ file.content | linebreaksbr }}</pre>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,28 @@
{% extends "base.html" %}
{% block body %}
<div class="pf-c-card" style="margin: 1em 0;">
<div class="pf-c-card__header pf-c-title pf-m-md">
Host: {{ host.name }}
</div>
</div>
<div class="pf-c-card" style="margin: 1em 0;">
<div class="pf-c-card__body">
<table class="pf-c-table pf-m-grid-md" role="grid">
<thead>
<tr>
<th>Fact</th>
<th>Value</th>
</tr>
</thead>
<tbody>
{% for fact, value in host.facts.items %}
<tr>
<td id="{{ fact }}" style="white-space: nowrap"><a href="#{{ fact }}">{{ fact }}</a></td>
<td>{{ value | safe }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,41 @@
{% extends "base.html" %}
{% block body %}
<div class="pf-c-card">
<table class="pf-c-table pf-m-grid-md" role="grid" id="playbooks">
<thead>
<tr>
<th>Name</th>
<th>Status</th>
<th>Started</th>
<th>Duration</th>
<th>Plays</th>
<th>Tasks</th>
<th>Results</th>
<th>Hosts</th>
<th>Files</th>
<th>Records</th>
</tr>
</thead>
<tbody>
{% for playbook in playbooks %}
<tr>
<td title="{{ playbook.path }}">
<a href="playbook/{{ playbook.id }}.html">
{% if playbook.name is not None %}{{ playbook.name }}{% else %}{{ playbook.path | truncatechars:30 }}{% endif %}
</a>
</td>
<td>{{ playbook.status }}</td>
<td>{{ playbook.started }}</td>
<td>{{ playbook.duration }}</td>
<td>{{ playbook.items.plays }}</td>
<td>{{ playbook.items.tasks }}</td>
<td>{{ playbook.items.results }}</td>
<td>{{ playbook.items.hosts }}</td>
<td>{{ playbook.items.files }}</td>
<td>{{ playbook.items.records }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@ -0,0 +1,121 @@
{% extends "base.html" %}
{% block body %}
<div class="pf-c-card" style="margin: 1em 0;" id="properties">
<div class="pf-c-card__header pf-c-title pf-m-md">
<a href="#properties">Playbook properties</a>
</div>
<div class="pf-c-card__body">
<ul class="pf-c-list">
<li>Status: {{ playbook.status }}</li>
<li>Name: {{ playbook.name }}</li>
<li>Path: {{ playbook.path }}</li>
<li>Started: {{ playbook.started }}</li>
<li>Ended: {{ playbook.ended }}</li>
<li>Duration: {{ playbook.duration }}</li>
<li>Ansible version: {{ playbook.ansible_version }}</li>
</ul>
</div>
</div>
<div class="pf-c-card" style="margin: 1em 0;" id="arguments">
<div class="pf-c-card__header pf-c-title pf-m-md">
<a href="#arguments">Playbook arguments</a>
</div>
<div class="pf-c-card__body">
<ul class="pf-c-list">
{% for arg, value in playbook.arguments.items %}
<li>{{ arg }}: {{ value }}</li>
{% endfor %}
</ul>
</div>
</div>
<div class="pf-c-card" style="margin: 1em 0;" id="hosts">
<div class="pf-c-card__header pf-c-title pf-m-md">
<a href="#hosts">Playbook hosts</a>
</div>
<div class="pf-c-card__body">
<table class="pf-c-table pf-m-grid-md" role="grid" id="host-table">
<thead>
<tr>
<td>Host</td>
<td>Changed</td>
<td>Failed</td>
<td>Ok</td>
<td>Skipped</td>
<td>Unreachable</td>
</tr>
</thead>
<tbody>
{% for host in playbook.hosts %}
<tr>
<td><a href="../host/{{ host.id }}.html">{{ host.name }}</a></td>
<td>{{ host.changed }}</td>
<td>{{ host.failed }}</td>
<td>{{ host.ok }}</td>
<td>{{ host.skipped }}</td>
<td>{{ host.unreachable }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="pf-c-card" style="margin: 1em 0;" id="results">
<div class="pf-c-card__header pf-c-title pf-m-md">
<a href="#results">Playbook task results</a>
</div>
<div class="pf-c-card__body">
<table class="pf-c-table pf-m-grid-md" role="grid" id="result-table">
<thead>
<tr>
<td>Task</td>
<td>Action</td>
<td>Status</td>
<td>Changed</td>
<td>Host</td>
<td>Path</td>
<td>Handler</td>
<td>Started</td>
<td>Duration</td>
</tr>
</thead>
<tbody>
{% for play in playbook.plays %}
{% for task in play.tasks %}
{% for result in task.results %}
<tr>
<td><a href="../result/{{ result.id }}.html">{{ task.name }}</a></td>
<td>{{ task.action }}</td>
<td>{{ result.status }}</td>
<td>{{ result.changed }}</td>
<td>{{ result.host.name }}</td>
<td>
<a href="../file/{{ task.file.id }}.html">{{ task.file.path | truncatechars:30 }}:{{ task.lineno }}</a>
</td>
<td>{{ task.handler }}</td>
<td>{{ result.started }}</td>
<td>{{ result.duration }}</td>
</tr>
{% endfor %}
{% endfor %}
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="pf-c-card" style="margin: 1em 0;" id="files">
<div class="pf-c-card__header pf-c-title pf-m-md">
<a href="#files">Playbook files</a>
</div>
<div class="pf-c-card__body">
<ul class="pf-c-list">
{% for file in playbook.files %}
<li><a href="../file/{{ file.id }}.html">{{ file.path }}</a></li>
{% endfor %}
</ul>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,28 @@
{% extends "base.html" %}
{% block body %}
<div class="pf-c-card" style="margin: 1em 0;">
<div class="pf-c-card__header pf-c-title pf-m-md">
Result: {{ result.task.name }}
</div>
</div>
<div class="pf-c-card" style="margin: 1em 0;">
<div class="pf-c-card__body">
<table class="pf-c-table pf-m-grid-md" role="grid">
<thead>
<tr>
<th>Field</th>
<th>Value</th>
</tr>
</thead>
<tbody>
{% for field, value in result.content.items %}
<tr>
<td id="{{ field }}" style="white-space: nowrap"><a href="#{{ field }}">{{ field }}</a></td>
<td>{{ value | safe }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}

12
ara/ui/urls.py Normal file
View File

@ -0,0 +1,12 @@
from django.urls import path
from ara.ui import views
app_name = "ui"
urlpatterns = [
path("", views.index, name="index"),
path("playbook/<int:playbook_id>.html", views.playbook, name="playbook"),
path("result/<int:result_id>.html", views.result, name="result"),
path("file/<int:file_id>.html", views.file, name="file"),
path("host/<int:host_id>.html", views.host, name="host"),
]

30
ara/ui/views.py Normal file
View File

@ -0,0 +1,30 @@
from django.shortcuts import render
from ara.clients.offline import AraOfflineClient
client = AraOfflineClient(run_sql_migrations=False)
def index(request):
playbooks = client.get("/api/v1/playbooks")
return render(request, "index.html", {"page": "index", "playbooks": playbooks["results"]})
def playbook(request, playbook_id):
playbook = client.get("/api/v1/playbooks/%s" % playbook_id)
return render(request, "playbook.html", {"playbook": playbook})
def host(request, host_id):
host = client.get("/api/v1/hosts/%s" % host_id)
return render(request, "host.html", {"host": host})
def file(request, file_id):
file = client.get("/api/v1/files/%s" % file_id)
return render(request, "file.html", {"file": file})
def result(request, result_id):
result = client.get("/api/v1/results/%s" % result_id)
return render(request, "result.html", {"result": result})