Compare commits
4 Commits
f57e28f50b
...
master
Author | SHA1 | Date |
---|---|---|
|
f9145b63d9 | |
![]() |
309b37745b | |
![]() |
d6286ac3e4 | |
![]() |
7802bb963e |
|
@ -1,3 +1,5 @@
|
||||||
__pycache__
|
__pycache__
|
||||||
*.py[co]
|
*.py[co]
|
||||||
*.sqlite
|
*.sqlite
|
||||||
|
data/
|
||||||
|
dist/
|
||||||
|
|
|
@ -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/
|
|
@ -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"
|
|
@ -0,0 +1,3 @@
|
||||||
|
from statusforce.app import get_app
|
||||||
|
|
||||||
|
app = get_app()
|
|
@ -0,0 +1,64 @@
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling", "hatch-vcs"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "StatusForce"
|
||||||
|
description = "Simple status page for system status"
|
||||||
|
readme = "README.rst"
|
||||||
|
license = "MIT"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
authors = [
|
||||||
|
{ name = "Raoul Snyman", email = "raoul@libertytechforce.com" },
|
||||||
|
]
|
||||||
|
keywords = [
|
||||||
|
"status",
|
||||||
|
"website",
|
||||||
|
]
|
||||||
|
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",
|
||||||
|
]
|
||||||
|
dependencies = [
|
||||||
|
"Flask",
|
||||||
|
"Flask-Admin",
|
||||||
|
"Flask-Login",
|
||||||
|
"Flask-SQLAlchemy",
|
||||||
|
"psycopg2-binary",
|
||||||
|
"pymysql",
|
||||||
|
"greenlet<0.5,>=0.4.5"
|
||||||
|
]
|
||||||
|
dynamic = ["version"]
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Homepage = "https://libertytechforce.com"
|
||||||
|
Issues = "https://git.libertytechforce.com/libertytechforce/statusforce/issues"
|
||||||
|
Source = "https://git.libertytechforce.com/libertytechforce/statusforce"
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
tests = [
|
||||||
|
"pytest",
|
||||||
|
"pytest-faker",
|
||||||
|
"pytest-flask"
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.hatch.version]
|
||||||
|
source = "vcs"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.sdist]
|
||||||
|
include = [
|
||||||
|
"/statusforce",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
include = [
|
||||||
|
"statusforce",
|
||||||
|
]
|
37
setup.cfg
37
setup.cfg
|
@ -1,37 +0,0 @@
|
||||||
[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
6
setup.py
|
@ -1,6 +0,0 @@
|
||||||
"""
|
|
||||||
The statusforce package
|
|
||||||
"""
|
|
||||||
from setuptools import setup
|
|
||||||
|
|
||||||
setup()
|
|
|
@ -1,36 +1,48 @@
|
||||||
from flask import Flask, render_template
|
import os
|
||||||
|
from flask import Flask
|
||||||
from flask_admin import Admin
|
from flask_admin import Admin
|
||||||
from flask_admin.contrib.sqla import ModelView
|
from flask_login import LoginManager
|
||||||
|
|
||||||
from statusforce.db import db, session
|
from statusforce.db import db, session
|
||||||
from statusforce.models import Service, Incident
|
from statusforce.models import Service, Incident, User
|
||||||
|
from statusforce.views import AuthAdminIndexView, AuthModelView, main
|
||||||
|
|
||||||
|
|
||||||
|
def get_app():
|
||||||
# Set up Flask application and initial config
|
# Set up Flask application and initial config
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.secret_key = b'GSADFGST#$%^$%&^2345234534576476'
|
app.secret_key = b'super-secret-please-replace-me'
|
||||||
app.config['FLASK_ADMIN_SWATCH'] = 'materia'
|
app.config['FLASK_ADMIN_SWATCH'] = 'cosmo'
|
||||||
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///statusforce.sqlite'
|
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///statusforce.sqlite'
|
||||||
|
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||||
|
|
||||||
|
# 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
|
# Set up database
|
||||||
db.init_app(app)
|
db.init_app(app)
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
db.create_all()
|
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
|
# Set up admin interface
|
||||||
admin = Admin(app, name='StatusForce', template_mode='bootstrap4')
|
admin = Admin(app, name='StatusForce', index_view=AuthAdminIndexView(), template_mode='bootstrap4')
|
||||||
admin.add_view(ModelView(Service, session))
|
admin.add_view(AuthModelView(Service, session))
|
||||||
admin.add_view(ModelView(Incident, session))
|
admin.add_view(AuthModelView(Incident, session))
|
||||||
|
|
||||||
|
# Finally, attache the blueprint
|
||||||
@app.route('/')
|
app.register_blueprint(main)
|
||||||
def index():
|
return app
|
||||||
"""
|
|
||||||
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)
|
|
||||||
|
|
|
@ -7,9 +7,11 @@ from statusforce.db import Model, Column, ForeignKey, Boolean, DateTime, Enum, I
|
||||||
|
|
||||||
|
|
||||||
class Service(Model):
|
class Service(Model):
|
||||||
|
__tablename__ = 'services'
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
name = Column(String, nullable=False)
|
name = Column(String, nullable=False)
|
||||||
status = Column(Enum('operational', 'unclear', 'offline'), default='operational')
|
status = Column(Enum('operational', 'unclear', 'offline', native_enum=False), default='operational')
|
||||||
incidents = relationship('Incident', backref=backref('service', lazy=True))
|
incidents = relationship('Incident', backref=backref('service', lazy=True))
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
@ -17,8 +19,10 @@ class Service(Model):
|
||||||
|
|
||||||
|
|
||||||
class Incident(Model):
|
class Incident(Model):
|
||||||
|
__tablename__ = 'incidents'
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
service_id = Column(Integer, ForeignKey('service.id'))
|
service_id = Column(Integer, ForeignKey('services.id'))
|
||||||
title = Column(String, nullable=False)
|
title = Column(String, nullable=False)
|
||||||
description = Column(Text)
|
description = Column(Text)
|
||||||
is_resolved = Column(Boolean, default=False)
|
is_resolved = Column(Boolean, default=False)
|
||||||
|
@ -27,3 +31,27 @@ class Incident(Model):
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return '{} ({})'.format(self.title, self.service.name)
|
return '{} ({})'.format(self.title, self.service.name)
|
||||||
|
|
||||||
|
|
||||||
|
class User(Model):
|
||||||
|
__tablename__ = 'users'
|
||||||
|
|
||||||
|
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
|
||||||
|
|
Binary file not shown.
After Width: | Height: | Size: 65 KiB |
|
@ -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 %}
|
|
@ -6,6 +6,7 @@
|
||||||
<title>System Status</title>
|
<title>System Status</title>
|
||||||
<link href="{{url_for('static', filename='css/bootstrap.min.css')}}" rel="stylesheet">
|
<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 href="{{url_for('static', filename='css/bootstrap-icons.css')}}" rel="stylesheet">
|
||||||
|
<link rel="icon" href="{{url_for('static', filename='img/ltf-logo.png')}}">
|
||||||
<style>
|
<style>
|
||||||
.bd-placeholder-img {
|
.bd-placeholder-img {
|
||||||
font-size: 1.125rem;
|
font-size: 1.125rem;
|
||||||
|
@ -26,7 +27,7 @@
|
||||||
<main>
|
<main>
|
||||||
<div class="py-5 text-center">
|
<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">
|
<img class="d-block mx-auto mb-4" src="{{url_for('static', filename='img/ltf-logo.svg')}}" alt="" width="72" height="57">
|
||||||
<h2>Server Status</h2>
|
<h2>Systems Status</h2>
|
||||||
<p class="lead">The current status of the Liberty Tech Force systems.</p>
|
<p class="lead">The current status of the Liberty Tech Force systems.</p>
|
||||||
</div>
|
</div>
|
||||||
{% if overall_status == 'operational' %}
|
{% if overall_status == 'operational' %}
|
||||||
|
@ -55,13 +56,21 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{{service.name}}
|
{{service.name}}
|
||||||
{% if service.status == 'operational' %}
|
{% if service.status == 'operational' %}
|
||||||
<span class="p-2 bg-success rounded-circle">
|
<span class="text-success">
|
||||||
{% elif service.status == 'unclear' %}
|
{% elif service.status == 'unclear' %}
|
||||||
<span class="p-2 bg-warning rounded-circle">
|
<span class="text-warning">
|
||||||
{% elif service.status == 'offline' %}
|
{% elif service.status == 'offline' %}
|
||||||
<span class="p-2 bg-danger rounded-circle">
|
<span class="text-danger">
|
||||||
|
{% endif %}
|
||||||
|
{{service.status.title()}}
|
||||||
|
|
||||||
|
{% 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 %}
|
{% endif %}
|
||||||
<span class="visually-hidden">{{service.status}}</span>
|
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
|
@ -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)
|
Loading…
Reference in New Issue