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
|
# 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
|
from hnsdoh_status import create_app
|
||||||
import signal
|
|
||||||
import threading
|
|
||||||
import server
|
|
||||||
from gunicorn.app.base import BaseApplication
|
|
||||||
import os
|
|
||||||
import dotenv
|
|
||||||
|
|
||||||
|
|
||||||
class GunicornApp(BaseApplication):
|
app = create_app()
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def run_gunicorn():
|
if __name__ == "__main__":
|
||||||
workers = os.getenv('WORKERS', 1)
|
app.run(host="0.0.0.0", port=8000, debug=False)
|
||||||
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)
|
|
||||||
|
|||||||
@@ -20,5 +20,6 @@ dependencies = [
|
|||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
|
"pre-commit>=4.5.1",
|
||||||
"ruff>=0.14.5",
|
"ruff>=0.14.5",
|
||||||
]
|
]
|
||||||
|
|||||||
310
requirements.txt
@@ -1,11 +1,299 @@
|
|||||||
flask
|
# This file was autogenerated by uv via the following command:
|
||||||
gunicorn
|
# uv export --frozen --output-file=requirements.txt
|
||||||
requests
|
apscheduler==3.11.1 \
|
||||||
dnspython
|
--hash=sha256:0db77af6400c84d1747fe98a04b8b58f0080c77d11d338c4f507a9752880f221 \
|
||||||
dnslib
|
--hash=sha256:6162cb5683cb09923654fa9bdd3130c4be4bfda6ad8990971c9597ecd52965d2
|
||||||
python-dateutil
|
# via hnsdoh-status
|
||||||
python-dotenv
|
blinker==1.9.0 \
|
||||||
schedule
|
--hash=sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf \
|
||||||
apscheduler>=3.9.1
|
--hash=sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc
|
||||||
flask-caching
|
# via flask
|
||||||
brotli
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "charset-normalizer"
|
name = "charset-normalizer"
|
||||||
version = "3.4.4"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "dnslib"
|
name = "dnslib"
|
||||||
version = "0.9.26"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "flask"
|
name = "flask"
|
||||||
version = "3.1.2"
|
version = "3.1.2"
|
||||||
@@ -211,6 +238,7 @@ dependencies = [
|
|||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
|
{ name = "pre-commit" },
|
||||||
{ name = "ruff" },
|
{ name = "ruff" },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -230,7 +258,19 @@ requires-dist = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[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]]
|
[[package]]
|
||||||
name = "idna"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "packaging"
|
name = "packaging"
|
||||||
version = "25.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "python-dateutil"
|
name = "python-dateutil"
|
||||||
version = "2.9.0.post0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "python-dotenv"
|
name = "python-dotenv"
|
||||||
version = "1.2.1"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "requests"
|
name = "requests"
|
||||||
version = "2.32.5"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "werkzeug"
|
name = "werkzeug"
|
||||||
version = "3.1.3"
|
version = "3.1.3"
|
||||||
|
|||||||