This commit is contained in:
Nexus 2024-09-03 14:48:58 +01:00
parent 3d60c27dcc
commit b8ec4b0ddf
8 changed files with 247 additions and 45 deletions

View file

@ -38,7 +38,6 @@ class Site(Model):
async with in_transaction() as tx: async with in_transaction() as tx:
return await self.run_health_check(using_db=tx) return await self.run_health_check(using_db=tx)
start = time.perf_counter()
online = False online = False
latency = 0 latency = 0
status_code = 0 status_code = 0
@ -59,15 +58,15 @@ class Site(Model):
status_code = response.status_code status_code = response.status_code
latency = round(response.elapsed.microseconds / 1000) latency = round(response.elapsed.microseconds / 1000)
online = response.is_success online = response.is_success
except httpx.HTTPError: except httpx.HTTPError as e:
status_code = 0 status_code = 0
latency = (time.perf_counter() - start) * 1000 latency = (time.perf_counter() - start) * 1000
online = False online = False
sleep_time = 2**retry_count + random.uniform(0, 1) sleep_time = 2**retry_count + random.uniform(0, 1)
sleep_time = min(60, sleep_time) sleep_time = min(60, sleep_time)
log.warning( log.warning(
"Retry %d on %r failed - sleeping %.2f seconds and trying again", "Retry %d on %r failed - sleeping %.2f seconds and trying again (%r)",
retry_count, self, sleep_time, exc_info=True retry_count, self, sleep_time, e
) )
await asyncio.sleep(sleep_time) await asyncio.sleep(sleep_time)
else: else:
@ -117,24 +116,31 @@ class HealthCheckRunner(asyncio.Queue):
async def run(self): async def run(self):
while True: while True:
self.log.info("Waiting for a heathcheck job.")
site = await self.get() site = await self.get()
self.log.info("Got a HealthCheck job: %r", site) self.log.debug("HealthCheck job: %r", site)
self.log.info("HealthCheck job: %s", site)
await site.run_health_check() await site.run_health_check()
self.task_done() self.task_done()
self.log.info("Finisdelay=hed health check job: %r", site) self.log.debug("Finished health check job: %r", site)
self.log.debug("Finished health check job: %s", site)
async def add_site(self, site: Site): async def add_site(self, site: Site):
await self.put(site) await self.put(site)
self.log.info("Added a HealthCheck job: %r", site) self.log.debug("Added a HealthCheck job: %r", site)
return site return site
async def add_expired_sites(self): async def add_expired_sites(self):
# Sites that have not had a health check in the last hour # Sites that have not had a health check in the last hour
expired = ( expired = list(
await Site.filter(health_checks__checked_at__lt=datetime.datetime.now() - datetime.timedelta(hours=1)) await Site.filter(health_checks__checked_at__lt=datetime.datetime.now() - datetime.timedelta(hours=1))
.order_by("health_checks__checked_at") .order_by("health_checks__checked_at")
.distinct() .distinct()
) )
# Sites that have not had a health check at all
never_checked = await Site.filter(health_checks__isnull=True)
if never_checked:
expired.extend(list(never_checked))
for site in expired: for site in expired:
await self.add_site(site) await self.add_site(site)
@ -146,3 +152,6 @@ class HealthCheckRunner(asyncio.Queue):
except asyncio.CancelledError: except asyncio.CancelledError:
self.log.info("HealthCheckRunner loop cancelled.") self.log.info("HealthCheckRunner loop cancelled.")
return return
RUNNER = HealthCheckRunner()

View file

@ -1,17 +1,23 @@
import asyncio import asyncio
import os
import logging import logging
from tortoise.contrib.fastapi import RegisterTortoise import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import Body, Depends, HTTPException, Query, Request, BackgroundTasks
from fastapi import FastAPI from fastapi import FastAPI
from .models import HealthCheckRunner from fastapi.middleware.cors import CORSMiddleware
from fastapi import HTTPException, Query, Depends, Body from fastapi.responses import RedirectResponse
from fastapi.security import APIKeyHeader from fastapi.security import APIKeyHeader
from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
from httpx import AsyncClient
from tortoise import generate_config from tortoise import generate_config
from tortoise.contrib.fastapi import RegisterTortoise
from tortoise.queryset import Q from tortoise.queryset import Q
from tortoise.transactions import in_transaction from tortoise.transactions import in_transaction
from httpx import AsyncClient
from .models import Site, HealthCheck, SitesModel, HealthChecksModel from .models import HealthCheck, HealthChecksModel, RUNNER, Site, SitesModel
if not os.environ.get("API_KEY"): if not os.environ.get("API_KEY"):
@ -26,6 +32,8 @@ async def lifespan(_app):
handler.setFormatter(logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")) handler.setFormatter(logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s"))
logging.getLogger("uvicorn.error").handlers = [handler] logging.getLogger("uvicorn.error").handlers = [handler]
logging.getLogger("uvicorn.access").handlers = [handler] logging.getLogger("uvicorn.access").handlers = [handler]
logging.getLogger("uvicorn").handlers = [handler]
logging.info("Starting")
async with RegisterTortoise( async with RegisterTortoise(
_app, _app,
config=generate_config( config=generate_config(
@ -39,11 +47,15 @@ async def lifespan(_app):
use_tz=True, use_tz=True,
_create_db=True, _create_db=True,
): ):
_app.state.health_check_runner = HealthCheckRunner() default = ["nexy7574.co.uk", "i-am.nexus", "transgender.ing", "example.invalid"]
_app.state.hc_runner_task = asyncio.create_task(_app.state.health_check_runner.run()) logging.info("Generating default values")
_app.state.hc_runner_loop = asyncio.create_task(_app.state.health_check_runner.loop()) for d in default:
await Site.get_or_create(domain=d, name=d, contact="https://matrix.to/#/@nex:nexy7574.co.uk")
logging.info("Starting health check runners")
_app.state.hc_runner_task = asyncio.create_task(RUNNER.run())
logging.info("Starting API")
yield yield
_app.state.hc_runner_loop.cancel() _app.state.hc_runner_task.cancel()
app = FastAPI( app = FastAPI(
@ -52,6 +64,14 @@ app = FastAPI(
debug=os.environ.get("DEBUG") in ["0", "false", "no", None], debug=os.environ.get("DEBUG") in ["0", "false", "no", None],
lifespan=lifespan lifespan=lifespan
) )
templates = Jinja2Templates(directory=Path(__file__).parent / "templates")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["GET"],
allow_credentials=False
)
app.mount("/static", StaticFiles(directory=Path(__file__).parent / "static"), name="static")
API_KEY = APIKeyHeader(name="X-API-Key") API_KEY = APIKeyHeader(name="X-API-Key")
@ -71,6 +91,16 @@ async def get_http_client() -> AsyncClient:
yield http_client yield http_client
@app.get("/", include_in_schema=False)
async def home(req: Request):
sites = await SitesModel.from_queryset(Site.all())
return templates.TemplateResponse(
request=req,
name="index.html",
context={"request": req, "sites": sites}
)
@app.get("/sites") @app.get("/sites")
async def get_sites( async def get_sites(
limit: int = Query(50, ge=1, le=100), limit: int = Query(50, ge=1, le=100),
@ -81,7 +111,7 @@ async def get_sites(
@app.get("/sites/{identifier}") @app.get("/sites/{identifier}")
async def get_site(identifier: str) -> SitesModel: async def get_site(identifier: str, tasks: BackgroundTasks) -> SitesModel:
""" """
Returns a single site. Returns a single site.
@ -91,6 +121,7 @@ async def get_site(identifier: str) -> SitesModel:
result = await Site.filter(Q(guid=identifier) | Q(domain=identifier)).first() result = await Site.filter(Q(guid=identifier) | Q(domain=identifier)).first()
if not result: if not result:
raise HTTPException(status_code=404, detail="Site not found.") raise HTTPException(status_code=404, detail="Site not found.")
tasks.add_task(result.run_health_check)
return await SitesModel.from_tortoise_orm(result) return await SitesModel.from_tortoise_orm(result)
@ -116,8 +147,10 @@ async def get_site_health(
@app.get("/previous") @app.get("/previous")
async def get_previous_domain( async def get_previous_domain(
tasks: BackgroundTasks,
domain: str = Query(..., min_length=4, max_length=1024, alias="d"), domain: str = Query(..., min_length=4, max_length=1024, alias="d"),
allow_offline: bool = Query(False), allow_offline: bool = Query(False),
redirect: bool = Query(True)
) -> SitesModel: ) -> SitesModel:
""" """
Returns the most recent site added with the given domain. Returns the most recent site added with the given domain.
@ -131,28 +164,34 @@ async def get_previous_domain(
# Then, get the previous domain # Then, get the previous domain
if allow_offline is False: if allow_offline is False:
previous = Site.filter(online=allow_offline) previous = Site.filter(online=True)
else: else:
previous = Site previous = Site
previous = await previous.filter(added_at__lt=site.added_at).order_by("-added_at").first() previous = await previous.filter(added_at__lt=site.added_at).order_by("-added_at").first()
if not previous: if not previous:
# Fetch the latest site, there is none before us. # Fetch the latest site, there is none before us.
previous = await Site.order_by("-added_at").first() previous = await Site.all().order_by("-added_at").first()
if previous.domain == domain: if previous.domain == domain:
raise HTTPException(500, "Could not find previous nor next domain.") raise HTTPException(500, "Could not find previous nor next domain.")
tasks.add_task(previous.run_health_check)
if redirect:
return RedirectResponse(url=f"https://{previous.domain}", status_code=308)
return await SitesModel.from_tortoise_orm(previous) return await SitesModel.from_tortoise_orm(previous)
@app.get("/next") @app.get("/next")
async def get_next_domain( async def get_next_domain(
tasks: BackgroundTasks,
domain: str = Query(..., min_length=4, max_length=1024, alias="d"), domain: str = Query(..., min_length=4, max_length=1024, alias="d"),
allow_offline: bool = Query(False), allow_offline: bool = Query(False),
redirect: bool = Query(True)
) -> SitesModel: ) -> SitesModel:
""" """
Returns the next site added after the given domain. Returns the next site added after the given domain.
Raises 404 if the domain is not found. Raises 404 if the domain is not found.
If redirect is True (the default), a 308 Permanent Redirect is returned.
""" """
# First, get the current domain # First, get the current domain
site = await Site.get_or_none(domain=domain) site = await Site.get_or_none(domain=domain)
@ -161,16 +200,20 @@ async def get_next_domain(
# Then, get the next domain # Then, get the next domain
if allow_offline is False: if allow_offline is False:
next_site = Site.filter(online=allow_offline) next_site = Site.filter(online=True)
else: else:
next_site = Site next_site = Site
next_site = await next_site.filter(added_at__gt=site.added_at).order_by("added_at").first() next_site = await next_site.filter(added_at__gt=site.added_at).order_by("added_at").first()
if not next_site: if not next_site:
# Fetch the first site, there is none after us. # Fetch the first site, there is none after us.
next_site = await Site.order_by("added_at").first() next_site = await Site.all().order_by("added_at").filter(domain__not=domain).first()
if next_site.domain == domain: if not next_site or next_site.domain == domain:
raise HTTPException(500, "Could not find previous nor next domain.") raise HTTPException(500, "Could not find previous nor next domain.")
tasks.add_task(site.run_health_check)
if redirect:
return RedirectResponse(url=f"https://{next_site.domain}", status_code=308)
return await SitesModel.from_tortoise_orm(next_site) return await SitesModel.from_tortoise_orm(next_site)
@ -191,25 +234,6 @@ async def add_new_site(
return await SitesModel.from_tortoise_orm(site) return await SitesModel.from_tortoise_orm(site)
@app.post("/sites/{identifier}/health", dependencies=[Depends(get_api_key)], status_code=201)
async def start_health_check(identifier: str) -> dict[str, str | int | SitesModel]:
"""
Adds a health check to the queue for the site.
The identifier can be the GUID of the site or the domain name.
Raises 404 if the site is not found.
"""
site = await Site.filter(Q(guid=identifier) | Q(domain=identifier)).first()
if not site:
raise HTTPException(status_code=404, detail="Site not found.")
await app.state.health_check_runner.put(site)
return {
"message": "Health check added to queue.",
"site": await SitesModel.from_tortoise_orm(site),
"queue.length": app.state.health_check_runner.qsize(),
}
@app.delete("/sites/{identifier}", dependencies=[Depends(get_api_key)]) @app.delete("/sites/{identifier}", dependencies=[Depends(get_api_key)])
async def delete_site(identifier: str): async def delete_site(identifier: str):
""" """

3
src/static/prism.css Normal file
View file

@ -0,0 +1,3 @@
/* PrismJS 1.29.0
https://prismjs.com/download.html#themes=prism-tomorrow&languages=markup+css+clike+javascript */
code[class*=language-],pre[class*=language-]{color:#ccc;background:0 0;font-family:Consolas,Monaco,'Andale Mono','Ubuntu Mono',monospace;font-size:1em;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none}pre[class*=language-]{padding:1em;margin:.5em 0;overflow:auto}:not(pre)>code[class*=language-],pre[class*=language-]{background:#2d2d2d}:not(pre)>code[class*=language-]{padding:.1em;border-radius:.3em;white-space:normal}.token.block-comment,.token.cdata,.token.comment,.token.doctype,.token.prolog{color:#999}.token.punctuation{color:#ccc}.token.attr-name,.token.deleted,.token.namespace,.token.tag{color:#e2777a}.token.function-name{color:#6196cc}.token.boolean,.token.function,.token.number{color:#f08d49}.token.class-name,.token.constant,.token.property,.token.symbol{color:#f8c555}.token.atrule,.token.builtin,.token.important,.token.keyword,.token.selector{color:#cc99cd}.token.attr-value,.token.char,.token.regex,.token.string,.token.variable{color:#7ec699}.token.entity,.token.operator,.token.url{color:#67cdcc}.token.bold,.token.important{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help}.token.inserted{color:green}

7
src/static/prism.js Normal file

File diff suppressed because one or more lines are too long

13
src/static/script.js Normal file
View file

@ -0,0 +1,13 @@
function runHealthCheck(target) {
async function inner() {
try {
const response = await fetch("/sites/" + target).then((r) => r.json());
location.reload();
}
catch (error) {
console.error("Error:", error);
}
}
return inner;
}

View file

@ -0,0 +1,82 @@
:root {
--background-primary: #1F242C;
--background-secondary: #272C34;
--text-primary: #abb2bf;
--text-link: #56b6c2;
--text-link-visited: #c678dd
}
@media screen and (prefers-color-scheme: light) {
:root {
--background-primary: #f5f5f5;
--background-secondary: #fff;
--text-primary: #333;
}
}
html {
font-family: sans-serif;
padding: 0;
margin: 0;
height: 100%;
width: 100%;
background-color: var(--background-primary);
color: var(--text-primary);
}
body {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
width: 100%;
padding: 0;
margin: 0;
text-align: center;
}
table {
width: 100%;
border-collapse: collapse;
background-color: var(--background-secondary);
color: var(--text-primary);
}
a {
color: var(--text-link);
text-decoration: none;
}
a:visited {
color: var(--text-link-visited);
}
table th,
table td {
padding: 0.5em;
border: 1px solid #444;
}
table th {
background-color: #222;
font-weight: bold;
}
table tr:nth-child(even) {
background-color: #333;
}
table tr:hover {
background-color: #555;
}
.online {
color: #89ca78;
background-color: #89ca78;
}
.offline {
color: #ef596f;
background-color: #ef596f;
}

39
src/templates/about.html Normal file
View file

@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="en">
<head>
<link rel="stylesheet" href="{{ url_for('static', path='/prism.css') }}">
<script src="{{ url_for('static', path='/prism.js') }}" defer></script>
</head>
<body>
<header>
<!--Begin about template (to be imported into index.html)-->
<noscript>
<style>
code.noscript {
display: block;
padding: 1em;
background-color: var(--background-secondary);
border: 1px solid #ddd;
border-radius: 12px;
overflow-x: auto;
white-space: pre-wrap;
word-wrap: break-word;
margin: 1em 0;
}
</style>
</noscript>
<div style="text-align: center;">
<h1>WebbedRing</h1>
<p>Lorem ipsum</p>
<h2>Code to use ring</h2>
<pre><code class="language-html noscript">&lt;div&gt;
&lt;a href="https://webbed-ring.example/previous?d=my-domain.example" rel="noopener noreferrer"&gt;Previous&lt;/a&gt;
&lt;a href="https://webbed-ring.example/" rel="noopener noreferrer"&gt;WebbedRingName&lt;/a&gt;
&lt;a href="https://webbed-ring.example/next?d=my-domain.example" rel="noopener noreferrer"&gt;Next&lt;/a&gt;
&lt;/div&gt;</code></pre>
</div>
<!--End about template-->
</header>
</body>
</html>

View file

@ -2,9 +2,34 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>Title</title> <title>WebbedRing</title>
<link rel="stylesheet" href="{{ url_for('static', path='/style.css') }}">
</head> </head>
<body> <body>
<main>
{% include "about.html" %}
<table>
<thead>
<tr>
<th>Name</th>
<th>Domain</th>
<th>Online</th>
</tr>
</thead>
<tbody>
{% for site in sites %}
<tr>
<td>{{ site.name }}</td>
<td><a href="https://{{ site.domain }}" target="_blank" rel="noopener noreferrer">{{ site.domain }}</a></td>
{% if site.online %}
<td class="online">Yes</td>
{% else %}
<td class="offline">No</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</main>
</body> </body>
</html> </html>