Get the ball rolling

- Add templates
- Add static assets
- Add module setup
- Add services to the page
- Add incidents to the page
- Add authentication to the admin section
- Add Dockerfile and docker-compose.yaml
master
Raoul Snyman 2021-09-17 10:06:49 -07:00
parent 9e6cb91da9
commit 7802bb963e
No known key found for this signature in database
GPG Key ID: F55BCED79626AE9C
20 changed files with 1918 additions and 14 deletions

4
.gitignore vendored 100644
View File

@ -0,0 +1,4 @@
__pycache__
*.py[co]
*.sqlite
data/

6
Dockerfile 100644
View File

@ -0,0 +1,6 @@
FROM tiangolo/meinheld-gunicorn-flask
LABEL org.opencontainers.image.authors="Liberty Tech Force"
RUN pip install --no-cache-dir flask_admin flask_sqlalchemy flask_login psycopg2-binary pymysql 'greenlet<0.5,>=0.4.5'
COPY ./statusforce /app/statusforce
COPY ./docker/main.py /app/

4
README.rst 100644
View File

@ -0,0 +1,4 @@
StatusForce
===========
StatusForce is a simple, open source status page written in Python and Flask.

View File

@ -0,0 +1,20 @@
version: '3'
services:
web:
build:
context: .
ports:
- "8000:80"
environment:
- SQLALCHEMY_DATABASE_URI=postgresql+psycopg2://statusforce:statusforce@db/statusforce?sslmode=disable
- FLASK_SECRET_KEY=my-super-secret-key
depends_on:
- db
db:
image: postgres
environment:
- POSTGRES_USER=statusforce
- POSTGRES_PASSWORD=statusforce
- POSTGRES_DB=statusforce
volumes:
- "./data:/var/lib/postgresql/data"

3
docker/main.py 100644
View File

@ -0,0 +1,3 @@
from statusforce.app import get_app
app = get_app()

37
setup.cfg 100644
View File

@ -0,0 +1,37 @@
[metadata]
name = StatusForce
version = 0.0.1
author = Raoul Snyman
author_email = raoul@libertytechforce.com
description = Simple status page for system status
long_description = file:README.rst
long_description_content_type = text/x-rst
url = https://libertytechforce.com
license = MIT
classifiers =
Development Status :: 3 - Alpha
Intended Audience :: Developers
License :: OSI Approved :: MIT License
Operating System :: POSIX
Programming Language :: Python :: 3
Programming Language :: Python :: 3.7
Programming Language :: Python :: 3.8
Programming Language :: Python :: 3.9
Programming Language :: Python :: 3.10
Topic :: Utilities
keywords = website, status
[options]
py_modules = statusforce
python_requires = >=3.7
install_requires =
Flask
Flask-Admin
Flask-SQLAlchemy
Flask-Login
[bdist_wheel]
universal = 1
[flake8]
max-line-length = 120

6
setup.py 100644
View File

@ -0,0 +1,6 @@
"""
The statusforce package
"""
from setuptools import setup
setup()

View File

@ -1,17 +1,48 @@
import os
from flask import Flask
from flask_admin import Admin
from flask_admin.contrib.sqla import ModelView
from flask_login import LoginManager
from statusforce.db import db
from statusforce.models import Service, Incident
from statusforce.db import db, session
from statusforce.models import Service, Incident, User
from statusforce.views import AuthAdminIndexView, AuthModelView, main
# Set up Flask application
app = Flask(__name__)
app.config['FLASK_ADMIN_SWATCH'] = 'materia'
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:////tmp/test.db'
def get_app():
# Set up Flask application and initial config
app = Flask(__name__)
app.secret_key = b'super-secret-please-replace-me'
app.config['FLASK_ADMIN_SWATCH'] = 'cosmo'
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///statusforce.sqlite'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db.init_app(app)
admin = Admin(app, name='StatusForce', template_mode='bootstrap4')
admin.add_view(ModelView(Service))
admin.add_view(ModelView(Incident))
# Pull configuration from environment variables
for key, value in os.environ.items():
if key.startswith('FLASK_ADMIN_') or key.startswith('SQLALCHEMY_'):
app.config[key] = value
elif key == 'FLASK_SECRET_KEY':
# Special case, apparently
app.secret_key = value
elif key.startswith('FLASK_'):
app.config[key.replace('FLASK_', '')] = value
# Set up database
db.init_app(app)
with app.app_context():
db.create_all()
# Set up the authentication
login_manager = LoginManager(app)
@login_manager.user_loader
def load_user(user_id):
return User.query.get(user_id)
# Set up admin interface
admin = Admin(app, name='StatusForce', index_view=AuthAdminIndexView(), template_mode='bootstrap4')
admin.add_view(AuthModelView(Service, session))
admin.add_view(AuthModelView(Incident, session))
# Finally, attache the blueprint
app.register_blueprint(main)
return app

View File

@ -3,12 +3,15 @@ from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()
# Pull out some attributes to make life easier
session = db.session
Model = db.Model
Column = db.Column
ForeignKey = db.ForeignKey
Boolean = db.Boolean
DateTime = db.DateTime
Enum = db.Enum
Integer = db.Integer
String = db.String
Text = db.Text
relationship = db.relationship
backref = db.backref

View File

@ -2,18 +2,50 @@ from datetime import datetime
from sqlalchemy.sql.expression import func
from statusforce.db import Model, Column, ForeignKey, DateTime, Integer, String
from statusforce.db import Model, Column, ForeignKey, Boolean, DateTime, Enum, Integer, String, Text, \
relationship, backref
class Service(Model):
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String, nullable=False)
status = Column(String, choices=['operational', 'unclear', 'offline'])
status = Column(Enum('operational', 'unclear', 'offline', native_enum=False), default='operational')
incidents = relationship('Incident', backref=backref('service', lazy=True))
def __str__(self):
return self.name
class Incident(Model):
id = Column(Integer, primary_key=True, autoincrement=True)
service_id = Column(Integer, ForeignKey('service.id'))
title = Column(String, nullable=False)
description = Column(Text)
is_resolved = Column(Boolean, default=False)
created = Column(DateTime, nullable=False, default=datetime.utcnow)
updated = Column(DateTime, unupdate=func.current_timestamp())
updated = Column(DateTime, onupdate=func.current_timestamp())
def __str__(self):
return '{} ({})'.format(self.title, self.service.name)
class User(Model):
id = Column(Integer, primary_key=True, autoincrement=True)
full_name = Column(String)
email = Column(String, nullable=False)
password = Column(String, nullable=False)
is_active = Column(Boolean, default=True)
@property
def is_authenticated(self):
return True
@property
def is_anonymous(self):
return False
def get_id(self):
return self.id
def __str__(self):
return self.full_name or self.email

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

View File

@ -0,0 +1,119 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="260mm"
height="260mm"
version="1.1"
viewBox="0 0 260 260"
id="svg855"
sodipodi:docname="ltf-logo.svg"
inkscape:export-filename="/home/raoul/Nextcloud/Documents/LibertyTechForce/ltf-logo.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
inkscape:version="1.0.2 (e86c870879, 2021-01-15)">
<defs
id="defs859" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1015"
id="namedview857"
showgrid="false"
inkscape:zoom="0.83140224"
inkscape:cx="491.33858"
inkscape:cy="491.33858"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg855" />
<metadata
id="metadata833">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
transform="translate(43.957 -17.442)"
id="g853">
<path
d="m86.043 22.059c-39.82-5.3e-5 -79.258 7.7669-116.1 22.866l-4.4511 1.8241 0.37669 4.7952c6.6963 85.322 48.803 163.96 116.1 216.82l4.0744 3.201 4.0744-3.201c67.302-52.869 109.41-131.5 116.11-216.82l0.37616-4.7952-4.4505-1.8236c-36.847-15.1-76.286-22.868-116.11-22.868z"
color="#000000"
color-rendering="auto"
dominant-baseline="auto"
fill="none"
image-rendering="auto"
shape-rendering="auto"
solid-color="#000000"
stop-color="#000000"
stroke="#00a9ff"
stroke-linecap="round"
stroke-width="9.2339"
style="font-feature-settings:normal;font-variant-alternates:normal;font-variant-caps:normal;font-variant-east-asian:normal;font-variant-ligatures:normal;font-variant-numeric:normal;font-variant-position:normal;font-variation-settings:normal;inline-size:0;isolation:auto;mix-blend-mode:normal;shape-margin:0;shape-padding:0;text-decoration-color:#000000;text-decoration-line:none;text-decoration-style:solid;text-indent:0;text-orientation:mixed;text-transform:none;white-space:normal"
id="path835" />
<path
d="m86.042 35.423c-36.485-5.3e-5 -72.627 6.8321-106.59 20.095 7.426 78.046 45.817 149.85 106.59 199.36 60.769-49.515 99.161-121.32 106.59-199.36-33.959-13.263-70.102-20.096-106.59-20.097z"
color="#000000"
color-rendering="auto"
dominant-baseline="auto"
fill="#00a9ff"
fill-rule="evenodd"
image-rendering="auto"
shape-rendering="auto"
solid-color="#000000"
stop-color="#000000"
style="font-feature-settings:normal;font-variant-alternates:normal;font-variant-caps:normal;font-variant-east-asian:normal;font-variant-ligatures:normal;font-variant-numeric:normal;font-variant-position:normal;font-variation-settings:normal;inline-size:0;isolation:auto;mix-blend-mode:normal;shape-margin:0;shape-padding:0;text-decoration-color:#000000;text-decoration-line:none;text-decoration-style:solid;text-indent:0;text-orientation:mixed;text-transform:none;white-space:normal"
id="path837" />
<g
transform="matrix(.26382 0 0 .26382 -1336.7 -404.22)"
fill="#fff"
id="g851">
<path
d="m5352.5 1709.1s1.8115 11.734 1.7243 30.702c-0.09 19.529-2.5476 44.4-11.336 72.898-9.507 30.713-24.958 61.051-46.461 90.248-5.0966 6.9914-10.776 14.22-16.487 21.719-3.3072 4.3423-6.6851 8.8526-10.036 13.521-9.2086 12.678-18.137 26.635-25.058 41.958-7.6521 17.178-11.474 34.532-12.175 51.292-0.7637 18.134 2.0741 35.132 7.1133 50.426 6.0002 18.23 14.964 33.594 24.243 45.894 9.8042 13 19.931 22.51 27.18 28.626 7.3049 6.1634 11.869 9.1825 11.869 9.1825s-2.1535-4.2599-5.1401-11.919c-2.9974-7.6862-6.6513-18.653-8.9284-32.125-2.1545-12.754-2.9786-27.224-1.2734-42.978 1.4321-13.183 4.593-26.798 9.8393-40.4 1.5837-4.1002 3.3452-8.1707 5.278-12.199-0.066 8.8009 1.804 17.074 4.9134 24.585 4.0812 9.8702 10.178 18.189 16.49 24.848 6.6688 7.0385 13.557 12.188 18.487 15.499 4.9688 3.3369 8.073 4.9716 8.073 4.9716s-1.4649-2.3069-3.4964-6.4535c-2.0386-4.1616-4.5234-10.1-6.0723-17.393-1.4656-6.9054-2.0261-14.74-0.8662-23.269 0.9741-7.1374 3.1238-14.509 6.6922-21.873 3.2961-6.7927 7.7049-13.44 13.22-19.722 4.9702-5.7674 11.177-11.598 17.587-17.998 2.3746-2.3425 4.7684-4.7396 7.1344-7.219 4.0841-4.2799 8.1465-8.87 11.876-13.783 0.7474-0.9928 1.4695-1.9874 2.1672-2.9841 4.8101 10.333 11.033 20.599 18.854 30.556 5.0335 6.3636 10.363 12.257 15.653 17.726 3.0648 3.1687 6.143 6.2227 9.1896 9.2042 8.239 8.1514 16.144 15.547 22.695 22.964 15.3 16.97 25.1 36.162 29.978 54.892 4.6856 18.186 4.3259 34.074 2.5531 46.169-1.7328 11.823-4.3539 18.9-4.3539 18.9s6.7539-5.3156 15.259-16.217c8.5175-10.917 18.459-27.599 22.255-49.266 3.9891-22.479 0.9691-48.912-14.848-74.687-6.7764-10.884-14.884-20.641-23.058-29.438-2.9805-3.242-5.9635-6.3653-8.8761-9.3692-5.03-5.188-10.007-10.178-14.528-15.029-19.044-20.242-29.917-44.553-36.114-66.89-6.3714-24.38-7.141-41.046-6.0269-54.337 1.2586-15.015 8.7797-49.583 8.7797-49.583s-22.432 42.5-39.254 50.098c1.206-12.117 1.176-23.997 0.094-35.444-3.1047-33.196-14.642-59.919-25.291-78.338-10.556-18.259-19.52-27.968-19.52-27.968z"
id="path839" />
<g
fill-rule="evenodd"
id="g849">
<ellipse
cx="5390.9"
cy="2209.8"
rx="122.99"
ry="42.108"
id="ellipse841" />
<ellipse
cx="5390.9"
cy="2253.7"
rx="107.4"
ry="24.427"
id="ellipse843" />
<path
d="m5306.1 2195.9h169.48l-23.609 189.53h-122.26z"
id="path845" />
<ellipse
cx="5390.9"
cy="2385.2"
rx="69.792"
ry="15.873"
id="ellipse847" />
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.3 KiB

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,22 @@
{% extends 'admin/master.html' %}
{% import 'admin/lib.html' as lib with context %}
{% from 'admin/lib.html' import extra with context %} {# backward compatible #}
{% block head %}
{{ super() }}
{{ lib.form_css() }}
{% endblock head %}
{% block body %}
{{ super() }}
{% if current_user.is_authenticated %}
<p class="lead">Use the menu items above to add services and incidents.</p>
{% else %}
{{lib.render_form(form)}}
{% endif %}
{% endblock body %}
{% block tail %}
{{super()}}
{{lib.form_js()}}
{% endblock tail %}

View File

@ -0,0 +1,128 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>System Status</title>
<link href="{{url_for('static', filename='css/bootstrap.min.css')}}" rel="stylesheet">
<link href="{{url_for('static', filename='css/bootstrap-icons.css')}}" rel="stylesheet">
<link rel="icon" href="{{url_for('static', filename='img/ltf-logo.png')}}">
<style>
.bd-placeholder-img {
font-size: 1.125rem;
text-anchor: middle;
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
}
@media (min-width: 768px) {
.bd-placeholder-img-lg {
font-size: 3.5rem;
}
}
</style>
</head>
<body class="bg-light">
<div class="container px-5">
<main>
<div class="py-5 text-center">
<img class="d-block mx-auto mb-4" src="{{url_for('static', filename='img/ltf-logo.svg')}}" alt="" width="72" height="57">
<h2>Systems Status</h2>
<p class="lead">The current status of the Liberty Tech Force systems.</p>
</div>
{% if overall_status == 'operational' %}
<div class="p-3 mb-5 bg-success text-white" style="border-radius: 3px;">
<i class="bi-check-circle-fill" style="margin-right: 0.5rem;"></i> All systems are operational
</div>
{% elif overall_status == 'unclear' %}
<div class="p-3 mb-5 bg-warning" style="border-radius: 3px;">
<i class="bi-question-circle-fill" style="margin-right: 0.5rem;"></i> Some systems are experiencing problems
</div>
{% elif overall_status == 'offline' %}
<div class="p-3 mb-5 bg-danger text-white" style="border-radius: 3px;">
<i class="bi-exclamation-circle-fill" style="margin-right: 0.5rem;"></i> <strong>There is a major disruption of services</strong>
</div>
{% endif %}
{% if services | length > 0 %}
<h2 class="mt-5 mb-3">Services</h2>
<ul class="list-group">
{% for service in services %}
{% if service.status == 'operational' %}
<li class="list-group-item d-flex justify-content-between align-items-center p-3">
{% elif service.status == 'unclear' %}
<li class="list-group-item d-flex justify-content-between align-items-center p-3 bg-warning" style="--bs-bg-opacity: 0.2">
{% elif service.status == 'offline' %}
<li class="list-group-item d-flex justify-content-between align-items-center p-3 bg-danger" style="--bs-bg-opacity: 0.2">
{% endif %}
{{service.name}}
{% if service.status == 'operational' %}
<span class="text-success">
{% elif service.status == 'unclear' %}
<span class="text-warning">
{% elif service.status == 'offline' %}
<span class="text-danger">
{% endif %}
{{service.status.title()}}
&nbsp;
{% if service.status == 'operational' %}
<i class="bi-check-circle-fill"></i>
{% elif service.status == 'unclear' %}
<i class="bi-question-circle-fill"></i>
{% elif service.status == 'unclear' %}
<i class="bi-exclamation-circle-fill"></i>
{% endif %}
</span>
</li>
{% endfor %}
</ul>
{% endif %}
{% if incidents | length > 0 %}
<h2 class="mt-5 mb-3">Incidents</h2>
{% endif %}
{% for incident in incidents %}
<div class="row">
<div class="col-auto text-center flex-column d-none d-sm-flex">
<div class="row h-50">
{% if loop.first %}
<div class="col">&nbsp;</div>
<div class="col">&nbsp;</div>
{% else %}
<div class="col border-end border-2">&nbsp;</div>
<div class="col border-start border-2">&nbsp;</div>
{% endif %}
</div>
<!-- h5 class="m-2"><span class="rounded-circle bg-white border px-3 py-1"></span></h5 -->
{% if incident.is_resolved %}
<h5 class="m-2"><span class="bi-check-circle-fill text-success" style="font-size: 200%"></span></h5>
{% else %}
<h5 class="m-2"><span class="bi-exclamation-circle-fill text-danger" style="font-size: 200%"></span></h5>
{% endif %}
<div class="row h-50">
{% if loop.last %}
<div class="col">&nbsp;</div>
<div class="col">&nbsp;</div>
{% else %}
<div class="col border-end border-2">&nbsp;</div>
<div class="col border-start border-2">&nbsp;</div>
{% endif %}
</div>
</div>
<div class="col py-2">
<div class="card">
<div class="card-body">
<div class="float-end">{{incident.created.strftime('%H:%M%z - %a %d %B, %Y')}}</div>
<h4 class="card-title text-muted">{{incident.title}} - {{incident.service.name}}</h4>
<p class="card-text text-muted">{{incident.description}}</p>
</div>
</div>
</div>
</div>
{% endfor %}
</main>
<footer class="my-5 pt-5 text-muted text-center text-small">
<p class="mb-1">&copy; 2021 <a href="https://libertytechforce.com">Liberty Tech Force</a> | Powered by <a href="https://git.libertytechforce.com/libertytechforce/statusforce">StatusForce</a></p>
</footer>
</div>
<!-- script src="js/bootstrap.bundle.min.js"></script -->
</body>
</html>

View File

@ -0,0 +1,85 @@
from flask import Blueprint, redirect, request, url_for, render_template
from flask_admin import AdminIndexView, expose
from flask_admin.contrib.sqla import ModelView
from flask_admin.helpers import validate_form_on_submit
from flask_login import current_user, login_user, logout_user
from werkzeug.security import check_password_hash
from wtforms.form import Form
from wtforms.fields import StringField, PasswordField
from wtforms.validators import ValidationError, required
from statusforce.models import Service, Incident, User
# The blueprint for the main page
main = Blueprint('main', __name__, url_prefix='/')
class LoginForm(Form):
email = StringField(validators=[required()])
password = PasswordField(validators=[required()])
def validate_login(self, field):
user = self.get_user()
if user is None or not check_password_hash(user.password, self.password.data):
raise ValidationError('Unable to log user in')
def get_user(self):
return User.query.filter_by(email=self.email.data).first()
class AuthModelView(ModelView):
"""
A derivative ModelView that supports user authentication
"""
def is_accessible(self):
return current_user.is_authenticated
class AuthAdminIndexView(AdminIndexView):
"""
A derivative AdminIndexView that supports user authentication
"""
@expose('/')
def index(self):
"""
Override the index page implementation to check for user permissions, and redirect to the login page
"""
if not current_user.is_authenticated:
return redirect(url_for('.login'))
return super().index()
@expose('/login/', methods=('GET', 'POST'))
def login(self):
"""
A page for the user to log in
"""
form = LoginForm(request.form)
if validate_form_on_submit(form):
user = form.get_user()
login_user(user)
if current_user.is_authenticated:
return redirect(url_for('.index'))
self._template_args['form'] = form
return super().index()
@expose('/logout/')
def logout(self):
"""
Log the user out of the system
"""
logout_user()
return redirect(url_for('.index'))
@main.route('')
def index():
"""
Show the status page
"""
services = Service.query.all()
incidents = Incident.query.order_by(Incident.created.desc()).limit(10).all()
service_statuses = [service.status for service in services]
overall_status = 'operational' if all(status == 'operational' for status in service_statuses) else \
'offline' if all(status == 'offline' for status in service_statuses) else 'unclear'
return render_template('index.html', services=services, incidents=incidents, overall_status=overall_status)