feat: Add new status page
16
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/uv-pre-commit
|
||||
# uv version.
|
||||
rev: 0.9.8
|
||||
hooks:
|
||||
- id: uv-lock
|
||||
- id: uv-export
|
||||
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.14.4
|
||||
hooks:
|
||||
# Run the linter.
|
||||
- id: ruff-check
|
||||
# Run the formatter.
|
||||
- id: ruff-format
|
||||
17
Dockerfile
@@ -1,17 +0,0 @@
|
||||
FROM --platform=$BUILDPLATFORM python:3.10-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt /app
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
python3 -m pip install -r requirements.txt
|
||||
|
||||
COPY . /app
|
||||
|
||||
# Mount /data to store the data
|
||||
VOLUME /data
|
||||
|
||||
ENTRYPOINT ["python3"]
|
||||
CMD ["main.py"]
|
||||
|
||||
FROM builder as dev-envs
|
||||
52
README.md
@@ -1,4 +1,52 @@
|
||||
# HNSDoH Status
|
||||
This is a simple webserver to check the status of the Handshake DoH server.
|
||||
|
||||
It will check every 5 minutes to see if each node is up and running. It checks the node for plain dns, DNS over HTTPS, and DNS over TLS. For DNS over HTTPS and DNS over TLS, it will check the certificate to make sure it is valid.
|
||||
HNSDoH Status is a Flask service that discovers HNSDoH nodes from DNS A records for `hnsdoh.com` and continuously checks each node for:
|
||||
|
||||
- DNS over UDP on port 53
|
||||
- DNS over TCP on port 53
|
||||
- DNS over HTTPS (DoH) on port 443 at `/dns-query`
|
||||
- DNS over TLS (DoT) on port 853
|
||||
|
||||
For DoH and DoT, TLS certificates are validated with hostname `hnsdoh.com`.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. Resolve `hnsdoh.com` A records to discover active node IP addresses.
|
||||
2. Probe each discovered node for all four protocols.
|
||||
3. Keep current status and short in-memory history.
|
||||
4. Expose results through a web dashboard and JSON API.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
uv run python main.py
|
||||
```
|
||||
|
||||
The app runs on `0.0.0.0:8000` by default.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
- `GET /`: HTML status page
|
||||
- `GET /api/status`: current snapshot and history
|
||||
- `GET /api/health`: service health (503 if stale or no checks yet)
|
||||
|
||||
## Configuration
|
||||
|
||||
Environment variables:
|
||||
|
||||
- `HNSDOH_DOMAIN` (default: `hnsdoh.com`)
|
||||
- `HNSDOH_DOH_PATH` (default: `/dns-query`)
|
||||
- `HNSDOH_CHECK_INTERVAL_SECONDS` (default: `300`)
|
||||
- `HNSDOH_UI_REFRESH_SECONDS` (default: `30`)
|
||||
- `HNSDOH_HISTORY_SIZE` (default: `12`)
|
||||
- `HNSDOH_STALE_AFTER_SECONDS` (default: `900`)
|
||||
- `HNSDOH_DNS_TIMEOUT_SECONDS` (default: `5`)
|
||||
- `HNSDOH_DOH_TIMEOUT_SECONDS` (default: `10`)
|
||||
- `HNSDOH_DOT_TIMEOUT_SECONDS` (default: `10`)
|
||||
|
||||
## Notes
|
||||
|
||||
- Discovery uses DNS A records only.
|
||||
- DoH check uses RFC8484 DNS wireformat (`application/dns-message`) to each node IP while sending SNI/Host as `hnsdoh.com` for strict certificate hostname verification.
|
||||
- History is in-memory and resets on process restart.
|
||||
|
||||
29
hnsdoh_status/__init__.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Flask
|
||||
|
||||
from hnsdoh_status.config import Settings
|
||||
from hnsdoh_status.routes import create_routes
|
||||
from hnsdoh_status.scheduler import create_scheduler
|
||||
from hnsdoh_status.store import StatusStore
|
||||
|
||||
|
||||
scheduler = None
|
||||
|
||||
|
||||
def create_app() -> Flask:
|
||||
app = Flask(__name__)
|
||||
settings = Settings()
|
||||
store = StatusStore(history_size=settings.history_size)
|
||||
|
||||
app.config["SETTINGS"] = settings
|
||||
app.config["STORE"] = store
|
||||
|
||||
create_routes(app, settings, store)
|
||||
|
||||
global scheduler
|
||||
if scheduler is None:
|
||||
scheduler = create_scheduler(settings, store)
|
||||
scheduler.start()
|
||||
|
||||
return app
|
||||
250
hnsdoh_status/checks.py
Normal file
@@ -0,0 +1,250 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import ssl
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import dns.message
|
||||
import dns.query
|
||||
import dns.rcode
|
||||
import dns.rdatatype
|
||||
import dns.resolver
|
||||
|
||||
from hnsdoh_status.models import CheckResult, NodeSnapshot, ProtocolName, Snapshot
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def discover_nodes(domain: str) -> tuple[list[str], str]:
|
||||
resolver = dns.resolver.Resolver()
|
||||
try:
|
||||
answer = resolver.resolve(domain, "A")
|
||||
return sorted({record.address for record in answer}), ""
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return [], str(exc)
|
||||
|
||||
|
||||
def _check_dns_udp(ip: str, timeout: float) -> CheckResult:
|
||||
started = time.perf_counter()
|
||||
checked_at = utcnow()
|
||||
query = dns.message.make_query("hnsdoh.com", dns.rdatatype.A)
|
||||
try:
|
||||
response = dns.query.udp(query, ip, timeout=timeout, port=53)
|
||||
latency = (time.perf_counter() - started) * 1000
|
||||
return CheckResult(
|
||||
protocol="dns_udp",
|
||||
ok=bool(response.answer),
|
||||
latency_ms=latency,
|
||||
checked_at=checked_at,
|
||||
reason="ok" if response.answer else "empty answer",
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return CheckResult("dns_udp", False, None, checked_at, str(exc))
|
||||
|
||||
|
||||
def _check_dns_tcp(ip: str, timeout: float) -> CheckResult:
|
||||
started = time.perf_counter()
|
||||
checked_at = utcnow()
|
||||
query = dns.message.make_query("hnsdoh.com", dns.rdatatype.A)
|
||||
try:
|
||||
response = dns.query.tcp(query, ip, timeout=timeout, port=53)
|
||||
latency = (time.perf_counter() - started) * 1000
|
||||
return CheckResult(
|
||||
protocol="dns_tcp",
|
||||
ok=bool(response.answer),
|
||||
latency_ms=latency,
|
||||
checked_at=checked_at,
|
||||
reason="ok" if response.answer else "empty answer",
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return CheckResult("dns_tcp", False, None, checked_at, str(exc))
|
||||
|
||||
|
||||
def _tls_connection(ip: str, port: int, hostname: str, timeout: float) -> ssl.SSLSocket:
|
||||
context = ssl.create_default_context()
|
||||
raw = socket.create_connection((ip, port), timeout=timeout)
|
||||
try:
|
||||
tls_socket = context.wrap_socket(raw, server_hostname=hostname)
|
||||
return tls_socket
|
||||
except Exception:
|
||||
raw.close()
|
||||
raise
|
||||
|
||||
|
||||
def _decode_chunked_body(data: bytes) -> bytes:
|
||||
output = bytearray()
|
||||
cursor = 0
|
||||
|
||||
while True:
|
||||
line_end = data.find(b"\r\n", cursor)
|
||||
if line_end < 0:
|
||||
raise ValueError("invalid chunk framing")
|
||||
|
||||
size_token = data[cursor:line_end].split(b";", maxsplit=1)[0].strip()
|
||||
size = int(size_token or b"0", 16)
|
||||
cursor = line_end + 2
|
||||
|
||||
if size == 0:
|
||||
break
|
||||
|
||||
next_cursor = cursor + size
|
||||
if next_cursor + 2 > len(data):
|
||||
raise ValueError("truncated chunk payload")
|
||||
output.extend(data[cursor:next_cursor])
|
||||
|
||||
if data[next_cursor : next_cursor + 2] != b"\r\n":
|
||||
raise ValueError("invalid chunk terminator")
|
||||
cursor = next_cursor + 2
|
||||
|
||||
return bytes(output)
|
||||
|
||||
|
||||
def _parse_http_response(response: bytes) -> tuple[str, dict[str, str], bytes]:
|
||||
head, separator, body = response.partition(b"\r\n\r\n")
|
||||
if not separator:
|
||||
raise ValueError("invalid HTTP response")
|
||||
|
||||
lines = head.split(b"\r\n")
|
||||
status_line = lines[0].decode("latin-1", errors="replace")
|
||||
headers: dict[str, str] = {}
|
||||
|
||||
for line in lines[1:]:
|
||||
if b":" not in line:
|
||||
continue
|
||||
key, value = line.split(b":", maxsplit=1)
|
||||
headers[key.decode("latin-1", errors="replace").lower()] = value.decode(
|
||||
"latin-1", errors="replace"
|
||||
).strip()
|
||||
|
||||
transfer_encoding = headers.get("transfer-encoding", "").lower()
|
||||
if "chunked" in transfer_encoding:
|
||||
body = _decode_chunked_body(body)
|
||||
|
||||
return status_line, headers, body
|
||||
|
||||
|
||||
def _check_doh(ip: str, hostname: str, path: str, timeout: float) -> CheckResult:
|
||||
started = time.perf_counter()
|
||||
checked_at = utcnow()
|
||||
query = dns.message.make_query(hostname, dns.rdatatype.A)
|
||||
query_wire = query.to_wire()
|
||||
request = (
|
||||
f"POST {path} HTTP/1.1\r\n"
|
||||
f"Host: {hostname}\r\n"
|
||||
"Accept: application/dns-message\r\n"
|
||||
"Content-Type: application/dns-message\r\n"
|
||||
f"Content-Length: {len(query_wire)}\r\n"
|
||||
"Connection: close\r\n\r\n"
|
||||
).encode("ascii") + query_wire
|
||||
|
||||
try:
|
||||
with _tls_connection(ip, 443, hostname, timeout) as conn:
|
||||
conn.settimeout(timeout)
|
||||
conn.sendall(request)
|
||||
response = b""
|
||||
while True:
|
||||
chunk = conn.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
response += chunk
|
||||
|
||||
latency = (time.perf_counter() - started) * 1000
|
||||
status_line, _, body = _parse_http_response(response)
|
||||
status_ok = " 200 " in status_line
|
||||
|
||||
payload_ok = False
|
||||
reason = ""
|
||||
if status_ok and body:
|
||||
try:
|
||||
parsed_dns = dns.message.from_wire(body)
|
||||
payload_ok = parsed_dns.rcode() == dns.rcode.NOERROR and bool(
|
||||
parsed_dns.answer
|
||||
)
|
||||
if payload_ok:
|
||||
reason = "ok"
|
||||
elif parsed_dns.rcode() != dns.rcode.NOERROR:
|
||||
reason = f"dns rcode {dns.rcode.to_text(parsed_dns.rcode())}"
|
||||
else:
|
||||
reason = "empty answer"
|
||||
except Exception: # noqa: BLE001
|
||||
reason = "invalid dns wireformat payload"
|
||||
|
||||
ok = status_ok and payload_ok
|
||||
if not reason:
|
||||
reason = f"http status failed: {status_line}"
|
||||
|
||||
return CheckResult("doh", ok, latency, checked_at, reason)
|
||||
except ssl.SSLCertVerificationError as exc:
|
||||
return CheckResult("doh", False, None, checked_at, f"tls verify failed: {exc}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return CheckResult("doh", False, None, checked_at, str(exc))
|
||||
|
||||
|
||||
def _check_dot(ip: str, hostname: str, timeout: float) -> CheckResult:
|
||||
started = time.perf_counter()
|
||||
checked_at = utcnow()
|
||||
query = dns.message.make_query("hnsdoh.com", dns.rdatatype.A)
|
||||
context = ssl.create_default_context()
|
||||
try:
|
||||
response = dns.query.tls(
|
||||
query,
|
||||
where=ip,
|
||||
timeout=timeout,
|
||||
port=853,
|
||||
ssl_context=context,
|
||||
server_hostname=hostname,
|
||||
)
|
||||
latency = (time.perf_counter() - started) * 1000
|
||||
return CheckResult(
|
||||
protocol="dot",
|
||||
ok=bool(response.answer),
|
||||
latency_ms=latency,
|
||||
checked_at=checked_at,
|
||||
reason="ok" if response.answer else "empty answer",
|
||||
)
|
||||
except ssl.SSLCertVerificationError as exc:
|
||||
return CheckResult("dot", False, None, checked_at, f"tls verify failed: {exc}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return CheckResult("dot", False, None, checked_at, str(exc))
|
||||
|
||||
|
||||
def check_node(
|
||||
ip: str,
|
||||
hostname: str,
|
||||
doh_path: str,
|
||||
dns_timeout: float,
|
||||
doh_timeout: float,
|
||||
dot_timeout: float,
|
||||
) -> NodeSnapshot:
|
||||
results: dict[ProtocolName, CheckResult] = {
|
||||
"dns_udp": _check_dns_udp(ip, dns_timeout),
|
||||
"dns_tcp": _check_dns_tcp(ip, dns_timeout),
|
||||
"doh": _check_doh(ip, hostname, doh_path, doh_timeout),
|
||||
"dot": _check_dot(ip, hostname, dot_timeout),
|
||||
}
|
||||
return NodeSnapshot(ip=ip, results=results)
|
||||
|
||||
|
||||
def run_full_check(
|
||||
domain: str,
|
||||
doh_path: str,
|
||||
dns_timeout: float,
|
||||
doh_timeout: float,
|
||||
dot_timeout: float,
|
||||
) -> Snapshot:
|
||||
checked_at = utcnow()
|
||||
nodes, discovery_error = discover_nodes(domain)
|
||||
snapshots = [
|
||||
check_node(ip, domain, doh_path, dns_timeout, doh_timeout, dot_timeout)
|
||||
for ip in nodes
|
||||
]
|
||||
return Snapshot(
|
||||
domain=domain,
|
||||
checked_at=checked_at,
|
||||
node_count=len(snapshots),
|
||||
nodes=snapshots,
|
||||
discovery_error=discovery_error,
|
||||
)
|
||||
25
hnsdoh_status/config.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Settings:
|
||||
domain: str = os.getenv("HNSDOH_DOMAIN", "hnsdoh.com")
|
||||
doh_path: str = os.getenv("HNSDOH_DOH_PATH", "/dns-query")
|
||||
check_interval_seconds: int = int(os.getenv("HNSDOH_CHECK_INTERVAL_SECONDS", "300"))
|
||||
ui_refresh_seconds: int = int(os.getenv("HNSDOH_UI_REFRESH_SECONDS", "30"))
|
||||
history_size: int = int(os.getenv("HNSDOH_HISTORY_SIZE", "12"))
|
||||
stale_after_seconds: int = int(os.getenv("HNSDOH_STALE_AFTER_SECONDS", "900"))
|
||||
|
||||
dns_timeout_seconds: float = float(os.getenv("HNSDOH_DNS_TIMEOUT_SECONDS", "5"))
|
||||
doh_timeout_seconds: float = float(os.getenv("HNSDOH_DOH_TIMEOUT_SECONDS", "10"))
|
||||
dot_timeout_seconds: float = float(os.getenv("HNSDOH_DOT_TIMEOUT_SECONDS", "10"))
|
||||
|
||||
webhook_url: str = os.getenv("TMP_DISCORD_HOOK", "")
|
||||
40
hnsdoh_status/models.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
ProtocolName = Literal["dns_udp", "dns_tcp", "doh", "dot"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class CheckResult:
|
||||
protocol: ProtocolName
|
||||
ok: bool
|
||||
latency_ms: float | None
|
||||
checked_at: datetime
|
||||
reason: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class NodeSnapshot:
|
||||
ip: str
|
||||
results: dict[ProtocolName, CheckResult] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Snapshot:
|
||||
domain: str
|
||||
checked_at: datetime
|
||||
node_count: int
|
||||
nodes: list[NodeSnapshot]
|
||||
discovery_error: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class StatusPayload:
|
||||
generated_at: datetime
|
||||
interval_seconds: int
|
||||
stale_after_seconds: int
|
||||
current: Snapshot | None
|
||||
history: dict[str, list[dict[ProtocolName, CheckResult]]]
|
||||
105
hnsdoh_status/routes.py
Normal file
@@ -0,0 +1,105 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, Flask, Response, jsonify, render_template
|
||||
|
||||
from hnsdoh_status.config import Settings
|
||||
from hnsdoh_status.models import CheckResult, ProtocolName, Snapshot
|
||||
from hnsdoh_status.store import StatusStore
|
||||
|
||||
|
||||
def _result_to_dict(result: CheckResult) -> dict[str, Any]:
|
||||
return {
|
||||
"protocol": result.protocol,
|
||||
"ok": result.ok,
|
||||
"latency_ms": round(result.latency_ms, 2)
|
||||
if result.latency_ms is not None
|
||||
else None,
|
||||
"checked_at": result.checked_at.isoformat(),
|
||||
"reason": result.reason,
|
||||
}
|
||||
|
||||
|
||||
def _snapshot_to_dict(snapshot: Snapshot | None) -> dict | None:
|
||||
if snapshot is None:
|
||||
return None
|
||||
|
||||
return {
|
||||
"domain": snapshot.domain,
|
||||
"checked_at": snapshot.checked_at.isoformat(),
|
||||
"node_count": snapshot.node_count,
|
||||
"discovery_error": snapshot.discovery_error,
|
||||
"nodes": [
|
||||
{
|
||||
"ip": node.ip,
|
||||
"results": {
|
||||
protocol: _result_to_dict(result)
|
||||
for protocol, result in node.results.items()
|
||||
},
|
||||
}
|
||||
for node in snapshot.nodes
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _history_to_dict(
|
||||
history: dict[str, list[dict[ProtocolName, CheckResult]]],
|
||||
) -> dict[str, list[dict[str, dict]]]:
|
||||
rendered: dict[str, list[dict[str, dict]]] = {}
|
||||
for ip, entries in history.items():
|
||||
rendered[ip] = []
|
||||
for entry in entries:
|
||||
rendered[ip].append(
|
||||
{
|
||||
protocol: _result_to_dict(result)
|
||||
for protocol, result in entry.items()
|
||||
}
|
||||
)
|
||||
return rendered
|
||||
|
||||
|
||||
def create_routes(app: Flask, settings: Settings, store: StatusStore) -> Blueprint:
|
||||
bp = Blueprint("status", __name__)
|
||||
|
||||
@bp.get("/")
|
||||
def index() -> str:
|
||||
return render_template(
|
||||
"index.html",
|
||||
domain=settings.domain,
|
||||
ui_refresh_seconds=settings.ui_refresh_seconds,
|
||||
)
|
||||
|
||||
@bp.get("/api/health")
|
||||
def health() -> tuple[dict, int]:
|
||||
now = datetime.now(timezone.utc)
|
||||
current = store.current()
|
||||
|
||||
if current is None:
|
||||
return {"ok": False, "reason": "No checks completed yet."}, 503
|
||||
|
||||
age_seconds = (now - current.checked_at).total_seconds()
|
||||
stale = age_seconds > settings.stale_after_seconds
|
||||
status_code = 200 if not stale else 503
|
||||
return {
|
||||
"ok": not stale,
|
||||
"stale": stale,
|
||||
"age_seconds": age_seconds,
|
||||
"checked_at": current.checked_at.isoformat(),
|
||||
}, status_code
|
||||
|
||||
@bp.get("/api/status")
|
||||
def status() -> Response:
|
||||
return jsonify(
|
||||
{
|
||||
"generated_at": store.generated_at().isoformat(),
|
||||
"interval_seconds": settings.check_interval_seconds,
|
||||
"stale_after_seconds": settings.stale_after_seconds,
|
||||
"current": _snapshot_to_dict(store.current()),
|
||||
"history": _history_to_dict(store.history()),
|
||||
}
|
||||
)
|
||||
|
||||
app.register_blueprint(bp)
|
||||
return bp
|
||||
34
hnsdoh_status/scheduler.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
|
||||
from hnsdoh_status.checks import run_full_check
|
||||
from hnsdoh_status.config import Settings
|
||||
from hnsdoh_status.store import StatusStore
|
||||
|
||||
|
||||
def create_scheduler(settings: Settings, store: StatusStore) -> BackgroundScheduler:
|
||||
scheduler = BackgroundScheduler(daemon=True)
|
||||
|
||||
def run_checks() -> None:
|
||||
snapshot = run_full_check(
|
||||
domain=settings.domain,
|
||||
doh_path=settings.doh_path,
|
||||
dns_timeout=settings.dns_timeout_seconds,
|
||||
doh_timeout=settings.doh_timeout_seconds,
|
||||
dot_timeout=settings.dot_timeout_seconds,
|
||||
)
|
||||
store.update(snapshot)
|
||||
|
||||
# Run once on startup so the UI/API is populated immediately.
|
||||
run_checks()
|
||||
|
||||
scheduler.add_job(
|
||||
run_checks,
|
||||
"interval",
|
||||
seconds=settings.check_interval_seconds,
|
||||
id="node-health-check",
|
||||
max_instances=1,
|
||||
coalesce=True,
|
||||
)
|
||||
return scheduler
|
||||
75
hnsdoh_status/static/app.js
Normal file
@@ -0,0 +1,75 @@
|
||||
const refreshSeconds = window.HNSDOH_UI_REFRESH_SECONDS || 30;
|
||||
|
||||
function badgeFor(result) {
|
||||
const badgeClass = result.ok ? "badge badge-ok" : "badge badge-bad";
|
||||
const label = result.ok ? "UP" : "DOWN";
|
||||
const latency = result.latency_ms === null ? "" : ` (${result.latency_ms} ms)`;
|
||||
const reason = result.reason || "";
|
||||
|
||||
return `
|
||||
<span class="${badgeClass}">${label}${latency}</span>
|
||||
<span class="hint">${reason}</span>
|
||||
`;
|
||||
}
|
||||
|
||||
function historyDots(history, protocol) {
|
||||
if (!history || history.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const recent = history.slice(-10);
|
||||
const dots = recent
|
||||
.map((entry) => {
|
||||
const r = entry[protocol];
|
||||
if (!r) return '<span class="dot"></span>';
|
||||
return `<span class="dot ${r.ok ? "ok" : "bad"}"></span>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
return `<div class="history">${dots}</div>`;
|
||||
}
|
||||
|
||||
function rowForNode(node, history) {
|
||||
const udp = node.results.dns_udp;
|
||||
const tcp = node.results.dns_tcp;
|
||||
const doh = node.results.doh;
|
||||
const dot = node.results.dot;
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>${node.ip}</td>
|
||||
<td>${badgeFor(udp)}${historyDots(history, "dns_udp")}</td>
|
||||
<td>${badgeFor(tcp)}${historyDots(history, "dns_tcp")}</td>
|
||||
<td>${badgeFor(doh)}${historyDots(history, "doh")}</td>
|
||||
<td>${badgeFor(dot)}${historyDots(history, "dot")}</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
async function refreshStatus() {
|
||||
try {
|
||||
const response = await fetch("/api/status", { cache: "no-store" });
|
||||
const data = await response.json();
|
||||
const tableBody = document.getElementById("status-table-body");
|
||||
const lastUpdated = document.getElementById("last-updated");
|
||||
|
||||
if (!data.current) {
|
||||
tableBody.innerHTML = '<tr><td colspan="5">No data yet. Waiting for first check.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
lastUpdated.textContent = `Last updated: ${data.current.checked_at}`;
|
||||
|
||||
const rows = data.current.nodes
|
||||
.map((node) => rowForNode(node, data.history[node.ip] || []))
|
||||
.join("");
|
||||
|
||||
tableBody.innerHTML = rows || '<tr><td colspan="5">No nodes discovered.</td></tr>';
|
||||
} catch (error) {
|
||||
const tableBody = document.getElementById("status-table-body");
|
||||
tableBody.innerHTML = `<tr><td colspan="5">Failed to load status: ${error}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
refreshStatus();
|
||||
setInterval(refreshStatus, refreshSeconds * 1000);
|
||||
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
189
hnsdoh_status/static/style.css
Normal file
@@ -0,0 +1,189 @@
|
||||
:root {
|
||||
--bg: #f0f7f4;
|
||||
--paper: #ffffff;
|
||||
--ink: #16302b;
|
||||
--muted: #4b635d;
|
||||
--accent: #1f7a8c;
|
||||
--ok: #12824c;
|
||||
--bad: #ba2d0b;
|
||||
--border: #d6e8e1;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #09131a;
|
||||
--paper: #10222c;
|
||||
--ink: #e3f2f6;
|
||||
--muted: #93acb6;
|
||||
--accent: #69bfd6;
|
||||
--ok: #62d387;
|
||||
--bad: #ff8c73;
|
||||
--border: #214250;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(circle at 10% 10%, #cfeadf 0, transparent 32%),
|
||||
radial-gradient(circle at 90% 0%, #b4d6e3 0, transparent 28%),
|
||||
var(--bg);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background:
|
||||
radial-gradient(circle at 10% 10%, #143342 0, transparent 35%),
|
||||
radial-gradient(circle at 90% 0%, #233a46 0, transparent 30%),
|
||||
var(--bg);
|
||||
}
|
||||
}
|
||||
|
||||
.layout {
|
||||
max-width: 1100px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem 2rem;
|
||||
}
|
||||
|
||||
.hero {
|
||||
background: linear-gradient(130deg, #e2f4eb 0%, #e7f0ff 100%);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.hero {
|
||||
background: linear-gradient(135deg, #123342 0%, #182f3c 100%);
|
||||
}
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
margin-top: 0.3rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.panel {
|
||||
margin-top: 1rem;
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
text-align: left;
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
th {
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.3rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.8rem;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.badge-ok {
|
||||
color: var(--ok);
|
||||
background: #e7f7ef;
|
||||
border-color: #b6e3ca;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.badge-ok {
|
||||
background: #173b2a;
|
||||
border-color: #2f6c4f;
|
||||
}
|
||||
}
|
||||
|
||||
.badge-bad {
|
||||
color: var(--bad);
|
||||
background: #fdece7;
|
||||
border-color: #f3b9aa;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.badge-bad {
|
||||
background: #472118;
|
||||
border-color: #855040;
|
||||
}
|
||||
}
|
||||
|
||||
.hint {
|
||||
display: block;
|
||||
margin-top: 0.15rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.history {
|
||||
margin-top: 0.35rem;
|
||||
display: flex;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #adbcb6;
|
||||
}
|
||||
|
||||
.dot.ok {
|
||||
background: var(--ok);
|
||||
}
|
||||
|
||||
.dot.bad {
|
||||
background: var(--bad);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
th,
|
||||
td {
|
||||
border-bottom-color: #1f3f4c;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 880px) {
|
||||
.panel {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
min-width: 760px;
|
||||
}
|
||||
}
|
||||
30
hnsdoh_status/store.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict, deque
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from hnsdoh_status.models import CheckResult, ProtocolName, Snapshot
|
||||
|
||||
|
||||
class StatusStore:
|
||||
def __init__(self, history_size: int):
|
||||
self._history_size = history_size
|
||||
self._current: Snapshot | None = None
|
||||
self._history: dict[str, deque[dict[ProtocolName, CheckResult]]] = defaultdict(
|
||||
lambda: deque(maxlen=self._history_size)
|
||||
)
|
||||
|
||||
def update(self, snapshot: Snapshot) -> None:
|
||||
self._current = snapshot
|
||||
for node in snapshot.nodes:
|
||||
# Each history entry stores one full protocol result set for the node.
|
||||
self._history[node.ip].append(dict(node.results))
|
||||
|
||||
def current(self) -> Snapshot | None:
|
||||
return self._current
|
||||
|
||||
def history(self) -> dict[str, list[dict[ProtocolName, CheckResult]]]:
|
||||
return {ip: list(entries) for ip, entries in self._history.items()}
|
||||
|
||||
def generated_at(self) -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
56
hnsdoh_status/templates/index.html
Normal file
@@ -0,0 +1,56 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>HNSDoH Status</title>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
href="{{ url_for('static', filename='icons/HNS.png') }}"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
media="(prefers-color-scheme: dark)"
|
||||
href="{{ url_for('static', filename='icons/HNSW.png') }}"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
href="{{ url_for('static', filename='icons/HNS.png') }}"
|
||||
/>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="layout">
|
||||
<header class="hero">
|
||||
<h1>HNSDoH Status</h1>
|
||||
<p>Live checks for {{ domain }} nodes discovered from DNS A records.</p>
|
||||
<div class="meta">
|
||||
<span id="last-updated">Last updated: pending</span>
|
||||
<span>Refresh: {{ ui_refresh_seconds }}s</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Node IP</th>
|
||||
<th>DNS UDP :53</th>
|
||||
<th>DNS TCP :53</th>
|
||||
<th>DoH :443</th>
|
||||
<th>DoT :853</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="status-table-body"></tbody>
|
||||
</table>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
window.HNSDOH_UI_REFRESH_SECONDS = {{ ui_refresh_seconds }};
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='app.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
90
main.py
@@ -1,90 +1,8 @@
|
||||
import sys
|
||||
import signal
|
||||
import threading
|
||||
import server
|
||||
from gunicorn.app.base import BaseApplication
|
||||
import os
|
||||
import dotenv
|
||||
from hnsdoh_status import create_app
|
||||
|
||||
|
||||
class GunicornApp(BaseApplication):
|
||||
def __init__(self, app, options=None):
|
||||
self.options = options or {}
|
||||
self.application = app
|
||||
super().__init__()
|
||||
|
||||
def load_config(self):
|
||||
for key, value in self.options.items():
|
||||
if key in self.cfg.settings and value is not None:
|
||||
self.cfg.set(key.lower(), value)
|
||||
|
||||
def load(self):
|
||||
return self.application
|
||||
app = create_app()
|
||||
|
||||
|
||||
def run_gunicorn():
|
||||
workers = os.getenv('WORKERS', 1)
|
||||
threads = os.getenv('THREADS', 2)
|
||||
|
||||
try:
|
||||
workers = int(workers)
|
||||
except (ValueError, TypeError):
|
||||
workers = 1
|
||||
|
||||
try:
|
||||
threads = int(threads)
|
||||
except (ValueError, TypeError):
|
||||
threads = 2
|
||||
|
||||
options = {
|
||||
'bind': '0.0.0.0:5000',
|
||||
'workers': workers,
|
||||
'threads': threads,
|
||||
'timeout': 120,
|
||||
}
|
||||
|
||||
gunicorn_app = GunicornApp(server.app, options)
|
||||
print(f'Starting server with {workers} workers and {threads} threads', flush=True)
|
||||
gunicorn_app.run()
|
||||
|
||||
|
||||
def signal_handler(sig, frame):
|
||||
print("Shutting down gracefully...", flush=True)
|
||||
|
||||
# Shutdown the scheduler
|
||||
if server.scheduler.running:
|
||||
print("Stopping scheduler...", flush=True)
|
||||
server.scheduler.shutdown()
|
||||
|
||||
# Shutdown the node check executors
|
||||
print("Shutting down thread pools...", flush=True)
|
||||
server.node_check_executor.shutdown(wait=False)
|
||||
server.sub_check_executor.shutdown(wait=False)
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
dotenv.load_dotenv()
|
||||
|
||||
# Register signal handlers
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
|
||||
# Start the scheduler from server.py
|
||||
# This ensures we use the robust APScheduler defined there instead of the custom loop
|
||||
print("Starting background scheduler...", flush=True)
|
||||
with server.app.app_context():
|
||||
server.start_scheduler()
|
||||
|
||||
# Run an immediate check in a background thread so we don't block startup
|
||||
startup_thread = threading.Thread(target=server.scheduled_node_check)
|
||||
startup_thread.daemon = True
|
||||
startup_thread.start()
|
||||
|
||||
try:
|
||||
# Run the Gunicorn server
|
||||
run_gunicorn()
|
||||
except KeyboardInterrupt:
|
||||
print("Shutting down server...", flush=True)
|
||||
signal_handler(signal.SIGINT, None)
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=8000, debug=False)
|
||||
|
||||
@@ -20,5 +20,6 @@ dependencies = [
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pre-commit>=4.5.1",
|
||||
"ruff>=0.14.5",
|
||||
]
|
||||
|
||||
310
requirements.txt
@@ -1,11 +1,299 @@
|
||||
flask
|
||||
gunicorn
|
||||
requests
|
||||
dnspython
|
||||
dnslib
|
||||
python-dateutil
|
||||
python-dotenv
|
||||
schedule
|
||||
apscheduler>=3.9.1
|
||||
flask-caching
|
||||
brotli
|
||||
# This file was autogenerated by uv via the following command:
|
||||
# uv export --frozen --output-file=requirements.txt
|
||||
apscheduler==3.11.1 \
|
||||
--hash=sha256:0db77af6400c84d1747fe98a04b8b58f0080c77d11d338c4f507a9752880f221 \
|
||||
--hash=sha256:6162cb5683cb09923654fa9bdd3130c4be4bfda6ad8990971c9597ecd52965d2
|
||||
# via hnsdoh-status
|
||||
blinker==1.9.0 \
|
||||
--hash=sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf \
|
||||
--hash=sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc
|
||||
# via flask
|
||||
brotli==1.2.0 \
|
||||
--hash=sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c \
|
||||
--hash=sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a \
|
||||
--hash=sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6 \
|
||||
--hash=sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac \
|
||||
--hash=sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18 \
|
||||
--hash=sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48 \
|
||||
--hash=sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5 \
|
||||
--hash=sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c \
|
||||
--hash=sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21 \
|
||||
--hash=sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b \
|
||||
--hash=sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d \
|
||||
--hash=sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7 \
|
||||
--hash=sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e \
|
||||
--hash=sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab \
|
||||
--hash=sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8 \
|
||||
--hash=sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f \
|
||||
--hash=sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63 \
|
||||
--hash=sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888 \
|
||||
--hash=sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a \
|
||||
--hash=sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3 \
|
||||
--hash=sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361
|
||||
# via hnsdoh-status
|
||||
cachelib==0.13.0 \
|
||||
--hash=sha256:209d8996e3c57595bee274ff97116d1d73c4980b2fd9a34c7846cd07fd2e1a48 \
|
||||
--hash=sha256:8c8019e53b6302967d4e8329a504acf75e7bc46130291d30188a6e4e58162516
|
||||
# via flask-caching
|
||||
certifi==2025.11.12 \
|
||||
--hash=sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b \
|
||||
--hash=sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316
|
||||
# via requests
|
||||
cfgv==3.5.0 \
|
||||
--hash=sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0 \
|
||||
--hash=sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132
|
||||
# via pre-commit
|
||||
charset-normalizer==3.4.4 \
|
||||
--hash=sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152 \
|
||||
--hash=sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72 \
|
||||
--hash=sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e \
|
||||
--hash=sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c \
|
||||
--hash=sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2 \
|
||||
--hash=sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44 \
|
||||
--hash=sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede \
|
||||
--hash=sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed \
|
||||
--hash=sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133 \
|
||||
--hash=sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e \
|
||||
--hash=sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14 \
|
||||
--hash=sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828 \
|
||||
--hash=sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f \
|
||||
--hash=sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328 \
|
||||
--hash=sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090 \
|
||||
--hash=sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c \
|
||||
--hash=sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb \
|
||||
--hash=sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a \
|
||||
--hash=sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec \
|
||||
--hash=sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc \
|
||||
--hash=sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac \
|
||||
--hash=sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894 \
|
||||
--hash=sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14 \
|
||||
--hash=sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1 \
|
||||
--hash=sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3 \
|
||||
--hash=sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e \
|
||||
--hash=sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6 \
|
||||
--hash=sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191 \
|
||||
--hash=sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd \
|
||||
--hash=sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2 \
|
||||
--hash=sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794 \
|
||||
--hash=sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838 \
|
||||
--hash=sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490 \
|
||||
--hash=sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9
|
||||
# via requests
|
||||
click==8.3.1 \
|
||||
--hash=sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a \
|
||||
--hash=sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6
|
||||
# via flask
|
||||
colorama==0.4.6 ; sys_platform == 'win32' \
|
||||
--hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \
|
||||
--hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6
|
||||
# via click
|
||||
distlib==0.4.0 \
|
||||
--hash=sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16 \
|
||||
--hash=sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d
|
||||
# via virtualenv
|
||||
dnslib==0.9.26 \
|
||||
--hash=sha256:be56857534390b2fbd02935270019bacc5e6b411d156cb3921ac55a7fb51f1a8 \
|
||||
--hash=sha256:e68719e633d761747c7e91bd241019ef5a2b61a63f56025939e144c841a70e0d
|
||||
# via hnsdoh-status
|
||||
dnspython==2.8.0 \
|
||||
--hash=sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af \
|
||||
--hash=sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f
|
||||
# via hnsdoh-status
|
||||
filelock==3.25.2 \
|
||||
--hash=sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694 \
|
||||
--hash=sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70
|
||||
# via
|
||||
# python-discovery
|
||||
# virtualenv
|
||||
flask==3.1.2 \
|
||||
--hash=sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87 \
|
||||
--hash=sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c
|
||||
# via
|
||||
# flask-caching
|
||||
# hnsdoh-status
|
||||
flask-caching==2.3.1 \
|
||||
--hash=sha256:65d7fd1b4eebf810f844de7de6258254b3248296ee429bdcb3f741bcbf7b98c9 \
|
||||
--hash=sha256:d3efcf600e5925ea5a2fcb810f13b341ae984f5b52c00e9d9070392f3ca10761
|
||||
# via hnsdoh-status
|
||||
gunicorn==23.0.0 \
|
||||
--hash=sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d \
|
||||
--hash=sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec
|
||||
# via hnsdoh-status
|
||||
identify==2.6.18 \
|
||||
--hash=sha256:873ac56a5e3fd63e7438a7ecbc4d91aca692eb3fefa4534db2b7913f3fc352fd \
|
||||
--hash=sha256:8db9d3c8ea9079db92cafb0ebf97abdc09d52e97f4dcf773a2e694048b7cd737
|
||||
# via pre-commit
|
||||
idna==3.11 \
|
||||
--hash=sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea \
|
||||
--hash=sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902
|
||||
# via requests
|
||||
itsdangerous==2.2.0 \
|
||||
--hash=sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef \
|
||||
--hash=sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173
|
||||
# via flask
|
||||
jinja2==3.1.6 \
|
||||
--hash=sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d \
|
||||
--hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67
|
||||
# via flask
|
||||
markupsafe==3.0.3 \
|
||||
--hash=sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf \
|
||||
--hash=sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175 \
|
||||
--hash=sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219 \
|
||||
--hash=sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb \
|
||||
--hash=sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6 \
|
||||
--hash=sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab \
|
||||
--hash=sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218 \
|
||||
--hash=sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634 \
|
||||
--hash=sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73 \
|
||||
--hash=sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe \
|
||||
--hash=sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa \
|
||||
--hash=sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37 \
|
||||
--hash=sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97 \
|
||||
--hash=sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19 \
|
||||
--hash=sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9 \
|
||||
--hash=sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9 \
|
||||
--hash=sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc \
|
||||
--hash=sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4 \
|
||||
--hash=sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354 \
|
||||
--hash=sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698 \
|
||||
--hash=sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9 \
|
||||
--hash=sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc \
|
||||
--hash=sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485 \
|
||||
--hash=sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12 \
|
||||
--hash=sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025 \
|
||||
--hash=sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009 \
|
||||
--hash=sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d \
|
||||
--hash=sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5 \
|
||||
--hash=sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f \
|
||||
--hash=sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1 \
|
||||
--hash=sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287 \
|
||||
--hash=sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6 \
|
||||
--hash=sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581 \
|
||||
--hash=sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed \
|
||||
--hash=sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026 \
|
||||
--hash=sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676 \
|
||||
--hash=sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795 \
|
||||
--hash=sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5 \
|
||||
--hash=sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d \
|
||||
--hash=sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe \
|
||||
--hash=sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda \
|
||||
--hash=sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e \
|
||||
--hash=sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737 \
|
||||
--hash=sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523 \
|
||||
--hash=sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50
|
||||
# via
|
||||
# flask
|
||||
# jinja2
|
||||
# werkzeug
|
||||
nodeenv==1.10.0 \
|
||||
--hash=sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827 \
|
||||
--hash=sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb
|
||||
# via pre-commit
|
||||
packaging==25.0 \
|
||||
--hash=sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484 \
|
||||
--hash=sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f
|
||||
# via gunicorn
|
||||
platformdirs==4.9.4 \
|
||||
--hash=sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934 \
|
||||
--hash=sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868
|
||||
# via
|
||||
# python-discovery
|
||||
# virtualenv
|
||||
pre-commit==4.5.1 \
|
||||
--hash=sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77 \
|
||||
--hash=sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61
|
||||
python-dateutil==2.9.0.post0 \
|
||||
--hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \
|
||||
--hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427
|
||||
# via hnsdoh-status
|
||||
python-discovery==1.2.1 \
|
||||
--hash=sha256:180c4d114bff1c32462537eac5d6a332b768242b76b69c0259c7d14b1b680c9e \
|
||||
--hash=sha256:b6a957b24c1cd79252484d3566d1b49527581d46e789aaf43181005e56201502
|
||||
# via virtualenv
|
||||
python-dotenv==1.2.1 \
|
||||
--hash=sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6 \
|
||||
--hash=sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61
|
||||
# via hnsdoh-status
|
||||
pyyaml==6.0.3 \
|
||||
--hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \
|
||||
--hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \
|
||||
--hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \
|
||||
--hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \
|
||||
--hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \
|
||||
--hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \
|
||||
--hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \
|
||||
--hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \
|
||||
--hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \
|
||||
--hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \
|
||||
--hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \
|
||||
--hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \
|
||||
--hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \
|
||||
--hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \
|
||||
--hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \
|
||||
--hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \
|
||||
--hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \
|
||||
--hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \
|
||||
--hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \
|
||||
--hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \
|
||||
--hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \
|
||||
--hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \
|
||||
--hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \
|
||||
--hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \
|
||||
--hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \
|
||||
--hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \
|
||||
--hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \
|
||||
--hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \
|
||||
--hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6
|
||||
# via pre-commit
|
||||
requests==2.32.5 \
|
||||
--hash=sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6 \
|
||||
--hash=sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf
|
||||
# via hnsdoh-status
|
||||
ruff==0.14.5 \
|
||||
--hash=sha256:2d1fa985a42b1f075a098fa1ab9d472b712bdb17ad87a8ec86e45e7fa6273e68 \
|
||||
--hash=sha256:3676cb02b9061fee7294661071c4709fa21419ea9176087cb77e64410926eb78 \
|
||||
--hash=sha256:410e781f1122d6be4f446981dd479470af86537fb0b8857f27a6e872f65a38e4 \
|
||||
--hash=sha256:4b700459d4649e2594b31f20a9de33bc7c19976d4746d8d0798ad959621d64a4 \
|
||||
--hash=sha256:6d146132d1ee115f8802356a2dc9a634dbf58184c51bff21f313e8cd1c74899a \
|
||||
--hash=sha256:7497d19dce23976bdaca24345ae131a1d38dcfe1b0850ad8e9e6e4fa321a6e19 \
|
||||
--hash=sha256:88f0770d42b7fa02bbefddde15d235ca3aa24e2f0137388cc15b2dcbb1f7c7a7 \
|
||||
--hash=sha256:8d3b48d7d8aad423d3137af7ab6c8b1e38e4de104800f0d596990f6ada1a9fc1 \
|
||||
--hash=sha256:9d55d7af7166f143c94eae1db3312f9ea8f95a4defef1979ed516dbb38c27621 \
|
||||
--hash=sha256:b595bedf6bc9cab647c4a173a61acf4f1ac5f2b545203ba82f30fcb10b0318fb \
|
||||
--hash=sha256:c01be527ef4c91a6d55e53b337bfe2c0f82af024cc1a33c44792d6844e2331e1 \
|
||||
--hash=sha256:c135d4b681f7401fe0e7312017e41aba9b3160861105726b76cfa14bc25aa367 \
|
||||
--hash=sha256:c83642e6fccfb6dea8b785eb9f456800dcd6a63f362238af5fc0c83d027dd08b \
|
||||
--hash=sha256:d93be8f1fa01022337f1f8f3bcaa7ffee2d0b03f00922c45c2207954f351f465 \
|
||||
--hash=sha256:e2380596653dcd20b057794d55681571a257a42327da8894b93bbd6111aa801f \
|
||||
--hash=sha256:f3b8248123b586de44a8018bcc9fefe31d23dda57a34e6f0e1e53bd51fd63594 \
|
||||
--hash=sha256:f55382725ad0bdb2e8ee2babcbbfb16f124f5a59496a2f6a46f1d9d99d93e6e2 \
|
||||
--hash=sha256:f66e9bb762e68d66e48550b59c74314168ebb46199886c5c5aa0b0fbcc81b151 \
|
||||
--hash=sha256:f7a75236570318c7a30edd7f5491945f0169de738d945ca8784500b517163a72
|
||||
schedule==1.2.2 \
|
||||
--hash=sha256:15fe9c75fe5fd9b9627f3f19cc0ef1420508f9f9a46f45cd0769ef75ede5f0b7 \
|
||||
--hash=sha256:5bef4a2a0183abf44046ae0d164cadcac21b1db011bdd8102e4a0c1e91e06a7d
|
||||
# via hnsdoh-status
|
||||
six==1.17.0 \
|
||||
--hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \
|
||||
--hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81
|
||||
# via python-dateutil
|
||||
tzdata==2025.2 ; sys_platform == 'win32' \
|
||||
--hash=sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8 \
|
||||
--hash=sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9
|
||||
# via tzlocal
|
||||
tzlocal==5.3.1 \
|
||||
--hash=sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd \
|
||||
--hash=sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d
|
||||
# via apscheduler
|
||||
urllib3==2.5.0 \
|
||||
--hash=sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760 \
|
||||
--hash=sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc
|
||||
# via requests
|
||||
virtualenv==21.2.0 \
|
||||
--hash=sha256:1720dc3a62ef5b443092e3f499228599045d7fea4c79199770499df8becf9098 \
|
||||
--hash=sha256:1bd755b504931164a5a496d217c014d098426cddc79363ad66ac78125f9d908f
|
||||
# via pre-commit
|
||||
werkzeug==3.1.3 \
|
||||
--hash=sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e \
|
||||
--hash=sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746
|
||||
# via flask
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Page Not Found - HNSDoH Status</title>
|
||||
<link rel="stylesheet" href="/assets/style.css">
|
||||
<link rel="icon" type="image/png" href="/favicon.png">
|
||||
<style>
|
||||
.error-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
text-align: center;
|
||||
}
|
||||
.error-code {
|
||||
font-size: 6rem;
|
||||
font-weight: 800;
|
||||
color: var(--bg-card-hover);
|
||||
line-height: 1;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.error-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.error-msg {
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 2rem;
|
||||
max-width: 400px;
|
||||
}
|
||||
.btn {
|
||||
display: inline-block;
|
||||
background-color: var(--accent);
|
||||
color: white;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 600;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.btn:hover {
|
||||
opacity: 0.9;
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="error-container">
|
||||
<div class="error-code">404</div>
|
||||
<div class="error-title">Page Not Found</div>
|
||||
<div class="error-msg">The page you are looking for might have been removed, had its name changed, or is temporarily unavailable.</div>
|
||||
<a href="/" class="btn">Return Home</a>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>Powered by <a href="https://nathan.woodburn.au">Nathan.Woodburn/</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,104 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>API Documentation - HNSDoH Status</title>
|
||||
<link rel="stylesheet" href="/assets/style.css">
|
||||
<link rel="icon" type="image/png" href="/favicon.png">
|
||||
<style>
|
||||
.endpoint-card {
|
||||
background-color: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.endpoint-method {
|
||||
display: inline-block;
|
||||
background-color: rgba(59, 130, 246, 0.15);
|
||||
color: var(--accent);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-weight: 700;
|
||||
font-size: 0.75rem;
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
.endpoint-route {
|
||||
font-family: monospace;
|
||||
font-size: 1.1rem;
|
||||
color: var(--text-main);
|
||||
}
|
||||
.endpoint-desc {
|
||||
margin-top: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.param-table {
|
||||
width: 100%;
|
||||
margin-top: 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.param-table th { background-color: rgba(0,0,0,0.2); }
|
||||
.param-table td, .param-table th { padding: 0.75rem; border-bottom: 1px solid var(--border); }
|
||||
.param-table tr:last-child td { border-bottom: none; }
|
||||
.back-link { display: inline-block; margin-bottom: 1rem; }
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<div>
|
||||
<h1>API Documentation</h1>
|
||||
<div class="meta">Programmatic access to status data</div>
|
||||
</div>
|
||||
<div>
|
||||
<a href="/" class="back-link">← Back to Dashboard</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="endpoints-list">
|
||||
{% for endpoint in endpoints %}
|
||||
<div class="endpoint-card">
|
||||
<div>
|
||||
<span class="endpoint-method">GET</span>
|
||||
<a href="{{ endpoint.route }}" class="endpoint-route">{{ endpoint.route }}</a>
|
||||
</div>
|
||||
<div class="endpoint-desc">{{ endpoint.description }}</div>
|
||||
|
||||
{% if endpoint.parameters %}
|
||||
<div class="param-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Parameter</th>
|
||||
<th>Type</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for param in endpoint.parameters %}
|
||||
<tr>
|
||||
<td><code>{{ param.name }}</code></td>
|
||||
<td><code>{{ param.type }}</code></td>
|
||||
<td>{{ param.description }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>Powered by <a href="https://nathan.woodburn.au">Nathan.Woodburn/</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,19 +0,0 @@
|
||||
body {
|
||||
background-color: #000000;
|
||||
color: #ffffff;
|
||||
}
|
||||
.centre {
|
||||
text-align: center;
|
||||
margin-top: 20%;
|
||||
}
|
||||
a {
|
||||
color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #ff0000;
|
||||
}
|
||||
h1 {
|
||||
font-size: 100px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
.bs-icon {
|
||||
--bs-icon-size: .75rem;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: var(--bs-icon-size);
|
||||
width: calc(var(--bs-icon-size) * 2);
|
||||
height: calc(var(--bs-icon-size) * 2);
|
||||
color: var(--bs-primary);
|
||||
}
|
||||
|
||||
.bs-icon-xs {
|
||||
--bs-icon-size: 1rem;
|
||||
width: calc(var(--bs-icon-size) * 1.5);
|
||||
height: calc(var(--bs-icon-size) * 1.5);
|
||||
}
|
||||
|
||||
.bs-icon-sm {
|
||||
--bs-icon-size: 1rem;
|
||||
}
|
||||
|
||||
.bs-icon-md {
|
||||
--bs-icon-size: 1.5rem;
|
||||
}
|
||||
|
||||
.bs-icon-lg {
|
||||
--bs-icon-size: 2rem;
|
||||
}
|
||||
|
||||
.bs-icon-xl {
|
||||
--bs-icon-size: 2.5rem;
|
||||
}
|
||||
|
||||
.bs-icon.bs-icon-primary {
|
||||
color: var(--bs-white);
|
||||
background: var(--bs-primary);
|
||||
}
|
||||
|
||||
.bs-icon.bs-icon-primary-light {
|
||||
color: var(--bs-primary);
|
||||
background: rgba(var(--bs-primary-rgb), .2);
|
||||
}
|
||||
|
||||
.bs-icon.bs-icon-semi-white {
|
||||
color: var(--bs-primary);
|
||||
background: rgba(255, 255, 255, .5);
|
||||
}
|
||||
|
||||
.bs-icon.bs-icon-rounded {
|
||||
border-radius: .5rem;
|
||||
}
|
||||
|
||||
.bs-icon.bs-icon-circle {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
.fit-cover {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
body {
|
||||
background-color: #000000;
|
||||
color: #ffffff;
|
||||
}
|
||||
.centre {
|
||||
text-align: center;
|
||||
margin-top: 10px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 100px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
p {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style-type: none; /* Remove bullet points */
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
/* Style for the route names */
|
||||
li strong {
|
||||
color: #9ccdff; /* Darker shade for route names */
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Style for descriptions */
|
||||
li em {
|
||||
font-size: 14px;
|
||||
color: #ffa44f; /* Lighter shade for parameter labels */
|
||||
}
|
||||
|
||||
/* Nested parameter list */
|
||||
li ul {
|
||||
margin-left: 20px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
li ul li {
|
||||
font-size: 14px;
|
||||
color: #cfcfcf; /* Parameter details */
|
||||
}
|
||||
|
||||
/* Styling parameter names */
|
||||
li ul li strong {
|
||||
color: #3498db; /* Light blue for parameter names */
|
||||
}
|
||||
|
||||
/* Add a subtle hover effect */
|
||||
li.top:hover {
|
||||
/* Invert colours */
|
||||
filter: invert(1);
|
||||
background-color: #000000;
|
||||
border-left: 4px solid #3498db;
|
||||
padding-left: 10px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
[data-bs-theme=dark] {
|
||||
--bs-primary: #002459;
|
||||
--bs-primary-rgb: 0,36,89;
|
||||
--bs-primary-text-emphasis: #667C9B;
|
||||
--bs-primary-bg-subtle: #000712;
|
||||
--bs-primary-border-subtle: #001635;
|
||||
}
|
||||
|
||||
[data-bs-theme=dark] .btn-primary, .btn-primary[data-bs-theme=dark] {
|
||||
--bs-btn-color: #fff;
|
||||
--bs-btn-bg: #002459;
|
||||
--bs-btn-border-color: #002459;
|
||||
--bs-btn-hover-color: #fff;
|
||||
--bs-btn-hover-bg: #001F4C;
|
||||
--bs-btn-hover-border-color: #001D47;
|
||||
--bs-btn-focus-shadow-rgb: 217,222,230;
|
||||
--bs-btn-active-color: #fff;
|
||||
--bs-btn-active-bg: #001D47;
|
||||
--bs-btn-active-border-color: #001B43;
|
||||
--bs-btn-disabled-color: #fff;
|
||||
--bs-btn-disabled-bg: #002459;
|
||||
--bs-btn-disabled-border-color: #002459;
|
||||
}
|
||||
|
||||
[data-bs-theme=dark] .btn-outline-primary, .btn-outline-primary[data-bs-theme=dark] {
|
||||
--bs-btn-color: #002459;
|
||||
--bs-btn-border-color: #002459;
|
||||
--bs-btn-focus-shadow-rgb: 0,36,89;
|
||||
--bs-btn-hover-color: #fff;
|
||||
--bs-btn-hover-bg: #002459;
|
||||
--bs-btn-hover-border-color: #002459;
|
||||
--bs-btn-active-color: #fff;
|
||||
--bs-btn-active-bg: #002459;
|
||||
--bs-btn-active-border-color: #002459;
|
||||
--bs-btn-disabled-color: #002459;
|
||||
--bs-btn-disabled-bg: transparent;
|
||||
--bs-btn-disabled-border-color: #002459;
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
.node {
|
||||
margin: 25px;
|
||||
border: solid;
|
||||
border-radius: 20px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
margin: 25px;
|
||||
display: block;
|
||||
}
|
||||
.warnings,.errors {
|
||||
margin: auto;
|
||||
width: 1000px;
|
||||
max-width: 95%;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.node-info {
|
||||
margin-top: 5px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.node-info > p{
|
||||
margin: 0;
|
||||
}
|
||||
.node.warning {
|
||||
border-color: #FFA500;
|
||||
}
|
||||
.node.error {
|
||||
border-color: #FF0000;
|
||||
}
|
||||
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 12 KiB |
@@ -1,7 +0,0 @@
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bss-tooltip]'));
|
||||
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
|
||||
return new bootstrap.Tooltip(tooltipTriggerEl);
|
||||
})
|
||||
}, false);
|
||||
@@ -1,133 +0,0 @@
|
||||
:root {
|
||||
--bg-body: #0f172a;
|
||||
--bg-card: #1e293b;
|
||||
--bg-card-hover: #334155;
|
||||
--text-main: #f1f5f9;
|
||||
--text-muted: #94a3b8;
|
||||
--border: #334155;
|
||||
--accent: #3b82f6;
|
||||
--success: #22c55e;
|
||||
--error: #ef4444;
|
||||
--warning: #f59e0b;
|
||||
--shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
background-color: var(--bg-body);
|
||||
color: var(--text-main);
|
||||
line-height: 1.5;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
header {
|
||||
margin-bottom: 2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
h1 { font-size: 1.5rem; font-weight: 700; }
|
||||
h2 { font-size: 1.25rem; margin-bottom: 1rem; color: var(--text-main); }
|
||||
|
||||
.meta { color: var(--text-muted); font-size: 0.875rem; }
|
||||
|
||||
/* Alerts */
|
||||
.alerts-section { margin-bottom: 2rem; }
|
||||
.alert-box {
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.alert-box.error { background-color: rgba(239, 68, 68, 0.1); border: 1px solid var(--error); color: #fca5a5; }
|
||||
.alert-box.warning { background-color: rgba(245, 158, 11, 0.1); border: 1px solid var(--warning); color: #fcd34d; }
|
||||
|
||||
/* Grid */
|
||||
.node-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
box-shadow: var(--shadow);
|
||||
transition: transform 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.node-name { font-weight: 600; font-size: 1.125rem; }
|
||||
.node-location { font-size: 0.875rem; color: var(--text-muted); }
|
||||
.node-ip { font-family: monospace; font-size: 0.75rem; color: var(--text-muted); background: rgba(0,0,0,0.2); padding: 2px 6px; border-radius: 4px; }
|
||||
|
||||
.status-list { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
.status-item { display: flex; justify-content: space-between; align-items: center; font-size: 0.875rem; }
|
||||
|
||||
.badge {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.badge.up { background-color: rgba(34, 197, 94, 0.15); color: var(--success); }
|
||||
.badge.down { background-color: rgba(239, 68, 68, 0.15); color: var(--error); }
|
||||
|
||||
.cert-info { font-size: 0.75rem; color: var(--text-muted); margin-top: 0.25rem; text-align: right; }
|
||||
|
||||
/* History Table */
|
||||
.table-container {
|
||||
background-color: var(--bg-card);
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
|
||||
th, td { padding: 1rem; text-align: left; border-bottom: 1px solid var(--border); }
|
||||
th { background-color: rgba(0,0,0,0.2); font-weight: 600; color: var(--text-muted); }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
tr:hover td { background-color: var(--bg-card-hover); }
|
||||
|
||||
.uptime-bar {
|
||||
height: 6px;
|
||||
background-color: var(--bg-body);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
width: 100px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.uptime-fill { height: 100%; background-color: var(--success); }
|
||||
.uptime-fill.warn { background-color: var(--warning); }
|
||||
.uptime-fill.bad { background-color: var(--error); }
|
||||
|
||||
.footer { margin-top: 4rem; text-align: center; color: var(--text-muted); font-size: 0.875rem; }
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
@@ -1,128 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>HNSDoH Status</title>
|
||||
<link rel="stylesheet" href="/assets/style.css">
|
||||
<link rel="icon" type="image/png" href="/favicon.png">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<div>
|
||||
<h1>HNSDoH Status</h1>
|
||||
<div class="meta">Handshake DNS over HTTPS/TLS</div>
|
||||
</div>
|
||||
<div class="meta">
|
||||
Last checked: {{ last_check }}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{% if alerts or warnings %}
|
||||
<div class="alerts-section">
|
||||
{% for alert in alerts %}
|
||||
<div class="alert-box error">{{ alert }}</div>
|
||||
{% endfor %}
|
||||
{% for warning in warnings %}
|
||||
<div class="alert-box warning">{{ warning }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<h2>Node Status</h2>
|
||||
<div class="node-grid">
|
||||
{% for node in nodes %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<div class="node-name">{{ node.name }}</div>
|
||||
<div class="node-location">{{ node.location }}</div>
|
||||
</div>
|
||||
<div class="node-ip">{{ node.ip }}</div>
|
||||
</div>
|
||||
|
||||
<div class="status-list">
|
||||
<div class="status-item">
|
||||
<span>Plain DNS</span>
|
||||
<span class="badge {{ 'up' if node.plain_dns else 'down' }}">
|
||||
{{ 'UP' if node.plain_dns else 'DOWN' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="status-item">
|
||||
<span>DoH (443)</span>
|
||||
<span class="badge {{ 'up' if node.doh else 'down' }}">
|
||||
{{ 'UP' if node.doh else 'DOWN' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="status-item">
|
||||
<span>DoT (853)</span>
|
||||
<span class="badge {{ 'up' if node.dot else 'down' }}">
|
||||
{{ 'UP' if node.dot else 'DOWN' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="status-item">
|
||||
<span>Certificate</span>
|
||||
<div style="text-align: right;">
|
||||
<span class="badge {{ 'up' if node.cert.valid else 'down' }}">
|
||||
{{ 'VALID' if node.cert.valid else 'INVALID' }}
|
||||
</span>
|
||||
{% if node.cert.valid %}
|
||||
<div class="cert-info">Exp: {{ node.cert.expires }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<h2>30-Day History</h2>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Node</th>
|
||||
<th style="text-align: center;">Plain DNS</th>
|
||||
<th style="text-align: center;">DoH</th>
|
||||
<th style="text-align: center;">DoT</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for node in history.nodes %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="node-name">{{ node.name }}</div>
|
||||
<div class="node-location">{{ node.location }}</div>
|
||||
</td>
|
||||
{% for type in ['plain_dns', 'doh', 'dot'] %}
|
||||
<td style="text-align: center;">
|
||||
<div>{{ node[type].percentage }}%</div>
|
||||
<div class="uptime-bar" style="margin: 4px auto 0;">
|
||||
<div class="uptime-fill {% if node[type].percentage < 90 %}warn{% endif %} {% if node[type].percentage < 70 %}bad{% endif %}"
|
||||
style="width: {{ node[type].percentage }}%"></div>
|
||||
</div>
|
||||
{% if node[type].last_down != 'never' %}
|
||||
<div class="cert-info" style="text-align: center;">Last outage: {{ node[type].last_down }}</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>Powered by <a href="https://nathan.woodburn.au">Nathan.Woodburn/</a></p>
|
||||
<p><a href="/api">API Access</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,146 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>HNSDoH Status</title>
|
||||
<link rel="stylesheet" href="/assets/css/style.css">
|
||||
<meta name="description" content="HNSDoH Status page - Monitoring the status of HNSDoH resolvers">
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<link rel="icon" type="image/png" href="/favicon.png">
|
||||
<style>
|
||||
.loader {
|
||||
border: 5px solid #f3f3f3;
|
||||
border-radius: 50%;
|
||||
border-top: 5px solid #3498db;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin: 20px auto;
|
||||
animation: spin 1.5s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.quick-status {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
margin: 20px;
|
||||
border-radius: 5px;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.status-ok {
|
||||
color: green;
|
||||
}
|
||||
|
||||
.status-issues {
|
||||
color: orange;
|
||||
}
|
||||
|
||||
.status-error {
|
||||
color: red;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>HNSDoH Status</h1>
|
||||
<p>Monitoring the status of HNSDoH resolvers</p>
|
||||
</header>
|
||||
|
||||
<div class="quick-status">
|
||||
<h2>Current Status</h2>
|
||||
<div id="quick-status-display">Loading...</div>
|
||||
<div class="loader" id="status-loader"></div>
|
||||
</div>
|
||||
|
||||
<main>
|
||||
<div id="content">
|
||||
<div class="loader"></div>
|
||||
<p>Loading full status data...</p>
|
||||
<p>This may take a few moments as we check all resolver nodes.</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p>Made by <a href="https://nathan.woodburn.au">Nathan.Woodburn/</a></p>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// Load quick status first
|
||||
fetch('/api/quick-status')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const statusDisplay = document.getElementById('quick-status-display');
|
||||
const statusLoader = document.getElementById('status-loader');
|
||||
|
||||
let statusClass = 'status-ok';
|
||||
let statusMessage = 'All systems operational';
|
||||
|
||||
if (data.status === 'issues') {
|
||||
statusClass = 'status-issues';
|
||||
statusMessage = `${data.nodes_with_issues} out of ${data.total_nodes} nodes have issues`;
|
||||
} else if (data.status === 'error' || data.status === 'unknown') {
|
||||
statusClass = 'status-error';
|
||||
statusMessage = 'Unable to determine system status';
|
||||
}
|
||||
|
||||
statusDisplay.innerHTML = `
|
||||
<h3 class="${statusClass}">${statusMessage}</h3>
|
||||
<p>Last check: ${data.last_check}</p>
|
||||
`;
|
||||
|
||||
statusLoader.style.display = 'none';
|
||||
})
|
||||
.catch(error => {
|
||||
document.getElementById('quick-status-display').innerHTML = `
|
||||
<h3 class="status-error">Error loading status</h3>
|
||||
<p>${error}</p>
|
||||
`;
|
||||
document.getElementById('status-loader').style.display = 'none';
|
||||
});
|
||||
|
||||
// Then load full page data
|
||||
fetch('/api/nodes')
|
||||
.then(response => response.json())
|
||||
.then(nodeData => {
|
||||
// Once we have node data, get history data
|
||||
return Promise.all([
|
||||
Promise.resolve(nodeData),
|
||||
fetch('/api/history').then(res => res.json())
|
||||
]);
|
||||
})
|
||||
.then(([nodeData, historyData]) => {
|
||||
// Now we have both datasets, fetch the HTML with them
|
||||
return fetch('/?' + new URLSearchParams({
|
||||
_data_loaded: 'true' // Signal to the server we already have data
|
||||
}));
|
||||
})
|
||||
.then(response => response.text())
|
||||
.then(html => {
|
||||
document.getElementById('content').innerHTML = html;
|
||||
|
||||
// Replace direct links with JS-enhanced versions
|
||||
document.querySelectorAll('a').forEach(link => {
|
||||
const href = link.getAttribute('href');
|
||||
if (href && href.startsWith('/') && !href.includes('fast_load')) {
|
||||
link.setAttribute('href', href + (href.includes('?') ? '&' : '?') + 'fast_load=true');
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
document.getElementById('content').innerHTML = `
|
||||
<div class="error">
|
||||
<h2>Error Loading Data</h2>
|
||||
<p>There was a problem loading the full status data. Please try refreshing the page.</p>
|
||||
<p>Error details: ${error}</p>
|
||||
<a href="/" class="button">Refresh Page</a>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"name": "HNSDoH Status",
|
||||
"short_name": "HNSDoH Status",
|
||||
"start_url": "https://{{host}}/",
|
||||
"display": "standalone",
|
||||
"background_color": "#212529",
|
||||
"theme_color": "#212529",
|
||||
"orientation": "portrait-primary",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/favicon.png",
|
||||
"sizes": "500x500"
|
||||
}
|
||||
]
|
||||
}
|
||||
140
uv.lock
generated
@@ -69,6 +69,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfgv"
|
||||
version = "3.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "3.4.4"
|
||||
@@ -131,6 +140,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "distlib"
|
||||
version = "0.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dnslib"
|
||||
version = "0.9.26"
|
||||
@@ -149,6 +167,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filelock"
|
||||
version = "3.25.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/94/b8/00651a0f559862f3bb7d6f7477b192afe3f583cc5e26403b44e59a55ab34/filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694", size = 40480, upload-time = "2026-03-11T20:45:38.487Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759, upload-time = "2026-03-11T20:45:37.437Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flask"
|
||||
version = "3.1.2"
|
||||
@@ -211,6 +238,7 @@ dependencies = [
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "pre-commit" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
@@ -230,7 +258,19 @@ requires-dist = [
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [{ name = "ruff", specifier = ">=0.14.5" }]
|
||||
dev = [
|
||||
{ name = "pre-commit", specifier = ">=4.5.1" },
|
||||
{ name = "ruff", specifier = ">=0.14.5" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "identify"
|
||||
version = "2.6.18"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/46/c4/7fb4db12296cdb11893d61c92048fe617ee853f8523b9b296ac03b43757e/identify-2.6.18.tar.gz", hash = "sha256:873ac56a5e3fd63e7438a7ecbc4d91aca692eb3fefa4534db2b7913f3fc352fd", size = 99580, upload-time = "2026-03-15T18:39:50.319Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/46/33/92ef41c6fad0233e41d3d84ba8e8ad18d1780f1e5d99b3c683e6d7f98b63/identify-2.6.18-py2.py3-none-any.whl", hash = "sha256:8db9d3c8ea9079db92cafb0ebf97abdc09d52e97f4dcf773a2e694048b7cd737", size = 99394, upload-time = "2026-03-15T18:39:48.915Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
@@ -314,6 +354,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nodeenv"
|
||||
version = "1.10.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "25.0"
|
||||
@@ -323,6 +372,31 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "platformdirs"
|
||||
version = "4.9.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pre-commit"
|
||||
version = "4.5.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cfgv" },
|
||||
{ name = "identify" },
|
||||
{ name = "nodeenv" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "virtualenv" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dateutil"
|
||||
version = "2.9.0.post0"
|
||||
@@ -335,6 +409,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-discovery"
|
||||
version = "1.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "filelock" },
|
||||
{ name = "platformdirs" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b9/88/815e53084c5079a59df912825a279f41dd2e0df82281770eadc732f5352c/python_discovery-1.2.1.tar.gz", hash = "sha256:180c4d114bff1c32462537eac5d6a332b768242b76b69c0259c7d14b1b680c9e", size = 58457, upload-time = "2026-03-26T22:30:44.496Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/67/0f/019d3949a40280f6193b62bc010177d4ce702d0fce424322286488569cd3/python_discovery-1.2.1-py3-none-any.whl", hash = "sha256:b6a957b24c1cd79252484d3566d1b49527581d46e789aaf43181005e56201502", size = 31674, upload-time = "2026-03-26T22:30:43.396Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.2.1"
|
||||
@@ -344,6 +431,42 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.32.5"
|
||||
@@ -433,6 +556,21 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "virtualenv"
|
||||
version = "21.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "distlib" },
|
||||
{ name = "filelock" },
|
||||
{ name = "platformdirs" },
|
||||
{ name = "python-discovery" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/aa/92/58199fe10049f9703c2666e809c4f686c54ef0a68b0f6afccf518c0b1eb9/virtualenv-21.2.0.tar.gz", hash = "sha256:1720dc3a62ef5b443092e3f499228599045d7fea4c79199770499df8becf9098", size = 5840618, upload-time = "2026-03-09T17:24:38.013Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/59/7d02447a55b2e55755011a647479041bc92a82e143f96a8195cb33bd0a1c/virtualenv-21.2.0-py3-none-any.whl", hash = "sha256:1bd755b504931164a5a496d217c014d098426cddc79363ad66ac78125f9d908f", size = 5825084, upload-time = "2026-03-09T17:24:35.378Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "werkzeug"
|
||||
version = "3.1.3"
|
||||
|
||||