Compare commits

...

8 Commits

8 changed files with 191 additions and 72 deletions

32
app.py
View File

@@ -5,7 +5,8 @@ from mem import services, app, db
import threading
from flask_migrate import upgrade, stamp
from pathlib import Path
from models import service, log
from typing import Any, Optional, cast
# Init and upgrade
with app.app_context():
@@ -17,6 +18,19 @@ with app.app_context():
# Upgrade db if any new migrations exist
upgrade()
with app.app_context():
if not db.session.query(service).first():
for s in services:
db.session.add(
service(
url=s.url,
label=s.label,
public_access=s.public,
ping_method=s.ping_type,
)
)
db.session.commit()
@app.route("/")
def homepage():
@@ -25,7 +39,20 @@ def homepage():
@app.route("/api/status")
def status():
return jsonify([s.to_dict() for s in services])
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)
@app.route("/favicon.svg")
@@ -35,7 +62,6 @@ def favicon():
# Only run if directly running file
if __name__ == "__main__":
t = threading.Thread(target=start_async_loop, daemon=True)
t.start()

View File

@@ -1,4 +1,4 @@
from typing import Any, Optional
from typing import Any
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
@@ -7,12 +7,8 @@ from flask_migrate import Migrate
class service:
id: int
url: str
status: Optional[int]
online: bool
public: bool
error: Optional[str]
ping: Optional[int]
icon_filetype: str
ping_type: int
def __init__(
@@ -21,7 +17,6 @@ class service:
url: str = "",
label: str = "",
public: bool = True,
icon_filetype: str = "svg",
ping_type: int = 0,
):
self.id = id
@@ -30,38 +25,15 @@ class service:
self.label = label
self.ping_type = ping_type
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,
"ping_type": self.ping_type,
}
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"),

View File

@@ -75,18 +75,18 @@
// 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: 175px">
<a href="${s.url}" class="d-block text-body text-decoration-none m-2 border border-3 ${s.ping ? 'border-success' : 'border-danger'}" style="width: 175px">
<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 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="static/icons/${s.id}.${s.icon_filetype}" class="img-fluid w-75">
<img src="static/icons/${s.service_id - 1}.svg" class="img-fluid w-75">
</div>
<div>
${s.public ? `` : `<img src='static/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.online ? s.ping + "ms" : ""}</div>
${s.public_access ? `` : `<img src='static/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>
</div>

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 ###

View File

@@ -1,12 +1,62 @@
from mem import db
from datetime import datetime, timezone
from validators import url as is_url
from typing import Any, Optional
class log(db.Model):
id = db.Column(db.Integer, primary_key=True)
dateCreated = db.Column(db.DateTime, nullable=False)
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)
def __init__(self):
def __init__(self, service_id: int, ping: Optional[int]):
super().__init__()
self.service_id = service_id
self.ping = ping
self.dateCreated = datetime.now(timezone.utc)
def to_dict(self) -> dict[str, Any]:
return {
"log_id": self.id,
"service_id": self.service_id,
"ping": self.ping,
"dateCreated": self.dateCreated,
}
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,
}

View File

@@ -1,8 +1,8 @@
from mem import services, service, db, app
from mem import db, app
import aiohttp
import asyncio
import time
from models import log
from models import log, service
from sqlalchemy.orm import sessionmaker
@@ -10,40 +10,32 @@ async def check_service(client: aiohttp.ClientSession, s: service) -> log:
try:
timeout = aiohttp.client.ClientTimeout(total=4)
before = time.perf_counter()
match s.ping_type:
match s.ping_method:
case 0:
r = await client.head(
url=s.url,
ssl=True if s.public else False,
ssl=True if s.public_access else False,
allow_redirects=True,
timeout=timeout,
)
case 1:
r = await client.get(
url=s.url,
ssl=True if s.public else False,
ssl=True if s.public_access else False,
allow_redirects=True,
timeout=timeout,
)
case _:
raise Exception("UNKNOWN PING TYPE")
after = time.perf_counter()
s.set_error(None)
s.set_online(r.status == 200)
s.set_status(r.status)
s.set_ping(int((after - before) * 1000))
if r.status == 200:
return log(service_id=s.id, ping=int((after - before) * 1000))
else:
return log(service_id=s.id, ping=None)
except aiohttp.ConnectionTimeoutError:
s.set_error("Connection Timeout")
s.set_online(False)
s.set_status(None)
s.set_ping(None)
except Exception as e:
print(type(e))
s.set_error(str(e))
s.set_online(False)
s.set_status(None)
s.set_ping(None)
return log()
return log(service_id=s.id, ping=None)
except Exception:
return log(service_id=s.id, ping=None)
def start_async_loop():
@@ -53,11 +45,6 @@ def start_async_loop():
loop.run_forever()
async def sleepTask():
await asyncio.sleep(5)
return log()
async def update_services(loop: asyncio.AbstractEventLoop):
print("Starting service updates...")
with app.app_context():
@@ -65,10 +52,13 @@ async def update_services(loop: asyncio.AbstractEventLoop):
client = aiohttp.ClientSession()
while True:
session = WorkerSession()
tasks = [check_service(client=client, s=s) for s in services]
tasks.append(sleepTask())
sleeptask = asyncio.create_task(asyncio.sleep(5))
tasks = [
check_service(client=client, s=s)
for s in session.query(service).all()
]
logs = await asyncio.gather(*tasks)
logs = logs[:-1]
await sleeptask
try:
session.add_all(logs)
session.commit()

View File

@@ -2,10 +2,8 @@ aiohappyeyeballs==2.6.1
aiohttp==3.12.15
aiosignal==1.4.0
alembic==1.16.5
anyio==4.10.0
attrs==25.3.0
blinker==1.9.0
certifi==2025.8.3
click==8.2.1
colorama==0.4.6
Flask==3.1.2
@@ -13,8 +11,6 @@ Flask-Migrate==4.1.0
Flask-SQLAlchemy==3.1.1
frozenlist==1.7.0
greenlet==3.2.4
h11==0.16.0
httpcore==1.0.9
idna==3.10
itsdangerous==2.2.0
Jinja2==3.1.6
@@ -22,8 +18,8 @@ Mako==1.3.10
MarkupSafe==3.0.2
multidict==6.6.4
propcache==0.3.2
sniffio==1.3.1
SQLAlchemy==2.0.43
typing_extensions==4.15.0
validators==0.35.0
Werkzeug==3.1.3
yarl==1.20.1