Compare commits

...

71 Commits

Author SHA1 Message Date
7242392233 Added an on hover history button to display history chart. 2025-09-06 10:52:21 +02:00
55e8356b1b Added TODO notes 2025-09-05 21:17:47 +02:00
8023978307 Changed timeout to 1s which will make it ping every 2s. This is to test 2025-09-05 21:12:31 +02:00
5617440013 Did some chart stuff (ugly) 2025-09-05 21:09:31 +02:00
7c5e15af99 gets all logs from past hour rather than past 300 logs 2025-09-05 21:00:05 +02:00
89f5c4ef0b MAJOR REFACTOR P4 FINAL 2025-09-05 20:47:11 +02:00
e7cf9edde9 Update routes.py 2025-09-05 20:45:00 +02:00
a2b13f3d32 MAJOR REFACTOR P3 2025-09-05 20:44:41 +02:00
12af4e7bbe MAJOR REFACTOR P2 2025-09-05 20:41:37 +02:00
4cfd5b2dbe MAJOR REFACTOR P1 2025-09-05 20:39:42 +02:00
3b87da9292 log.dateCreated didn't contain any UTC metadata so now log.dateCreatedUTC() return the datetime object with correct metadata 2025-09-05 18:57:21 +02:00
2ab46f1994 Switched from manual tming to using trace request context. 2025-09-05 18:05:50 +02:00
afd6383daa Added a global config.py 2025-09-05 12:27:01 +02:00
8a5ff68f23 Renamed split_graph() to prepare_chart_data() 2025-09-05 12:11:03 +02:00
05d9ea1104 Fixed graph not being split when over threshold. Added route arg so services can be charted by id. 2025-09-05 12:08:35 +02:00
139be129ee Added a bool timeout to logs. This indicated whether or not the request timed out. Preparing data for the frontend graph is put into it's own function. When data is more than 6 s apart it is considered a break in connectivity and a None point is added. 2025-09-05 11:57:34 +02:00
e1b0ab4dc5 Refactor check_service, split the different ping types to a function called ping 2025-09-05 09:19:04 +02:00
cab5c3b7ec More chart experimentation. Managed to make a chart of ping history display 2025-09-05 08:25:51 +02:00
9e4d2e031d Experimenting with chart.js 2025-09-04 08:18:26 +02:00
e355999c53 Disabled auto decompression for security reasons 2025-09-03 19:47:52 +02:00
39b4ee7a00 Polling of services is done based on services in DB 2025-09-03 18:32:02 +02:00
5ddafd3b98 First big step in moving towards DB storage instead of memory storage 2025-09-03 18:28:08 +02:00
5a7aa44d6c Creates logs when services are pinged 2025-09-03 17:11:02 +02:00
dea5278731 Migrate db to connect logs to service. Deleted any previous logs as they are useless 2025-09-03 16:44:27 +02:00
9908683e45 Added a function that turns a service instance into a dictionary. 2025-09-03 16:05:01 +02:00
96c8c2d5c8 Update requirements.txt 2025-09-03 15:43:44 +02:00
87aacaa08a Add services to db if they don't exist 2025-09-03 15:43:28 +02:00
bb8f3e6c71 Removed packages that aren't used and assed a script to generate db upgrade 2025-09-03 15:20:49 +02:00
bf43999d36 aiohttp seems to have fixed an issue with Plex HEAD requests 2025-09-03 09:01:18 +02:00
3f4dc83929 Forgot to update requirements.txt 2025-09-03 08:19:36 +02:00
5b1b43f9ad Switched from httpx to aiohttp. Hopefully fixes some SSL handshake issues 2025-09-03 08:17:07 +02:00
324f75d100 Fixed margin and padding top 2025-09-02 22:09:52 +02:00
a63e313036 Increased wait bar size and fixed mobile viewing 2025-09-02 22:01:55 +02:00
0c580bc9f1 Improved the progress bar / waiting bar 2025-09-02 21:43:20 +02:00
5a42d998d6 Scuffed progress bar 2025-09-02 20:26:32 +02:00
7ad601fc14 Increased timeout to 4s and runs every 5s. Added a 5s wait in parralel with the pings to set it to a minimum of 5s but allows it to go longer. 2025-09-02 20:26:23 +02:00
446f36fc5b Another small mistake 2025-09-02 10:25:44 +02:00
07325125f1 Fixed build error where permissions were being applied to a deleted file 2025-09-02 10:22:18 +02:00
7d8b5df046 Creates db and assumes it's up to date. This replaces init() 2025-09-02 10:00:12 +02:00
3eb066922d Removed entrypoint as migration is handled by app 2025-09-02 09:43:45 +02:00
1f88bcf396 Added logs for each ping. Contains no data yet and estimates 15.7 mil rows a year. Considering adding log aggregation. Moved some folders and files into mem folder due to circular import. 2025-09-02 09:34:46 +02:00
28493e5790 Moved basic db class/model to models.py 2025-09-01 10:12:50 +02:00
826cf6a26a Update requirements.txt 2025-09-01 10:10:58 +02:00
7ee6ed88c1 Added an SQLite database that will host ping logs. DB file is automatically created and upgraded 2025-09-01 10:05:39 +02:00
f685e2b705 Change Plex from HEAD ping to GET ping, hopefully fixes random disconnect issue 2025-08-31 17:21:02 +02:00
16c61fa482 Added two services 2025-08-31 17:11:05 +02:00
93bc1d7f04 Added option to ping with a different method (HEAD and GET) 2025-08-31 16:28:38 +02:00
b8c5114335 Added a little padding to fix text issues 2025-08-31 15:53:21 +02:00
9c7f6e9a01 Changed page title and added a favicon 2025-08-31 15:45:49 +02:00
b7f32c2bce Hide ping when offline 2025-08-31 15:36:24 +02:00
36c0758b6e Changed icon size and text size 2025-08-31 15:32:20 +02:00
3cf32d9296 Potential mobile text size fix 2025-08-31 15:16:44 +02:00
ea827e1366 Change the frontend to update every 5 seconds 2025-08-31 15:13:42 +02:00
e136c8d605 Displays ping provided by api 2025-08-31 15:07:00 +02:00
6848aab1e1 Added a lock icon to the "private" URLs 2025-08-31 14:54:45 +02:00
903fb525c0 Made the blocks on the dashboard clickable 2025-08-31 14:24:36 +02:00
6103a34ae1 Switched from browser refresh to update webpage with API fetch
Added an ID field to the API. Added a fetch script that updates the webpage every second.
2025-08-31 14:06:55 +02:00
f2a006b397 Updated frontend and changed a little backend
Added svg icons to the applications. Frontend will display the icon by name of [id].[icon_filetype] which defaults to svg. Able to set to other filetypes. Changed the frontend to center the application
2025-08-31 13:31:15 +02:00
7cb07691bb Added icon filetype so icons can be saved as [id].[icon_filetype] 2025-08-31 13:06:07 +02:00
3e8ecf7565 Added labels to each service 2025-08-31 13:00:23 +02:00
4876f16760 Run on all addresses 2025-08-31 12:36:53 +02:00
67cad7f8a9 Set port to 80 and disable the reloader 2025-08-31 12:34:38 +02:00
9323605b48 Renamed DockerFile to Dockerfile 2025-08-31 12:24:52 +02:00
0431ccbec4 Rename DockerFile to Dockerfile 2025-08-31 12:24:28 +02:00
363196c0db Added requirements.txt, removed an unused package 2025-08-31 12:21:34 +02:00
aadcc81d02 Merge branch 'main' of https://github.com/StefBuwalda/dashboard_test 2025-08-31 12:18:09 +02:00
e0349ab058 Set up docker auto build, copied from another project 2025-08-31 12:18:08 +02:00
Stef
f841afa105 Create main.yml 2025-08-31 12:14:56 +02:00
071c15f40d Dynamically build the page. Automatically update by refreshing page 2025-08-31 12:12:49 +02:00
fd0cb02615 Two colors of borders 2025-08-31 11:59:08 +02:00
3b3c49d8df Added a first version of the dashboard 2025-08-31 11:49:11 +02:00
42 changed files with 1198 additions and 127 deletions

46
.github/workflows/main.yml vendored Normal file
View File

@@ -0,0 +1,46 @@
name: Build and Push to GHCR
on:
push:
branches:
- main
permissions:
packages: write
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
# Checkout code
- name: Checkout code
uses: actions/checkout@v4.2.2
# Log in to GHCR
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3.5.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Set up Docker Buildx (needed for ARM64 cross-build)
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.11.1
# Build and push Docker image with caching
- name: Build and push ARM64 image
uses: docker/build-push-action@v6.18.0
with:
context: .
push: true
platforms: linux/arm64
tags: |
ghcr.io/stefbuwalda/dashboard_test:arm64
ghcr.io/stefbuwalda/dashboard_test:latest
cache-from: type=registry,ref=ghcr.io/stefbuwalda/dashboard_test:cache
cache-to: type=registry,ref=ghcr.io/stefbuwalda/dashboard_test:cache,mode=max
build-args: |
PIP_NO_CACHE_DIR=1

14
Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
FROM python:3.12-slim
# Everything will be done in /app (Not in the main OS Image)
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
ENV FLASK_APP=app.py
CMD ["python", "app.py"]

20
app.py Normal file
View File

@@ -0,0 +1,20 @@
from app.flask_app import start_flask, stop_event as flask_stop
from app.aio_client import start_worker, stop_event as aio_stop
import threading
import sys
import time
# Only run if directly running file
if __name__ == "__main__":
threading.Thread(target=start_worker, daemon=True).start()
threading.Thread(target=start_flask, daemon=True).start()
# Optional: monitor stop_event in a separate thread
def monitor_worker():
while not aio_stop.is_set() and not flask_stop.is_set():
time.sleep(1)
print("Worker failed, stopping program...")
sys.exit(1)
monitor_worker()

22
app/__init__.py Normal file
View File

@@ -0,0 +1,22 @@
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate, upgrade, stamp
from .flask_app import app
from pathlib import Path
__all__ = ["app"]
# Create db
db = SQLAlchemy(app=app)
# Set up migration
migrations_dir = Path(__file__).parent / "migrations"
migration = Migrate(app=app, db=db, directory=str(migrations_dir))
# Init and upgrade
with app.app_context():
# Check if DB file is missing
if not (Path("./instance/app.db").is_file()):
db.create_all()
stamp()
# Upgrade db if any new migrations exist
upgrade()

View File

@@ -0,0 +1,3 @@
from .worker import start_worker, stop_event
__all__ = ["start_worker", "stop_event"]

47
app/aio_client/client.py Normal file
View File

@@ -0,0 +1,47 @@
import aiohttp
from app.models import service, log
from types import SimpleNamespace
from typing import Optional
async def ping(
client: aiohttp.ClientSession,
s: service,
ctx: Optional[SimpleNamespace] = None,
) -> int:
ctx = ctx or SimpleNamespace()
match s.ping_method:
case 0:
r = await client.head(
url=s.url,
ssl=True if s.public_access else False,
allow_redirects=True,
trace_request_ctx=ctx, # type: ignore
)
case 1:
r = await client.get(
url=s.url,
ssl=True if s.public_access else False,
allow_redirects=True,
trace_request_ctx=ctx, # type: ignore
)
case _:
raise Exception("UNKNOWN PING METHOD")
return r.status
async def ping_service(client: aiohttp.ClientSession, s: service) -> log:
try:
ctx = SimpleNamespace()
status = await ping(client=client, s=s, ctx=ctx)
if status == 200:
return log(service_id=s.id, ping=int(ctx.duration_ms))
else:
return log(service_id=s.id, ping=None)
except aiohttp.ConnectionTimeoutError:
return log(service_id=s.id, ping=None, timeout=True)
except Exception as e:
print(e)
return log(service_id=s.id, ping=None)

91
app/aio_client/worker.py Normal file
View File

@@ -0,0 +1,91 @@
from sqlalchemy.orm import sessionmaker
from config import timeout as timeout_
import aiohttp
import asyncio
import time
from types import SimpleNamespace
from app import db, app
from app.models import service
from .client import ping_service
import threading
stop_event = threading.Event()
def start_worker():
try:
# Creates new event loop in new thread
loop = asyncio.new_event_loop()
# Creates new task on new loop
loop.create_task(update_services())
# Schedule loop to run forever
loop.run_forever()
except Exception as e:
print("Worker thread exception:", e)
stop_event.set()
def setup_client() -> aiohttp.ClientSession:
timeout = aiohttp.client.ClientTimeout(total=timeout_ / 1000)
# Each request will get its own context
trace_config = aiohttp.TraceConfig()
async def on_start(
session: aiohttp.ClientSession,
context: SimpleNamespace,
params: aiohttp.TraceRequestStartParams,
):
ctx = context.trace_request_ctx
ctx.start = time.perf_counter() # store per-request
async def on_end(
session: aiohttp.ClientSession,
context: SimpleNamespace,
params: aiohttp.TraceRequestEndParams,
):
ctx = context.trace_request_ctx
ctx.end = time.perf_counter()
ctx.duration_ms = int((ctx.end - ctx.start) * 1000)
trace_config.on_request_start.append(on_start)
trace_config.on_request_end.append(on_end)
client = aiohttp.ClientSession(
timeout=timeout, auto_decompress=False, trace_configs=[trace_config]
)
return client
async def update_services():
try:
print("Starting service updates...")
# Create new session
with app.app_context():
WorkerSession = sessionmaker(bind=db.engine)
client = setup_client()
# Actual update loop
while True:
session = WorkerSession()
# TODO: Add http processing time var
# instead of just adding 1 second
sleeptask = asyncio.create_task(asyncio.sleep(timeout_ / 1000 + 1))
tasks = [
ping_service(client=client, s=s)
for s in session.query(service).all()
]
logs = await asyncio.gather(*tasks)
await sleeptask
try:
session.add_all(logs)
session.commit()
except Exception as e:
session.rollback()
raise e
finally:
session.close()
except Exception as e:
print("Worker thread exception:", e)
stop_event.set()

20
app/flask_app/__init__.py Normal file
View File

@@ -0,0 +1,20 @@
import threading
from flask import Flask
stop_event = threading.Event()
# Flask app to serve status
app = Flask(__name__)
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///app.db"
def start_flask() -> None:
try:
# Run flask app
from .routes import bp
app.register_blueprint(bp)
app.run(host="0.0.0.0", port=80, debug=True, use_reloader=False)
except Exception as e:
print("Worker thread exception:", e)
stop_event.set()

101
app/flask_app/routes.py Normal file
View File

@@ -0,0 +1,101 @@
from flask import Blueprint, render_template, abort, jsonify, send_file, json
from typing import cast, Optional, Any
from datetime import datetime, timedelta, timezone
from config import timeout
from ..models import service, log
from app import app, db
bp = Blueprint(
"main",
"__name__",
url_prefix="/",
static_folder="static",
)
# TODO: Move util functions to seperate file (utils.py?)
# Prepares log data for chart.js chart
def prepare_chart_data(
logs: list[log],
) -> tuple[list[str], list[Optional[int]]]:
if len(logs) <= 0: # Return empty if there are no logs
return ([], [])
x = [logs[0].dateCreatedUTC().isoformat()]
y = [logs[0].ping]
for i in range(1, len(logs)):
log1 = logs[i]
log2 = logs[i - 1]
# TODO: add timeout duration to log so this can be used
# instead of 1.5 * the current timeout + 1.
# Check if the gap in points exceeds a threshold
if (abs(log1.dateCreatedUTC() - log2.dateCreatedUTC())) > timedelta(
milliseconds=1.5 * (timeout + 1000)
):
x.append(log2.dateCreatedUTC().isoformat())
y.append(None)
x.append(log1.dateCreatedUTC().isoformat())
y.append(log1.ping)
return (x, y)
@bp.route("/")
def homepage():
return render_template("home.html")
@bp.route("/chart/<int:id>")
def chart(id: int):
one_hour_ago = datetime.now(timezone.utc) - timedelta(hours=1)
with app.app_context():
logs = []
s = db.session.query(service).filter_by(id=id).first()
if s:
logs = cast(
list[log],
s.logs.filter(log.dateCreated >= one_hour_ago)
.order_by(log.dateCreated.desc()) # type: ignore
.all(),
)
else:
return abort(code=403)
x, y = prepare_chart_data(logs=logs)
now = datetime.now(timezone.utc)
max_ = now
min_ = now - timedelta(hours=1)
return render_template(
"chart.html",
dates=x,
values=json.dumps(y),
min=min_.isoformat(),
max=max_.isoformat(),
)
@bp.route("/api/status")
def status():
results: list[dict[str, Any]] = []
with app.app_context():
a = db.session.query(service).all()
for s in a:
b = cast(
Optional[log],
s.logs.order_by(
log.dateCreated.desc() # type: ignore
).first(),
)
if b:
results.append(s.to_dict() | b.to_dict())
return jsonify(results)
@bp.route("/favicon.svg")
def favicon():
return send_file("/static/favicon.svg")

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 15.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="250px" height="250px" viewBox="-77.997 -77.998 250 250" enable-background="new -77.997 -77.998 250 250"
xml:space="preserve">
<circle fill="#009090" cx="47.003" cy="47.002" r="121.834"/>
<g>
<path fill="#FFFFFF" d="M135.518,28.755c0,5.432-0.891,10.791-2.618,15.942c-8.112,36.712-78.279,81.124-81.264,83.017
c-0.934,0.581-1.978,0.884-3.046,0.884c-1.063,0-2.125-0.271-3.06-0.884c-0.8-0.521-20.569-12.996-40.611-30.144
c-2.4-2.071-2.653-5.639-0.639-8.026c2.062-2.37,5.627-2.661,8.031-0.645c14.94,12.766,30.039,23.081,36.308,27.238
c21.07-13.738,67.592-48.691,73.293-74.471c1.489-4.553,2.164-8.695,2.164-12.899c0-14.636-7.967-28.126-20.797-35.195
c-16.387-9.091-38.32-4.898-50.309,9.551c-2.151,2.61-6.583,2.61-8.778,0C32.171-11.335,10.254-15.495-6.107-6.441
c-12.871,7.083-20.839,20.567-20.839,35.187c0,4.177,0.707,8.335,2.025,12.354c0.845,3.76,2.462,7.437,4.52,11.25h33.237
L24.25,26.116c0.978-2.245,3.313-3.657,5.714-3.405c2.447,0.214,4.487,1.973,5.032,4.363L43.4,62.599l10.868-17.824
c1.024-1.693,2.869-2.727,4.86-2.727h20.629c3.137,0,5.689,2.541,5.689,5.696c0,3.137-2.553,5.693-5.689,5.693H62.323
L45.948,80.335c-1.044,1.714-2.885,2.739-4.842,2.739c-0.311,0-0.611-0.025-0.89-0.067c-2.293-0.369-4.101-2.062-4.652-4.306
l-7.678-32.358l-6.068,13.967c-0.906,2.098-2.96,3.423-5.226,3.423h-66.41c-3.117,0-5.694-2.55-5.694-5.693
c0-3.138,2.577-5.694,5.694-5.694h16.698c-1.185-2.826-2.142-5.57-2.735-8.224c-1.589-4.608-2.449-9.975-2.449-15.379
c0-18.779,10.213-36.071,26.716-45.163c19.114-10.573,44.406-7.249,60.202,7.594c15.754-14.845,41.027-18.167,60.175-7.594
C125.272-7.339,135.518,9.964,135.518,28.755z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1 @@
<!-- icon666.com - MILLIONS OF FREE VECTOR ICONS --><svg id="Layer_1" enable-background="new 0 0 24 24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m18 24h-12c-3.3 0-6-2.7-6-6v-12c0-3.3 2.7-6 6-6h12c3.3 0 6 2.7 6 6v12c0 3.3-2.7 6-6 6z" fill="#00adff" style="fill: rgb(82, 82, 82);"></path><path d="m12 3.8c4.5 0 8.2 3.7 8.2 8.2s-3.7 8.2-8.2 8.2-8.2-3.7-8.2-8.2h1.6c0 3.6 3 6.6 6.6 6.6s6.6-3 6.6-6.6-3-6.6-6.6-6.6c-2.1 0-3.9.9-5 2.4l1.7 1.7h-4.9v-4.9l2 2c1.5-1.7 3.7-2.8 6.2-2.8zm.8 4.1v3.8l2.6 2.6-1.1 1.1-3.1-3.1v-4.4z" fill="#fff"></path></svg>

After

Width:  |  Height:  |  Size: 566 B

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<style>
.st1{fill:#fff}
</style>
<g id="Icon">
<circle cx="512" cy="512" r="512" style="fill:#609926"/>
<path class="st1" d="M762.2 350.3c-100.9 5.3-160.7 8-212 8.5v114.1l-16-7.9-.1-106.1c-58.9 0-110.7-3.1-209.1-8.6-12.3-.1-29.5-2.4-47.9-2.5-47.1-.1-110.2 33.5-106.7 118C175.8 597.6 296 609.9 344 610.9c5.3 24.7 61.8 110.1 103.6 114.6H631c109.9-8.2 192.3-373.8 131.2-375.2zm-546 117.3c-4.7-36.6 11.8-74.8 73.2-73.2C296.1 462 307 501.5 329 561.9c-56.2-7.4-104-25.7-112.8-94.3zm415.6 83.5-51.3 105.6c-6.5 13.4-22.7 19-36.2 12.5l-105.6-51.3c-13.4-6.5-19-22.7-12.5-36.2l51.3-105.6c6.5-13.4 22.7-19 36.2-12.5l105.6 51.3c13.4 6.6 19 22.8 12.5 36.2z"/>
<path class="st1" d="M555 609.9c.1-.2.2-.3.2-.5 17.2-35.2 24.3-49.8 19.8-62.4-3.9-11.1-15.5-16.6-36.7-26.6-.8-.4-1.7-.8-2.5-1.2.2-2.3-.1-4.7-1-7-.8-2.3-2.1-4.3-3.7-6l13.6-27.8-11.9-5.8-13.7 28.4c-2 0-4.1.3-6.2 1-8.9 3.2-13.5 13-10.3 21.9.7 1.9 1.7 3.5 2.8 5l-23.6 48.4c-1.9 0-3.8.3-5.7 1-8.9 3.2-13.5 13-10.3 21.9 3.2 8.9 13 13.5 21.9 10.3 8.9-3.2 13.5-13 10.3-21.9-.9-2.5-2.3-4.6-4-6.3l23-47.2c2.5.2 5 0 7.5-.9 2.1-.8 3.9-1.9 5.5-3.3.9.4 1.9.9 2.7 1.3 17.4 8.2 27.9 13.2 30 19.1 2.6 7.5-5.1 23.4-19.3 52.3-.1.2-.2.5-.4.7-2.2-.1-4.4.2-6.5 1-8.9 3.2-13.5 13-10.3 21.9 3.2 8.9 13 13.5 21.9 10.3 8.9-3.2 13.5-13 10.3-21.9-.6-2-1.9-4-3.4-5.7z"/>
</g>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><rect width="512" height="512" rx="15%" fill="#282a2d"/><path d="M256 70H148l108 186-108 186h108l108-186z" fill="#e5a00d"/></svg>

After

Width:  |  Height:  |  Size: 191 B

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" height="800px" width="800px" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 1125.628 1125.628" xml:space="preserve">
<g>
<path d="M562.812,0.002C252.476,0.002,0,252.479,0,562.814s252.476,562.812,562.812,562.812
c310.34,0,562.817-252.476,562.817-562.812S873.152,0.002,562.812,0.002z M309.189,739.263l-68.974-101h-17.735v101h-70v-357h70
v203h15.889l57.901-93h77.963l-79.808,111.736l92.036,135.264H309.189z M468.184,672.88c7.299,13.589,20.325,20.382,38.317,20.382
c11.995,0,21.792-3.329,29.023-10.286c7.226-6.952,11.026-14.712,11.026-27.712h61.131l0.69,1.237
c0.612,25.224-8.88,46.258-28.489,63.246c-19.605,16.997-43.942,25.452-73.007,25.452c-37.218,0-65.962-11.781-86.11-35.309
c-20.144-23.529-30.283-53.763-30.283-90.671v-6.925c0-36.753,10.102-66.968,30.169-90.652
c20.071-23.68,48.745-35.524,85.958-35.524c30.76,0,55.57,8.766,74.412,26.297c18.833,17.531,27.954,41.73,27.342,70.334
l-0.453,2.516H546.55c0-14-3.54-24.775-10.611-33.312c-7.075-8.533-16.837-13.365-29.298-13.365
c-17.837,0-31.158,6.628-38.457,20.446c-7.308,13.818-11.703,31.349-11.703,53.151v6.911
C456.481,641.362,460.876,659.29,468.184,672.88z M793.142,739.263c-2.462-4-4.582-11.157-6.345-17.465
c-1.772-6.304-3.038-12.499-3.805-19.113c-6.925,12.15-16.033,22.354-27.338,30.348c-11.301,7.998-24.798,12.061-40.484,12.061
c-26.141,0-46.285-6.691-60.432-20.148c-14.151-13.457-21.222-31.78-21.222-54.998c0-24.456,9.414-43.221,28.256-56.683
c18.833-13.452,46.327-20.003,82.467-20.003h39.242v-20.18c0-11.995-3.974-21.3-10.282-27.914
c-6.303-6.609-16.019-9.917-28.32-9.917c-10.922,0-19.545,2.65-25.465,7.957c-5.92,5.303-8.982,12.648-8.982,22.026l-65.101-0.228
l-0.259-1.384c-1.073-21.066,8.063-39.251,27.44-54.553c19.377-15.302,44.822-22.953,76.349-22.953
c29.832,0,54.075,7.578,72.684,22.72c18.605,15.151,27.938,36.716,27.938,64.703v103.113c0,11.689,0.854,22.156,2.622,32.461
c1.768,10.3,4.55,21.149,8.396,30.149H793.142z M902.481,739.263v-357h70v357H902.481z"/>
<path d="M711.712,640.846c-7.382,7.153-11.072,16.229-11.072,26.379c0,8.304,2.768,15.211,8.304,20.285
c5.536,5.075,13.069,7.717,22.606,7.717c11.84,0,23.195-2.865,32.422-8.707c9.227-5.847,14.509-12.558,19.509-20.246v-37.012
h-39.242C729.933,629.263,719.093,633.698,711.712,640.846z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg xmlns="http://www.w3.org/2000/svg"
aria-label="Ubiquiti" role="img"
viewBox="0 0 512 512"><rect
width="512" height="512"
rx="15%"
fill="#399cdb"/><path d="M112 94v18h18V94h-18zm288 0c-82 0-90 31-90 61v172a147 147 0 01-3 28c43-9 72-36 86-82l7-23V94zm-234 18v18h18v-18h-18zm-18 18v18h18v-18h-18zm36 9v18h18v-18h-18zm-72 4v147c0 73 53 128 144 128 0 0-54-30-54-91V197h-18v66h-18v-39h-18v17h-18v-98h-18zm54 18v18h18v-18h-18zm-18 27v18h18v-18h-18zm252 87c-19 64-65 92-131 89-24-1-43-7-57-16 10 42 46 63 48 64l10 6c82-5 130-59 130-128v-15z" fill="#ffffff"/></svg>

After

Width:  |  Height:  |  Size: 680 B

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<circle cx="512" cy="512" r="512" style="fill:#0095d5"/>
<path d="M780 468.41v114.21L535.48 723.86v-114.2zM488.69 609.66v114.21L244 582.62V468.41l110.57 63.79a1.81 1.81 0 0 0 .4.24zM611 512l-98.91 57.15-99-57.15 99-57.15zm145.7-84.09-99 57.15-122.22-70.64V300.13zm-268-127.81v114.32L366.3 485l-99-57.06z" style="fill:#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 550 B

View File

@@ -0,0 +1,9 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
<svg fill="#ffffff" width="800px" height="800px" viewBox="-3.2 -3.2 38.40 38.40" xmlns="http://www.w3.org/2000/svg" stroke="#ffffff">
<g id="SVGRepo_bgCarrier" stroke-width="0" transform="translate(0,0), scale(1)">
<rect x="-3.2" y="-3.2" width="38.40" height="38.40" rx="19.2" fill="#00678e" strokewidth="0"/>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="57.54 57.53 396.92 396.93">
<path d="M0 0 C0.77839233 0.70552002 0.77839233 0.70552002 1.57250977 1.42529297 C20.51724698 18.95857955 35.3183542 42.25039645 45.25 65.9375 C45.75917969 67.13761719 46.26835937 68.33773437 46.79296875 69.57421875 C53.13904333 85.15591751 57.42335993 102.21306817 59.25 118.9375 C59.35957031 119.79472656 59.46914063 120.65195313 59.58203125 121.53515625 C65.30647708 174.58168758 49.01780615 227.1014046 15.875 268.5 C12.4098987 272.65198864 8.83161111 276.6927508 5.1875 280.6875 C4.48197998 281.46589233 4.48197998 281.46589233 3.76220703 282.26000977 C-13.77107955 301.20474698 -37.06289645 316.0058542 -60.75 325.9375 C-61.95011719 326.44667969 -63.15023438 326.95585937 -64.38671875 327.48046875 C-79.96841751 333.82654333 -97.02556817 338.11085993 -113.75 339.9375 C-114.60722656 340.04707031 -115.46445312 340.15664063 -116.34765625 340.26953125 C-169.39418758 345.99397708 -221.9139046 329.70530615 -263.3125 296.5625 C-267.46448864 293.0973987 -271.5052508 289.51911111 -275.5 285.875 C-276.27839233 285.16947998 -276.27839233 285.16947998 -277.07250977 284.44970703 C-296.01724698 266.91642045 -310.8183542 243.62460355 -320.75 219.9375 C-321.25917969 218.73738281 -321.76835937 217.53726562 -322.29296875 216.30078125 C-328.63904333 200.71908249 -332.92335993 183.66193183 -334.75 166.9375 C-334.85957031 166.08027344 -334.96914063 165.22304688 -335.08203125 164.33984375 C-340.80647708 111.29331242 -324.51780615 58.7735954 -291.375 17.375 C-287.9098987 13.22301136 -284.33161111 9.1822492 -280.6875 5.1875 C-280.21715332 4.66857178 -279.74680664 4.14964355 -279.26220703 3.61499023 C-261.72892045 -15.32974698 -238.43710355 -30.1308542 -214.75 -40.0625 C-213.54988281 -40.57167969 -212.34976563 -41.08085938 -211.11328125 -41.60546875 C-195.53158249 -47.95154333 -178.47443183 -52.23585993 -161.75 -54.0625 C-160.89277344 -54.17207031 -160.03554687 -54.28164063 -159.15234375 -54.39453125 C-99.82500101 -60.79676248 -43.54726444 -39.72491441 0 0 Z " fill="#A289F9" transform="translate(393.75,113.0625)"/>
<path d="M0 0 C2.63622485 2.22708275 5.19150738 4.52921782 7.7409668 6.85546875 C8.46670898 7.49871094 9.19245117 8.14195312 9.94018555 8.8046875 C28.36748008 26.03588989 41.75155651 53.35960955 42.86425781 78.65869141 C43.70797362 112.3608808 36.58885956 142.14006064 13.7409668 167.85546875 C12.54020508 169.20705078 12.54020508 169.20705078 11.31518555 170.5859375 C-8.53153214 191.53705519 -37.09680019 204.15705607 -65.8137207 205.140625 C-77.94100422 205.37709014 -89.5422865 205.25443065 -101.2590332 201.85546875 C-103.0224707 201.37980469 -103.0224707 201.37980469 -104.8215332 200.89453125 C-121.40302939 195.94116057 -136.3527812 187.31831389 -149.2590332 175.85546875 C-150.14848633 175.0665625 -151.03793945 174.27765625 -151.9543457 173.46484375 C-167.40909083 158.83370573 -177.61511815 140.25052542 -183.2590332 119.85546875 C-183.5787207 118.70820313 -183.8984082 117.5609375 -184.2277832 116.37890625 C-188.26683057 98.91376625 -188.98196274 77.02425735 -183.2590332 59.85546875 C-182.5990332 58.86546875 -181.9390332 57.87546875 -181.2590332 56.85546875 C-180.93032227 57.46132812 -180.60161133 58.0671875 -180.26293945 58.69140625 C-172.21889547 72.86113142 -160.79177658 83.491992 -144.8840332 88.23046875 C-127.13242009 91.2910917 -110.45150547 88.72225434 -95.2590332 78.85546875 C-83.60677615 70.49132656 -75.75104634 56.9235307 -73.2590332 42.85546875 C-71.45757952 24.03946126 -74.73970981 8.78864654 -86.8762207 -6.1328125 C-90.96217608 -10.5413433 -95.77705169 -13.57401274 -100.90356445 -16.6640625 C-103.2590332 -18.14453125 -103.2590332 -18.14453125 -105.2590332 -20.14453125 C-103.57312478 -20.62926803 -101.88520775 -21.10702051 -100.1965332 -21.58203125 C-98.78694336 -21.98228516 -98.78694336 -21.98228516 -97.34887695 -22.390625 C-64.24168178 -30.4686132 -26.24452842 -21.45755623 0 0 Z " fill="#131928" transform="translate(344.259033203125,182.14453125)"/>
<path d="M0 0 C27.51668329 -1.54459553 56.68622642 12.36261605 76.97143555 30.24731445 C100.05828616 51.22761351 116.14269665 79.83089922 118.22119141 111.35473633 C119.4842267 149.41509562 109.54536352 181.25369492 83.42504883 209.56640625 C61.20694161 232.76768274 30.18258643 245.68447023 -1.72436523 246.47045898 C-37.47694444 246.75019142 -67.91707948 234.63666274 -93.52539062 209.50195312 C-112.03605933 190.64804024 -127.24579555 162.86302866 -128.09765625 135.91015625 C-128.08605469 134.97042969 -128.07445312 134.03070313 -128.0625 133.0625 C-128.05347656 132.10472656 -128.04445312 131.14695312 -128.03515625 130.16015625 C-128.01775391 129.09087891 -128.01775391 129.09087891 -128 128 C-127.67 128 -127.34 128 -127 128 C-126.79246094 129.23492187 -126.58492187 130.46984375 -126.37109375 131.7421875 C-122.03641631 156.12747696 -112.65378734 175.70645513 -96 194 C-95.43023437 194.65226563 -94.86046875 195.30453125 -94.2734375 195.9765625 C-77.53338041 214.09016274 -49.82674725 227.04001852 -25.19677734 228.12329102 C8.55585455 228.96826963 38.21751895 221.8135893 64 199 C64.82757813 198.27296875 65.65515625 197.5459375 66.5078125 196.796875 C87.15165343 177.37583998 99.32367565 148.51732778 100.28515625 120.4453125 C100.52162139 108.31802898 100.3989619 96.7167467 97 85 C96.68289063 83.824375 96.36578125 82.64875 96.0390625 81.4375 C91.07002873 64.80357134 82.4108957 49.99996858 71 37 C70.28457031 36.18402344 69.56914062 35.36804688 68.83203125 34.52734375 C50.47303107 14.99722536 25.98974766 5.20070823 0 1 C0 0.67 0 0.34 0 0 Z " fill="#6865CD" transform="translate(286,158)"/>
</svg>

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><circle cx="256" cy="256" r="256"/><path d="M268.6 102.4c64.4 0 116.8 52.4 116.8 116.7 0 25.3-8 49.4-23 69.6-14.8 19.9-35 34.3-58.4 41.7l-6.5 2-15.5-76.2 4.3-2c14-6.7 23-21.1 23-36.6 0-22.4-18.2-40.6-40.6-40.6S228 195.2 228 217.6c0 15.5 9 29.8 23 36.6l4.2 2-25 153.4h-69.5V102.4z" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 389 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><circle cx="256" cy="256" r="256" style="fill:#292b2f"/><path d="M380.6 344.1c-14.8 0-27.5 8.6-33.5 21.1l-53.2-7.7c-.2-20.3-16.7-36.8-37.1-36.8-7.3 0-14 2.1-19.8 5.7l-75.6-85.6c4.4-6.1 6.9-13.5 6.9-21.6 0-20.5-16.6-37.1-37.1-37.1s-37.1 16.6-37.1 37.1 16.6 37.1 37.1 37.1c6.3 0 12.3-1.6 17.5-4.4l76.4 86.5c-3.4 5.6-5.4 12.2-5.4 19.3 0 20.5 16.6 37.1 37.1 37.1 14.7 0 27.4-8.5 33.4-20.9l53.3 7.7c.3 20.2 16.8 36.5 37.1 36.5 20.5 0 37.1-16.6 37.1-37.1.1-20.3-16.6-36.9-37.1-36.9" style="fill:#fff"/><path d="M380.6 93.7c-20.5 0-37.1 16.6-37.1 37.1 0 10.1 4 19.2 10.6 25.9L271.8 273c-4.6-2-9.6-3.1-15-3.1-20.5 0-37.1 16.6-37.1 37.1q0 5.55 1.5 10.5l-62.5 38.7c-6.8-7.4-16.6-12.1-27.4-12.1-20.5 0-37.1 16.6-37.1 37.1s16.6 37.1 37.1 37.1 37.1-16.6 37.1-37.1c0-3.5-.5-7-1.4-10.2l62.6-38.8c6.8 7.3 16.5 11.9 27.2 11.9 20.5 0 37.1-16.6 37.1-37.1 0-9-3.2-17.3-8.6-23.8l83-117.3c3.8 1.3 7.9 2.1 12.2 2.1 20.5 0 37.1-16.6 37.1-37.1s-16.5-37.2-37-37.2" style="fill:#e5a00d"/></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.8 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.9 KiB

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" fill="#878e96"/>
<path d="M15.7491 10.73V10C15.7491 9.07 15.7491 6.25 11.9991 6.25C8.24915 6.25 8.24915 9.07 8.24915 10V10.73C7.02915 11 6.61914 11.79 6.61914 13.5V14.5C6.61914 16.7 7.29915 17.38 9.49915 17.38H14.4991C16.6991 17.38 17.3792 16.7 17.3792 14.5V13.5C17.3792 11.79 16.9691 11 15.7491 10.73ZM11.9991 15.1C11.3891 15.1 10.8992 14.61 10.8992 14C10.8992 13.39 11.3891 12.9 11.9991 12.9C12.6091 12.9 13.0991 13.39 13.0991 14C13.0991 14.61 12.6091 15.1 11.9991 15.1ZM14.2491 10.62H9.74915V10C9.74915 8.54 10.1091 7.75 11.9991 7.75C13.8891 7.75 14.2491 8.54 14.2491 10V10.62Z" fill="#292D32"/>
</svg>

After

Width:  |  Height:  |  Size: 950 B

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 36 36" version="1.1" preserveAspectRatio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>no-access-solid</title>
<path d="M18,2A16,16,0,1,0,34,18,16,16,0,0,0,18,2ZM29.15,20H6.85A.85.85,0,0,1,6,19.15V16.85A.85.85,0,0,1,6.85,16H29.15a.85.85,0,0,1,.85.85v2.29A.85.85,0,0,1,29.15,20Z" class="clr-i-solid clr-i-solid-path-1"></path>
<rect x="0" y="0" width="36" height="36" fill-opacity="0"/>
</svg>

After

Width:  |  Height:  |  Size: 642 B

View File

@@ -0,0 +1,66 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1" charset=" UTF-8">
<title>Simple Line Chart</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns"></script>
<script src="https://www.chartjs.org/docs/latest/samples/utils.js"></script>
</head>
<body>
<canvas id="myChart"></canvas>
</body>
<script>
const chartDates = ({{ dates | safe }}).map(dt => new Date(dt));
const data = {
labels: chartDates,
datasets: [{
label: 'Ping',
data: {{ values }},
}]
};
const ctx = document.getElementById('myChart').getContext('2d');
// Current time in UTC
const nowUTC = new Date();
// One hour ago in UTC
const oneDayAgoUTC = new Date(nowUTC.getTime() - 24 * 60 * 60 * 1000);
const min = "{{ min }}"
const max = "{{ max }}"
new Chart(ctx, {
type: 'line',
data: data,
options: {
scales: {
x: {
type: 'time', // Important for datetime axis
time: {
unit: 'minute',
tooltipFormat: 'HH:mm:ss',
displayFormats: { hour: 'HH:mm' }
},
min: min,
max: max,
ticks: { color: '#ffffff' }, // X-axis labels
grid: { color: 'rgba(255, 255, 255, 0.1)' } // X-axis grid lines
},
y: {
ticks: { color: '#ffffff' }, // Y-axis labels
grid: { color: 'rgba(255, 255, 255, 0.1)' } // Y-axis grid lines
}
}
}
});
</script>
</html>

View File

@@ -0,0 +1,123 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1" charset=" UTF-8">
<title>Dashboard</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<style>
/* Prevent Bootstrap's default 0.6s width tween that causes lag */
.progress-bar {
transition: none !important;
}
</style>
</head>
<body class="m-2 bg-light-subtle pt-3">
<div class="progress fixed-top mt-1" style="height: auto;">
<div id="progressBar" class="progress-bar rounded-pill" role="progressbar" style="width: 100%; margin: 0 auto;">
<h5 class="m-0">5s</h5>
</div>
</div>
<div id="main_body" class="d-flex flex-wrap justify-content-center"></div>
</body>
<script>
const main_body = document.getElementById("main_body");
const url = '/api/status';
const interval = 5000; // 5 seconds between requests
const progressBar = document.getElementById('progressBar');
const chartURL = "{{ url_for("main.chart", id=0) }}"
function fetchData() {
fetch(url, { cache: 'no-store' })
.then(response => response.json())
.then(data => {
updateWebpage(data)
})
.catch(error => console.error("Error fetching data", error))
.finally(() => {
// Animate progress bar over 'interval' before next fetch
animateProgress(interval, () => {
fetchData(); // next fetch after animation
});
});
}
function animateProgress(duration, callback) {
const start = performance.now();
function frame(now) {
const elapsed = now - start;
const ratio = Math.min(elapsed / duration, 1);
progressBar.style.width = 100 * (1 - ratio) + "%";
progressBar.innerHTML = "<h5 class='m-0'>" + (interval * (1 - ratio) / 1000).toFixed(1) + "s</h5>";
if (ratio < 1) {
requestAnimationFrame(frame);
} else {
callback();
}
}
// reset bar and start animation
progressBar.style.width = '100%';
progressBar.textContent = '100%';
requestAnimationFrame(frame);
}
function updateWebpage(services) {
main_body.innerHTML = ''; // Clear webpage
// Build all service divs as a single string
main_body.innerHTML = services.map(s => `
<div class="service-tile position-relative m-2" style="width: 175px">
<a href="${s.url}" class="text-body text-decoration-none bg-body-tertiary d-flex flex-column align-items-center border border-3 ${s.ping ? 'border-success' : 'border-danger'}">
<div class="bg-dark w-100">
<h4 class="text-center text-truncate m-0 p-1">${s.label}</h4>
</div>
<div class="position-relative ratio ratio-1x1">
<div class="d-flex justify-content-center align-items-center">
<img src="{{ url_for('static', filename="icons") }}/${s.service_id - 1}.svg" class="img-fluid w-75">
</div>
<div>
${s.public_access ? `` : `<img src='{{ url_for('static', filename='lock.svg') }}' class='img-fluid position-absolute bottom-0 end-0 w-25'>`}
<div class="position-absolute bottom-0 text-body bg-dark bg-opacity-75 px-1 rounded">${s.ping ? s.ping + "ms" : ""}</div>
</div>
</div>
</a>
<a href="${chartURL.replace("0", s.service_id)}" class="overlay position-absolute bottom-0 end-0 m-1" style="width:40px;">
<img src="{{ url_for('static', filename='history.svg') }}">
</a>
</div>
`).join(''); // join into a single string
}
fetchData(); // start the loop
</script>
<!-- Add this CSS once (in your stylesheet or in a <style> block) -->
<style>
/* make overlay invisible and non-interactive by default */
.service-tile .overlay {
opacity: 0;
pointer-events: none;
transition: opacity .18s ease;
}
/* reveal overlay on hover (and make it clickable) */
.service-tile:hover .overlay {
opacity: 1;
pointer-events: auto;
}
</style>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"
integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI"
crossorigin="anonymous"></script>
</html>

1
app/migrations/README Normal file
View File

@@ -0,0 +1 @@
Single-database configuration for Flask.

View File

@@ -0,0 +1,50 @@
# A generic, single database configuration.
[alembic]
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic,flask_migrate
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[logger_flask_migrate]
level = INFO
handlers =
qualname = flask_migrate
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

113
app/migrations/env.py Normal file
View File

@@ -0,0 +1,113 @@
import logging
from logging.config import fileConfig
from flask import current_app
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
logger = logging.getLogger('alembic.env')
def get_engine():
try:
# this works with Flask-SQLAlchemy<3 and Alchemical
return current_app.extensions['migrate'].db.get_engine()
except (TypeError, AttributeError):
# this works with Flask-SQLAlchemy>=3
return current_app.extensions['migrate'].db.engine
def get_engine_url():
try:
return get_engine().url.render_as_string(hide_password=False).replace(
'%', '%%')
except AttributeError:
return str(get_engine().url).replace('%', '%%')
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
config.set_main_option('sqlalchemy.url', get_engine_url())
target_db = current_app.extensions['migrate'].db
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def get_metadata():
if hasattr(target_db, 'metadatas'):
return target_db.metadatas[None]
return target_db.metadata
def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url, target_metadata=get_metadata(), literal_binds=True
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
# this callback is used to prevent an auto-migration from being generated
# when there are no changes to the schema
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
def process_revision_directives(context, revision, directives):
if getattr(config.cmd_opts, 'autogenerate', False):
script = directives[0]
if script.upgrade_ops.is_empty():
directives[:] = []
logger.info('No changes in schema detected.')
conf_args = current_app.extensions['migrate'].configure_args
if conf_args.get("process_revision_directives") is None:
conf_args["process_revision_directives"] = process_revision_directives
connectable = get_engine()
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=get_metadata(),
**conf_args
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,37 @@
"""empty message
Revision ID: 3c05315d5b9b
Revises: f87909a4293b
Create Date: 2025-09-05 09:48:08.561045
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "3c05315d5b9b"
down_revision = "f87909a4293b"
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("log", schema=None) as batch_op:
batch_op.add_column(
sa.Column(
"timeout", sa.Boolean(), nullable=False, server_default="false"
)
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("log", schema=None) as batch_op:
batch_op.drop_column("timeout")
# ### end Alembic commands ###

View File

@@ -0,0 +1,32 @@
"""empty message
Revision ID: d7d380435347
Revises:
Create Date: 2025-09-02 08:43:16.682424
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'd7d380435347'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('log',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('dateCreated', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('log')
# ### end Alembic commands ###

View File

@@ -0,0 +1,35 @@
"""empty message
Revision ID: f04407e8e466
Revises: d7d380435347
Create Date: 2025-09-03 15:40:30.413166
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'f04407e8e466'
down_revision = 'd7d380435347'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('service',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('url', sa.String(), nullable=False),
sa.Column('label', sa.String(length=15), nullable=False),
sa.Column('public_access', sa.Boolean(), nullable=False),
sa.Column('ping_method', sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('service')
# ### end Alembic commands ###

View File

@@ -0,0 +1,50 @@
"""empty message
Revision ID: f87909a4293b
Revises: f04407e8e466
Create Date: 2025-09-03 16:36:14.608372
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "f87909a4293b"
down_revision = "f04407e8e466"
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.execute("DROP TABLE IF EXISTS _alembic_tmp_log")
op.execute("DELETE FROM log")
with op.batch_alter_table("log", schema=None) as batch_op:
batch_op.add_column(
sa.Column(
"service_id", sa.Integer(), nullable=False, server_default="0"
)
)
batch_op.add_column(sa.Column("ping", sa.Integer(), nullable=True))
batch_op.create_index(
batch_op.f("ix_log_dateCreated"), ["dateCreated"], unique=False
)
batch_op.create_foreign_key(
"fk_log2service", "service", ["service_id"], ["id"]
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("log", schema=None) as batch_op:
batch_op.drop_constraint("fk_log2service", type_="foreignkey")
batch_op.drop_index(batch_op.f("ix_log_dateCreated"))
batch_op.drop_column("ping")
batch_op.drop_column("service_id")
# ### end Alembic commands ###

70
app/models.py Normal file
View File

@@ -0,0 +1,70 @@
from app import db
from datetime import datetime, timezone
from validators import url as is_url
from typing import Any, Optional
class log(db.Model):
id: int = db.Column(db.Integer, primary_key=True) # TODO: Switch to UUID
dateCreated: datetime = db.Column(db.DateTime, nullable=False, index=True)
service_id: int = db.Column(
db.Integer,
db.ForeignKey("service.id"),
nullable=False,
)
ping: Optional[int] = db.Column(db.Integer, nullable=True)
timeout: bool = db.Column(db.Boolean, nullable=False)
def __init__(
self, service_id: int, ping: Optional[int], timeout: bool = False
):
super().__init__()
self.service_id = service_id
self.ping = ping
self.dateCreated = datetime.now(timezone.utc)
self.timeout = timeout
def to_dict(self) -> dict[str, Any]:
return {
"log_id": self.id,
"service_id": self.service_id,
"ping": self.ping,
"dateCreated": self.dateCreatedUTC(),
"timeout": self.timeout,
}
def dateCreatedUTC(self) -> datetime:
return self.dateCreated.replace(tzinfo=timezone.utc)
class service(db.Model):
id: int = db.Column(db.Integer, primary_key=True) # TODO: Switch to UUID
url: str = db.Column(db.String, nullable=False)
label: str = db.Column(db.String(15), nullable=False)
public_access: bool = db.Column(db.Boolean, nullable=False)
ping_method: int = db.Column(db.Integer, nullable=False)
logs = db.relationship("log", lazy="dynamic")
def __init__(
self, url: str, label: str, public_access: bool, ping_method: int
):
if not is_url(url):
raise Exception("URL IS NOT A VALID URL")
if len(label) > 15:
raise Exception("LABEL EXCEEDS MAXIMUM LENGTH (15)")
super().__init__()
self.url = url
self.label = label
self.public_access = public_access
self.ping_method = ping_method
def to_dict(self) -> dict[str, Any]:
return {
"url": self.url,
"public_access": self.public_access,
"label": self.label,
"service_id": self.id,
"ping_method": self.ping_method,
}

95
app/routes.py Normal file
View File

@@ -0,0 +1,95 @@
from flask import Blueprint, render_template, abort, jsonify, send_file, json
from typing import cast, Optional, Any
from datetime import datetime, timedelta, timezone
from config import timeout
from .models import service, log
from app import app, db
bp = Blueprint(
"main",
"__name__",
url_prefix="/",
static_folder="static",
)
# Prepares log data for chart.js chart
def prepare_chart_data(
logs: list[log],
) -> tuple[list[str], list[Optional[int]]]:
if len(logs) <= 0: # Return empty if there are no logs
return ([], [])
x = [logs[0].dateCreatedUTC().isoformat()]
y = [logs[0].ping]
for i in range(1, len(logs)):
log1 = logs[i]
log2 = logs[i - 1]
# Check if the gap in points exceeds a threshold
if (abs(log1.dateCreatedUTC() - log2.dateCreatedUTC())) > timedelta(
milliseconds=1.5 * (timeout + 1000)
):
x.append(log2.dateCreatedUTC().isoformat())
y.append(None)
x.append(log1.dateCreatedUTC().isoformat())
y.append(log1.ping)
return (x, y)
@bp.route("/")
def homepage():
return render_template("home.html")
@bp.route("/chart/<int:id>")
def chart(id: int):
with app.app_context():
logs = []
s = db.session.query(service).filter_by(id=id).first()
if s:
logs = cast(
list[log],
s.logs.order_by(log.dateCreated.desc()) # type: ignore
.limit(300)
.all(),
)
else:
return abort(code=403)
x, y = prepare_chart_data(logs=logs)
now = datetime.now(timezone.utc)
max_ = now
min_ = now - timedelta(hours=1)
return render_template(
"chart.html",
dates=x,
values=json.dumps(y),
min=min_.isoformat(),
max=max_.isoformat(),
)
@bp.route("/api/status")
def status():
results: list[dict[str, Any]] = []
with app.app_context():
a = db.session.query(service).all()
for s in a:
b = cast(
Optional[log],
s.logs.order_by(
log.dateCreated.desc() # type: ignore
).first(),
)
if b:
results.append(s.to_dict() | b.to_dict())
return jsonify(results)
@bp.route("/favicon.svg")
def favicon():
return send_file("/static/favicon.svg")

1
config.py Normal file
View File

@@ -0,0 +1 @@
timeout: int = 1000

24
main.py
View File

@@ -1,24 +0,0 @@
# import requests as r
from flask import jsonify, Flask
from poll_services import start_async_loop
from mem import services
import threading
# Flask app to serve status
app = Flask(__name__)
@app.route("/")
def status():
return jsonify([s.to_dict() for s in services])
# Only run if directly running file
if __name__ == "__main__":
t = threading.Thread(target=start_async_loop, daemon=True)
t.start()
# Run flask app
app.run(debug=True, use_reloader=False)

View File

@@ -1,55 +0,0 @@
from typing import Any, Optional
class service:
url: str
status: Optional[int]
online: bool
public: bool
error: Optional[str]
ping: Optional[int]
def __init__(self, url: str = "", public: bool = True):
self.url = url
self.public = public
self.online = False
self.status = None
self.error = None
self.ping = None
def to_dict(self) -> dict[str, Any]:
return {
"url": self.url,
"status": self.status,
"public": self.public,
"online": self.online,
"error": self.error,
"ping": self.ping,
}
def set_status(self, status: Optional[int]):
self.status = status
def set_online(self, b: bool):
self.online = b
def set_error(self, s: Optional[str]):
self.error = s
def set_ping(self, n: Optional[int]):
self.ping = n
services: list[service] = [
service("https://git.ihatemen.uk/"),
service("https://plex.ihatemen.uk/"),
service("https://truenas.local/", False),
service("https://cloud.ihatemen.uk/"),
service("https://request.ihatemen.uk/"),
service("https://id.ihatemen.uk/"),
service("http://tautulli.local", False),
service("https://transmission.local", False),
service("https://vault.ihatemen.uk"),
service("https://nginx.local", False),
]

View File

@@ -1,48 +0,0 @@
from mem import services, service
import httpx
import urllib3
import asyncio
import time
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
async def check_service(client: httpx.AsyncClient, s: service):
try:
before = time.perf_counter()
r = await client.get(
url=s.url,
follow_redirects=True,
timeout=1,
)
after = time.perf_counter()
s.set_error(None)
s.set_online(r.status_code == 200)
s.set_status(r.status_code)
s.set_ping(int((after - before) * 1000))
except httpx.HTTPError as e:
s.set_error(str(e))
s.set_online(False)
s.set_status(None)
s.set_ping(None)
def start_async_loop():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
asyncio.run_coroutine_threadsafe(update_services(loop=loop), loop=loop)
loop.run_forever()
async def update_services(loop: asyncio.AbstractEventLoop):
print("Starting service updates...")
async with httpx.AsyncClient() as public_client, httpx.AsyncClient(
verify=False
) as local_client:
while True:
tasks = [
check_service(public_client if s.public else local_client, s)
for s in services
]
await asyncio.gather(*tasks)
await asyncio.sleep(2)

25
requirements.txt Normal file
View File

@@ -0,0 +1,25 @@
aiohappyeyeballs==2.6.1
aiohttp==3.12.15
aiosignal==1.4.0
alembic==1.16.5
attrs==25.3.0
blinker==1.9.0
click==8.2.1
colorama==0.4.6
Flask==3.1.2
Flask-Migrate==4.1.0
Flask-SQLAlchemy==3.1.1
frozenlist==1.7.0
greenlet==3.2.4
idna==3.10
itsdangerous==2.2.0
Jinja2==3.1.6
Mako==1.3.10
MarkupSafe==3.0.2
multidict==6.6.4
propcache==0.3.2
SQLAlchemy==2.0.43
typing_extensions==4.15.0
validators==0.35.0
Werkzeug==3.1.3
yarl==1.20.1