Stuff!
This commit is contained in:
parent
3d60c27dcc
commit
b8ec4b0ddf
8 changed files with 247 additions and 45 deletions
|
@ -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()
|
||||||
|
|
|
@ -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
3
src/static/prism.css
Normal 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
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
13
src/static/script.js
Normal 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;
|
||||||
|
}
|
||||||
|
|
|
@ -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
39
src/templates/about.html
Normal 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"><div>
|
||||||
|
<a href="https://webbed-ring.example/previous?d=my-domain.example" rel="noopener noreferrer">Previous</a>
|
||||||
|
<a href="https://webbed-ring.example/" rel="noopener noreferrer">WebbedRingName</a>
|
||||||
|
<a href="https://webbed-ring.example/next?d=my-domain.example" rel="noopener noreferrer">Next</a>
|
||||||
|
</div></code></pre>
|
||||||
|
</div>
|
||||||
|
<!--End about template-->
|
||||||
|
</header>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -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>
|
Loading…
Reference in a new issue