This commit is contained in:
yuuki 2024-05-31 03:32:14 +00:00
commit 31b68eb474
20 changed files with 336 additions and 0 deletions

6
Dockerfile Normal file
View file

@ -0,0 +1,6 @@
FROM python:3
COPY . /app
WORKDIR /app
RUN pip install -r requirements.txt
ENV PYTHONUNBUFFERED 1
CMD ["fastapi", "run", "--workers", "5"]

27
analytics_daily.txt Normal file
View file

@ -0,0 +1,27 @@
{
viewer {
zones(filter: {
zoneTag: $zoneTag
}) {
httpRequests1dGroups(
orderBy: [date_ASC],
limit: $limit,
filter: {
date_gt: $from,
date_leq: $to
}
) {
dimensions {
date
}
sum {
bytes
cachedBytes
}
uniq {
uniques
}
}
}
}
}

73
app.py Normal file
View file

@ -0,0 +1,73 @@
from typing import List, Union
from pathlib import Path
from datetime import datetime, timedelta, timezone
from urllib.parse import urlparse
from json import dumps
from requests import post
from fastapi import FastAPI, Response, Header, status
from fastapi.responses import JSONResponse, PlainTextResponse, FileResponse
def fastapi_serve(dir: str, ref: str, indexes: List[str] = ["index.html", "index.htm"]) -> Response:
url_path = urlparse(ref or "/").path
root = Path(dir)
try_files = []
if url_path.endswith("/"):
try_files += [root / url_path.lstrip("/") / i for i in indexes]
try_files += [root / url_path]
try_files_tried = [t for t in try_files if t.is_file()]
print(try_files, try_files_tried)
if not try_files_tried:
return PlainTextResponse("指定されたファイルが見つかりません", status.HTTP_404_NOT_FOUND)
path = try_files_tried[0]
print(path, "をサーブ中")
return FileResponse(path)
app = FastAPI()
@app.get("/cloudflare")
async def cloudflare(zone_id: str, x_token: Union[str, None] = Header()):
query = Path("analytics_daily.txt").read_text("UTF-8")
now = datetime.now()
before = now - timedelta(**{ "days": 30 })
variables = {
"zoneTag": zone_id,
"from": before.astimezone(timezone.utc).strftime("%Y-%m-%d"),
"to": now.astimezone(timezone.utc).strftime("%Y-%m-%d"),
"limit": 30
}
result = post(
url="https://api.cloudflare.com/client/v4/graphql",
headers={
"Authorization": f"Bearer {x_token}"
},
data=dumps({
"query": query,
"variables": variables
})
)
res = JSONResponse(result.json())
res.headers["Cache-Control"] = f"public, max-age=0, s-maxage=0"
res.headers["CDN-Cache-Control"] = f"max-age=0"
return res
@app.get("/")
@app.get("/{ref:path}")
async def home(ref: str = None):
res = fastapi_serve("static", ref)
res.headers["Cache-Control"] = "public, max-age=3600, s-maxage=3600"
res.headers["CDN-Cache-Control"] = "max-age=3600"
return res

2
requirements.txt Normal file
View file

@ -0,0 +1,2 @@
requests==2.32.2
fastapi==0.111.0

20
static/chart-js-4-4-3.js Normal file

File diff suppressed because one or more lines are too long

17
static/index.html Normal file
View file

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>クラウドフレアの統計情報</title>
<link rel="stylesheet" href="style.css">
<script src="chart-js-4-4-3.js"></script>
<script src="script.js"></script>
</head>
<body>
<div id="loading">読み込み中…</div>
<canvas id="users" width="10" height="10"></canvas>
<canvas id="bytes" width="10" height="10"></canvas>
<canvas id="bytes2" width="10" height="10"></canvas>
</body>
</html>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

168
static/script.js Normal file
View file

@ -0,0 +1,168 @@
const token = "FFCH7nhjGM6ey6VF8wtPkFJQ_Vn65DvozpIStEER";
const websites = [
{
zoneId: "3ace6ae0587033b37c79e168cf60c234",
domain: "yuuk1.tk",
color: [255, 182, 193],
},
{
zoneId: "068e85c0bc67ef053660c7d2ceca7b89",
domain: "yuuk1.uk",
color: [173, 216, 230],
},
{
zoneId: "176677a44c89b3aa8ab0a33f2d7108c3",
domain: "taikoapp.uk",
color: [152, 255, 152],
},
{
zoneId: "9d4b398a23e094448b287b84947f58ff",
domain: "forgejo.win",
color: [255, 160, 122],
},
{
zoneId: "09990364a38b739e3de9338f908d584f",
domain: "litey.trade",
color: [255, 215, 0],
},
];
async function worker(item) {
const res = await fetch(`/cloudflare?zone_id=${encodeURIComponent(item.zoneId)}`, {
headers: {
"X-Token": token,
},
});
const json = await res.json();
return {
domain: item.domain,
fg: `rgb(${item.color[0]}, ${item.color[1]}, ${item.color[2]})`,
bg: `rgba(${item.color[0]}, ${item.color[1]}, ${item.color[2]}, 0.2)`,
json,
};
}
addEventListener("load", () => {
Promise.all(websites.map((i) => worker(i)))
.then((results) => {
document.querySelector("#loading").remove();
const scale = Math.max(...results.map((r) => r.json["data"]["viewer"]["zones"][0]["httpRequests1dGroups"].length));
const endedAt = Math.max(...results.map((r) => r.json["data"]["viewer"]["zones"][0]["httpRequests1dGroups"].map((g) => new Date(g["dimensions"]["date"]).getTime())).flat());
const range = Array.from({ length: scale }, (_, k) => new Date(endedAt - k * 24 * 60 * 60 * 1000));
const ctxUsers = document.querySelector("#users").getContext("2d");
new Chart(ctxUsers, {
data: {
labels: range.map((d) => d.toISOString().slice(0, 10)).reverse(),
datasets: results.map((r) => {
return {
type: "line",
label: r.domain,
data: range.map((d) => r.json["data"]["viewer"]["zones"][0]["httpRequests1dGroups"].find((g) => g["dimensions"]["date"] === d.toISOString().slice(0, 10))?.["uniq"]?.["uniques"] ?? null).reverse(),
borderColor: r.fg,
borderWidth: 1,
fill: "origin",
backgroundColor: r.bg,
pointStyle: "star",
};
}),
},
options: {
plugins: {
title: {
display: true,
text: "過去30日のユーザー数の推移",
},
legend: {
labels: {
usePointStyle: true,
},
},
},
scales: {
y: {
beginAtZero: true,
},
},
},
});
const ctxBytes = document.querySelector("#bytes").getContext("2d");
new Chart(ctxBytes, {
data: {
labels: range.map((d) => d.toISOString().slice(0, 10)).reverse(),
datasets: results.map((r) => {
return {
type: "line",
label: r.domain,
data: range.map((d) => r.json["data"]["viewer"]["zones"][0]["httpRequests1dGroups"].find((g) => g["dimensions"]["date"] === d.toISOString().slice(0, 10))?.["sum"]?.["bytes"] / 1000 ** 3 ?? null).reverse(),
borderColor: r.fg,
borderWidth: 1,
fill: "origin",
backgroundColor: r.bg,
pointStyle: "star",
};
}),
},
options: {
plugins: {
title: {
display: true,
text: "過去30日の送受信データ量(GB)の推移",
},
legend: {
labels: {
usePointStyle: true,
},
},
},
scales: {
y: {
beginAtZero: true,
},
},
},
});
const ctxBytes2 = document.querySelector("#bytes2").getContext("2d");
new Chart(ctxBytes2, {
data: {
labels: range.map((d) => d.toISOString().slice(0, 10)).reverse(),
datasets: results.map((r) => {
return {
type: "line",
label: r.domain,
data: range.map((d) => r.json["data"]["viewer"]["zones"][0]["httpRequests1dGroups"].find((g) => g["dimensions"]["date"] === d.toISOString().slice(0, 10))?.["sum"]?.["cachedBytes"] / 1000 ** 3 ?? null).reverse(),
borderColor: r.fg,
borderWidth: 1,
fill: "origin",
backgroundColor: r.bg,
pointStyle: "star",
};
}),
},
options: {
plugins: {
title: {
display: true,
text: "過去30日のキャッシュ済み送受信データ量(GB)の推移",
},
legend: {
labels: {
usePointStyle: true,
},
},
},
scales: {
y: {
beginAtZero: true,
},
},
},
});
});
});

23
static/style.css Normal file
View file

@ -0,0 +1,23 @@
@font-face {
font-family: "Roboto";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(roboto/Roboto-Regular.ttf) format("truetype");
}
body {
margin: 0;
padding: 2rem;
}
body * {
margin: 0;
font-family: "Roboto", sans-serif;
word-break: break-all;
white-space: pre-line;
}
body > :not(:last-child) {
margin-bottom: 2rem;
}