Compare commits
3 Commits
master
...
f57e28f50b
Author | SHA1 | Date |
---|---|---|
![]() |
f57e28f50b | |
![]() |
7a826cc4bc | |
![]() |
6b29e2a373 |
|
@ -1,5 +1,3 @@
|
||||||
__pycache__
|
__pycache__
|
||||||
*.py[co]
|
*.py[co]
|
||||||
*.sqlite
|
*.sqlite
|
||||||
data/
|
|
||||||
dist/
|
|
||||||
|
|
|
@ -1,6 +0,0 @@
|
||||||
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/
|
|
|
@ -1,20 +0,0 @@
|
||||||
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"
|
|
|
@ -1,3 +0,0 @@
|
||||||
from statusforce.app import get_app
|
|
||||||
|
|
||||||
app = get_app()
|
|
|
@ -1,64 +0,0 @@
|
||||||
[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",
|
|
||||||
]
|
|
|
@ -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
|
|
@ -0,0 +1,6 @@
|
||||||
|
"""
|
||||||
|
The statusforce package
|
||||||
|
"""
|
||||||
|
from setuptools import setup
|
||||||
|
|
||||||
|
setup()
|
|
@ -1,48 +1,36 @@
|
||||||
import os
|
from flask import Flask, render_template
|
||||||
from flask import Flask
|
|
||||||
from flask_admin import Admin
|
from flask_admin import Admin
|
||||||
from flask_login import LoginManager
|
from flask_admin.contrib.sqla import ModelView
|
||||||
|
|
||||||
from statusforce.db import db, session
|
from statusforce.db import db, session
|
||||||
from statusforce.models import Service, Incident, User
|
from statusforce.models import Service, Incident
|
||||||
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
|
# Set up database
|
||||||
for key, value in os.environ.items():
|
db.init_app(app)
|
||||||
if key.startswith('FLASK_ADMIN_') or key.startswith('SQLALCHEMY_'):
|
with app.app_context():
|
||||||
app.config[key] = value
|
db.create_all()
|
||||||
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 admin interface
|
||||||
db.init_app(app)
|
admin = Admin(app, name='StatusForce', template_mode='bootstrap4')
|
||||||
with app.app_context():
|
admin.add_view(ModelView(Service, session))
|
||||||
db.create_all()
|
admin.add_view(ModelView(Incident, session))
|
||||||
|
|
||||||
# Set up the authentication
|
|
||||||
login_manager = LoginManager(app)
|
|
||||||
|
|
||||||
@login_manager.user_loader
|
@app.route('/')
|
||||||
def load_user(user_id):
|
def index():
|
||||||
return User.query.get(user_id)
|
"""
|
||||||
|
Show the status page
|
||||||
# Set up admin interface
|
"""
|
||||||
admin = Admin(app, name='StatusForce', index_view=AuthAdminIndexView(), template_mode='bootstrap4')
|
services = Service.query.all()
|
||||||
admin.add_view(AuthModelView(Service, session))
|
incidents = Incident.query.order_by(Incident.created.desc()).limit(10).all()
|
||||||
admin.add_view(AuthModelView(Incident, session))
|
service_statuses = [service.status for service in services]
|
||||||
|
overall_status = 'operational' if all(status == 'operational' for status in service_statuses) else \
|
||||||
# Finally, attache the blueprint
|
'offline' if all(status == 'offline' for status in service_statuses) else 'unclear'
|
||||||
app.register_blueprint(main)
|
return render_template('index.html', services=services, incidents=incidents, overall_status=overall_status)
|
||||||
return app
|
|
||||||
|
|
|
@ -7,11 +7,9 @@ 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', native_enum=False), default='operational')
|
status = Column(Enum('operational', 'unclear', 'offline'), default='operational')
|
||||||
incidents = relationship('Incident', backref=backref('service', lazy=True))
|
incidents = relationship('Incident', backref=backref('service', lazy=True))
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
@ -19,10 +17,8 @@ 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('services.id'))
|
service_id = Column(Integer, ForeignKey('service.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)
|
||||||
|
@ -31,27 +27,3 @@ 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.
Before Width: | Height: | Size: 65 KiB |
|
@ -1,22 +0,0 @@
|
||||||
{% 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,7 +6,6 @@
|
||||||
<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;
|
||||||
|
@ -27,7 +26,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>Systems Status</h2>
|
<h2>Server 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' %}
|
||||||
|
@ -56,21 +55,13 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{{service.name}}
|
{{service.name}}
|
||||||
{% if service.status == 'operational' %}
|
{% if service.status == 'operational' %}
|
||||||
<span class="text-success">
|
<span class="p-2 bg-success rounded-circle">
|
||||||
{% elif service.status == 'unclear' %}
|
{% elif service.status == 'unclear' %}
|
||||||
<span class="text-warning">
|
<span class="p-2 bg-warning rounded-circle">
|
||||||
{% elif service.status == 'offline' %}
|
{% elif service.status == 'offline' %}
|
||||||
<span class="text-danger">
|
<span class="p-2 bg-danger rounded-circle">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{{service.status.title()}}
|
<span class="visually-hidden">{{service.status}}</span>
|
||||||
|
|
||||||
{% 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>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
|
@ -1,85 +0,0 @@
|
||||||
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