Compare commits

...

54 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
43 changed files with 1076 additions and 216 deletions

View File

@@ -9,9 +9,6 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN chmod +x ./entrypoint.sh
ENV FLASK_APP=app.py
ENTRYPOINT ["./entrypoint.sh"]
CMD []
CMD ["python", "app.py"]

37
app.py
View File

@@ -1,29 +1,20 @@
# import requests as r
from flask import jsonify, Flask, render_template
from poll_services import start_async_loop
from mem import services
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
# Flask app to serve status
app = Flask(__name__)
@app.route("/")
def homepage():
return render_template("home.html", services=services)
@app.route("/api/status")
def status():
return jsonify([s.to_dict() for s in services])
import sys
import time
# Only run if directly running file
if __name__ == "__main__":
threading.Thread(target=start_worker, daemon=True).start()
t = threading.Thread(target=start_async_loop, daemon=True)
t.start()
threading.Thread(target=start_flask, daemon=True).start()
# Run flask app
app.run(host="0.0.0.0", port=80, debug=True, use_reloader=False)
# 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

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

Before

Width:  |  Height:  |  Size: 191 B

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

Before

Width:  |  Height:  |  Size: 550 B

After

Width:  |  Height:  |  Size: 550 B

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

Before

Width:  |  Height:  |  Size: 389 B

After

Width:  |  Height:  |  Size: 389 B

View File

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

Before

Width:  |  Height:  |  Size: 7.8 KiB

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

View File

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

View File

Before

Width:  |  Height:  |  Size: 950 B

After

Width:  |  Height:  |  Size: 950 B

View File

Before

Width:  |  Height:  |  Size: 642 B

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

View File

@@ -1,8 +0,0 @@
#!/bin/sh
set -ex
#echo "Running database migrations..."
#flask db upgrade
echo "Starting Flask app..."
python app.py

View File

@@ -1,70 +0,0 @@
from typing import Any, Optional
class service:
id = int
url: str
status: Optional[int]
online: bool
public: bool
error: Optional[str]
ping: Optional[int]
icon_filetype: str
def __init__(
self,
id: int,
url: str = "",
label: str = "",
public: bool = True,
icon_filetype: str = "svg",
):
self.id = id
self.url = url
self.public = public
self.label = label
self.online = False
self.status = None
self.error = None
self.ping = None
self.icon_filetype = icon_filetype
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,
"label": self.label,
"icon_filetype": self.icon_filetype,
"id": self.id,
}
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(0, "https://git.ihatemen.uk/", "Gitea"),
service(1, "https://plex.ihatemen.uk/", "Plex"),
service(2, "https://truenas.local/", "TrueNAS", False),
service(3, "https://cloud.ihatemen.uk/", "NextCloud"),
service(4, "https://request.ihatemen.uk/", "Overseerr"),
service(5, "https://id.ihatemen.uk/", "PocketID"),
service(6, "http://tautulli.local", "Tautulli", False),
service(7, "https://transmission.local", "Transmission", False),
service(8, "https://vault.ihatemen.uk", "Vault Warden"),
service(9, "https://nginx.local", "Nginx (NPM)", False),
]

View File

@@ -1,45 +0,0 @@
from mem import services, service
import httpx
import asyncio
import time
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)

View File

@@ -1,16 +1,25 @@
anyio==4.10.0
aiohappyeyeballs==2.6.1
aiohttp==3.12.15
aiosignal==1.4.0
alembic==1.16.5
attrs==25.3.0
blinker==1.9.0
certifi==2025.8.3
click==8.2.1
colorama==0.4.6
Flask==3.1.2
h11==0.16.0
httpcore==1.0.9
httpx==0.28.1
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
sniffio==1.3.1
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

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path d="M76.8 0h358.4C477.6 0 512 34.4 512 76.8v358.4c0 42.4-34.4 76.8-76.8 76.8H76.8C34.4 512 0 477.6 0 435.2V76.8C0 34.4 34.4 0 76.8 0" style="fill:#175ddc"/><path d="M372 297V131H256v294c47-28 115-74 116-128m49-198v198c0 106-152 181-165 181S91 403 91 297V99s0-17 17-17h296s17 0 17 17" style="fill:#fff"/></svg>

Before

Width:  |  Height:  |  Size: 397 B

View File

@@ -1,59 +0,0 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<title>Debug Divs</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">
<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>
</head>
<body id="main_body" class="m-2 bg-light-subtle d-flex flex-wrap justify-content-center">
</body>
<script>
const main_body = document.getElementById("main_body");
const url = '/api/status';
function fetchData() {
fetch(url, { cache: 'no-store' })
.then(response => response.json())
.then(data => {
console.log(data)
updateWebpage(data)
})
.catch(error => console.error("Error fetching data", error))
.finally(() => {
setTimeout(fetchData, 1000); // schedule next request after 1 second
});
}
function updateWebpage(services) {
main_body.innerHTML = ''; // Clear webpage
// Build all service divs as a single string
main_body.innerHTML = services.map(s => `
<a href="${s.url}" class="d-block text-body text-decoration-none m-2 border border-3 ${s.online ? 'border-success' : 'border-danger'}" style="width: 200px">
<div class="bg-body-tertiary d-flex flex-column align-items-center">
<div class="bg-dark w-100">
<h4 class="text-center text-truncate m-0" style="font-size: 1.5rem;">${s.label}</h4>
</div>
<div class="position-relative ratio ratio-1x1">
<img src="static/icons/${s.id}.${s.icon_filetype}" class="img-fluid">
${s.public ? `` : `<div>
<img src='static/lock.svg' class='img-fluid position-absolute bottom-0 end-0 w-25'>
</div>` }
</div>
</div>
</a>
`).join(''); // join into a single string
}
fetchData(); // start the loop
</script>
</html>