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 import threading
from flask_migrate import upgrade, stamp from flask_migrate import upgrade, stamp
from pathlib import Path from pathlib import Path
from models import service, log
from typing import Any, Optional, cast
# Init and upgrade # Init and upgrade
with app.app_context(): with app.app_context():
@@ -17,6 +18,19 @@ with app.app_context():
# Upgrade db if any new migrations exist # Upgrade db if any new migrations exist
upgrade() 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("/") @app.route("/")
def homepage(): def homepage():
@@ -25,7 +39,20 @@ def homepage():
@app.route("/api/status") @app.route("/api/status")
def 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") @app.route("/favicon.svg")
@@ -35,7 +62,6 @@ def favicon():
# Only run if directly running file # Only run if directly running file
if __name__ == "__main__": if __name__ == "__main__":
t = threading.Thread(target=start_async_loop, daemon=True) t = threading.Thread(target=start_async_loop, daemon=True)
t.start() t.start()

View File

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

View File

@@ -75,18 +75,18 @@
// Build all service divs as a single string // Build all service divs as a single string
main_body.innerHTML = services.map(s => ` 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-body-tertiary d-flex flex-column align-items-center">
<div class="bg-dark w-100"> <div class="bg-dark w-100">
<h4 class="text-center text-truncate m-0 p-1">${s.label}</h4> <h4 class="text-center text-truncate m-0 p-1">${s.label}</h4>
</div> </div>
<div class="position-relative ratio ratio-1x1"> <div class="position-relative ratio ratio-1x1">
<div class="d-flex justify-content-center align-items-center"> <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>
<div> <div>
${s.public ? `` : `<img src='static/lock.svg' class='img-fluid position-absolute bottom-0 end-0 w-25'>`} ${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.online ? s.ping + "ms" : ""}</div> <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> </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 mem import db
from datetime import datetime, timezone from datetime import datetime, timezone
from validators import url as is_url
from typing import Any, Optional
class log(db.Model): class log(db.Model):
id = db.Column(db.Integer, primary_key=True) id: int = db.Column(db.Integer, primary_key=True) # TODO: Switch to UUID
dateCreated = db.Column(db.DateTime, nullable=False) 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__() super().__init__()
self.service_id = service_id
self.ping = ping
self.dateCreated = datetime.now(timezone.utc) 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 aiohttp
import asyncio import asyncio
import time import time
from models import log from models import log, service
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
@@ -10,40 +10,32 @@ async def check_service(client: aiohttp.ClientSession, s: service) -> log:
try: try:
timeout = aiohttp.client.ClientTimeout(total=4) timeout = aiohttp.client.ClientTimeout(total=4)
before = time.perf_counter() before = time.perf_counter()
match s.ping_type: match s.ping_method:
case 0: case 0:
r = await client.head( r = await client.head(
url=s.url, url=s.url,
ssl=True if s.public else False, ssl=True if s.public_access else False,
allow_redirects=True, allow_redirects=True,
timeout=timeout, timeout=timeout,
) )
case 1: case 1:
r = await client.get( r = await client.get(
url=s.url, url=s.url,
ssl=True if s.public else False, ssl=True if s.public_access else False,
allow_redirects=True, allow_redirects=True,
timeout=timeout, timeout=timeout,
) )
case _: case _:
raise Exception("UNKNOWN PING TYPE") raise Exception("UNKNOWN PING TYPE")
after = time.perf_counter() after = time.perf_counter()
s.set_error(None) if r.status == 200:
s.set_online(r.status == 200) return log(service_id=s.id, ping=int((after - before) * 1000))
s.set_status(r.status) else:
s.set_ping(int((after - before) * 1000)) return log(service_id=s.id, ping=None)
except aiohttp.ConnectionTimeoutError: except aiohttp.ConnectionTimeoutError:
s.set_error("Connection Timeout") return log(service_id=s.id, ping=None)
s.set_online(False) except Exception:
s.set_status(None) return log(service_id=s.id, ping=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()
def start_async_loop(): def start_async_loop():
@@ -53,11 +45,6 @@ def start_async_loop():
loop.run_forever() loop.run_forever()
async def sleepTask():
await asyncio.sleep(5)
return log()
async def update_services(loop: asyncio.AbstractEventLoop): async def update_services(loop: asyncio.AbstractEventLoop):
print("Starting service updates...") print("Starting service updates...")
with app.app_context(): with app.app_context():
@@ -65,10 +52,13 @@ async def update_services(loop: asyncio.AbstractEventLoop):
client = aiohttp.ClientSession() client = aiohttp.ClientSession()
while True: while True:
session = WorkerSession() session = WorkerSession()
tasks = [check_service(client=client, s=s) for s in services] sleeptask = asyncio.create_task(asyncio.sleep(5))
tasks.append(sleepTask()) tasks = [
check_service(client=client, s=s)
for s in session.query(service).all()
]
logs = await asyncio.gather(*tasks) logs = await asyncio.gather(*tasks)
logs = logs[:-1] await sleeptask
try: try:
session.add_all(logs) session.add_all(logs)
session.commit() session.commit()

View File

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