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:
return await self.run_health_check(using_db=tx)
start = time.perf_counter()
online = False
latency = 0
status_code = 0
@ -59,15 +58,15 @@ class Site(Model):
status_code = response.status_code
latency = round(response.elapsed.microseconds / 1000)
online = response.is_success
except httpx.HTTPError:
except httpx.HTTPError as e:
status_code = 0
latency = (time.perf_counter() - start) * 1000
online = False
sleep_time = 2**retry_count + random.uniform(0, 1)
sleep_time = min(60, sleep_time)
log.warning(
"Retry %d on %r failed - sleeping %.2f seconds and trying again",
retry_count, self, sleep_time, exc_info=True
"Retry %d on %r failed - sleeping %.2f seconds and trying again (%r)",
retry_count, self, sleep_time, e
)
await asyncio.sleep(sleep_time)
else:
@ -117,24 +116,31 @@ class HealthCheckRunner(asyncio.Queue):
async def run(self):
while True:
self.log.info("Waiting for a heathcheck job.")
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()
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):
await self.put(site)
self.log.info("Added a HealthCheck job: %r", site)
self.log.debug("Added a HealthCheck job: %r", site)
return site
async def add_expired_sites(self):
# 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))
.order_by("health_checks__checked_at")
.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:
await self.add_site(site)
@ -146,3 +152,6 @@ class HealthCheckRunner(asyncio.Queue):
except asyncio.CancelledError:
self.log.info("HealthCheckRunner loop cancelled.")
return
RUNNER = HealthCheckRunner()

View file

@ -1,17 +1,23 @@
import asyncio
import os
import logging
from tortoise.contrib.fastapi import RegisterTortoise
import os
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import Body, Depends, HTTPException, Query, Request, BackgroundTasks
from fastapi import FastAPI
from .models import HealthCheckRunner
from fastapi import HTTPException, Query, Depends, Body
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import RedirectResponse
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.contrib.fastapi import RegisterTortoise
from tortoise.queryset import Q
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"):
@ -26,6 +32,8 @@ async def lifespan(_app):
handler.setFormatter(logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s"))
logging.getLogger("uvicorn.error").handlers = [handler]
logging.getLogger("uvicorn.access").handlers = [handler]
logging.getLogger("uvicorn").handlers = [handler]
logging.info("Starting")
async with RegisterTortoise(
_app,
config=generate_config(
@ -39,11 +47,15 @@ async def lifespan(_app):
use_tz=True,
_create_db=True,
):
_app.state.health_check_runner = HealthCheckRunner()
_app.state.hc_runner_task = asyncio.create_task(_app.state.health_check_runner.run())
_app.state.hc_runner_loop = asyncio.create_task(_app.state.health_check_runner.loop())
default = ["nexy7574.co.uk", "i-am.nexus", "transgender.ing", "example.invalid"]
logging.info("Generating default values")
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
_app.state.hc_runner_loop.cancel()
_app.state.hc_runner_task.cancel()
app = FastAPI(
@ -52,6 +64,14 @@ app = FastAPI(
debug=os.environ.get("DEBUG") in ["0", "false", "no", None],
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")
@ -71,6 +91,16 @@ async def get_http_client() -> AsyncClient:
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")
async def get_sites(
limit: int = Query(50, ge=1, le=100),
@ -81,7 +111,7 @@ async def get_sites(
@app.get("/sites/{identifier}")
async def get_site(identifier: str) -> SitesModel:
async def get_site(identifier: str, tasks: BackgroundTasks) -> SitesModel:
"""
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()
if not result:
raise HTTPException(status_code=404, detail="Site not found.")
tasks.add_task(result.run_health_check)
return await SitesModel.from_tortoise_orm(result)
@ -116,8 +147,10 @@ async def get_site_health(
@app.get("/previous")
async def get_previous_domain(
tasks: BackgroundTasks,
domain: str = Query(..., min_length=4, max_length=1024, alias="d"),
allow_offline: bool = Query(False),
redirect: bool = Query(True)
) -> SitesModel:
"""
Returns the most recent site added with the given domain.
@ -131,28 +164,34 @@ async def get_previous_domain(
# Then, get the previous domain
if allow_offline is False:
previous = Site.filter(online=allow_offline)
previous = Site.filter(online=True)
else:
previous = Site
previous = await previous.filter(added_at__lt=site.added_at).order_by("-added_at").first()
if not previous:
# 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:
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)
@app.get("/next")
async def get_next_domain(
tasks: BackgroundTasks,
domain: str = Query(..., min_length=4, max_length=1024, alias="d"),
allow_offline: bool = Query(False),
redirect: bool = Query(True)
) -> SitesModel:
"""
Returns the next site added after the given domain.
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
site = await Site.get_or_none(domain=domain)
@ -161,16 +200,20 @@ async def get_next_domain(
# Then, get the next domain
if allow_offline is False:
next_site = Site.filter(online=allow_offline)
next_site = Site.filter(online=True)
else:
next_site = Site
next_site = await next_site.filter(added_at__gt=site.added_at).order_by("added_at").first()
if not next_site:
# Fetch the first site, there is none after us.
next_site = await Site.order_by("added_at").first()
if next_site.domain == domain:
next_site = await Site.all().order_by("added_at").filter(domain__not=domain).first()
if not next_site or next_site.domain == 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)
@ -191,25 +234,6 @@ async def add_new_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)])
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">
<head>
<meta charset="UTF-8">
<title>Title</title>
<title>WebbedRing</title>
<link rel="stylesheet" href="{{ url_for('static', path='/style.css') }}">
</head>
<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>
</html>