后台管理系统后端 API:在线配置编辑、手动订阅管理、日志查看(logs 目录避开 gitignore)
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
const BACKEND = API_BASE ? `${API_BASE}/api/ops/config` : "";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const res = await fetch(BACKEND, { headers: auth.headers, cache: "no-store" });
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (e) { return buildProxyExceptionResponse(e, { publicMessage: "Config fetch failed" }); }
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest) {
|
||||
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const body = await req.text();
|
||||
const res = await fetch(BACKEND, { method: "PUT", headers: { ...auth.headers, "Content-Type": "application/json" }, body, cache: "no-store" });
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (e) { return buildProxyExceptionResponse(e, { publicMessage: "Config update failed" }); }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const body = await req.text();
|
||||
const res = await fetch(`${API_BASE}/api/ops/subscriptions/extend`, {
|
||||
method: "POST", headers: { ...auth.headers, "Content-Type": "application/json" }, body, cache: "no-store",
|
||||
});
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (e) { return buildProxyExceptionResponse(e, { publicMessage: "Subscription extend failed" }); }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const body = await req.text();
|
||||
const res = await fetch(`${API_BASE}/api/ops/subscriptions/grant`, {
|
||||
method: "POST", headers: { ...auth.headers, "Content-Type": "application/json" }, body, cache: "no-store",
|
||||
});
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (e) { return buildProxyExceptionResponse(e, { publicMessage: "Subscription grant failed" }); }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const url = new URL(`${API_BASE}/api/ops/logs`);
|
||||
const level = req.nextUrl.searchParams.get("level");
|
||||
const lines = req.nextUrl.searchParams.get("lines");
|
||||
if (level) url.searchParams.set("level", level);
|
||||
if (lines) url.searchParams.set("lines", lines);
|
||||
const res = await fetch(url.toString(), { headers: auth.headers, cache: "no-store" });
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (e) { return buildProxyExceptionResponse(e, { publicMessage: "Log fetch failed" }); }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
import { requireOpsAdmin } from "@/lib/ops-admin";
|
||||
import { LogsPageClient } from "@/components/ops/view-logs/LogsPageClient";
|
||||
|
||||
export const metadata: Metadata = { title: "日志查看 — PolyWeather Ops" };
|
||||
|
||||
export default async function LogsPage() {
|
||||
await requireOpsAdmin("/ops/view-logs");
|
||||
return <LogsPageClient />;
|
||||
}
|
||||
@@ -38,7 +38,7 @@ const navGroups = [
|
||||
items: [
|
||||
{ href: "/ops/config", icon: Settings, label: "系统配置" },
|
||||
{ href: "/ops/subscriptions", icon: ScrollText, label: "订阅操作" },
|
||||
{ href: "/ops/logs", icon: FileText, label: "日志查看" },
|
||||
{ href: "/ops/view-logs", icon: FileText, label: "日志查看" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { RefreshCcw, Pause, Play } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function LogsPageClient() {
|
||||
const [lines, setLines] = useState<string[]>([]);
|
||||
const [level, setLevel] = useState("");
|
||||
const [limit, setLimit] = useState(100);
|
||||
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const params = new URLSearchParams({ lines: String(limit) });
|
||||
if (level) params.set("level", level);
|
||||
const res = await fetch(`/api/ops/view-logs?${params}`);
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as { lines?: string[] };
|
||||
setLines(data.lines ?? []);
|
||||
setError("");
|
||||
} else {
|
||||
setError(await res.text().catch(() => "fetch failed"));
|
||||
}
|
||||
} catch {
|
||||
setError("日志 API 尚未就绪(需要后端支持)");
|
||||
}
|
||||
}, [level, limit]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoRefresh) {
|
||||
timerRef.current = setInterval(load, 5000);
|
||||
} else {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
}
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
};
|
||||
}, [autoRefresh, load]);
|
||||
|
||||
const levelColor = (line: string) => {
|
||||
if (line.includes("ERROR")) return "text-red-400";
|
||||
if (line.includes("WARNING")) return "text-amber-400";
|
||||
if (line.includes("INFO")) return "text-slate-300";
|
||||
return "text-slate-500";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<h1 className="text-2xl font-bold text-white">日志查看</h1>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<select
|
||||
value={level}
|
||||
onChange={(e) => setLevel(e.target.value)}
|
||||
className="rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white outline-none"
|
||||
>
|
||||
<option value="">全部级别</option>
|
||||
<option value="info">INFO</option>
|
||||
<option value="warning">WARNING</option>
|
||||
<option value="error">ERROR</option>
|
||||
</select>
|
||||
<select
|
||||
value={limit}
|
||||
onChange={(e) => setLimit(Number(e.target.value))}
|
||||
className="rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white outline-none"
|
||||
>
|
||||
<option value="50">50 行</option>
|
||||
<option value="100">100 行</option>
|
||||
<option value="200">200 行</option>
|
||||
<option value="500">500 行</option>
|
||||
</select>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{autoRefresh ? <Pause className="h-3.5 w-3.5" /> : <Play className="h-3.5 w-3.5" />}
|
||||
{autoRefresh ? "暂停" : "自动刷新"}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={load} className="gap-1.5">
|
||||
<RefreshCcw className="h-3.5 w-3.5" /> 刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{error ? (
|
||||
<div className="p-6 text-amber-400 text-sm">{error}</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto max-h-[70vh] overflow-y-auto font-mono text-xs leading-relaxed">
|
||||
<div className="min-w-[800px]">
|
||||
{lines.map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`px-4 py-0.5 border-b border-white/[0.02] hover:bg-white/[0.03] ${levelColor(line)}`}
|
||||
>
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
{lines.length === 0 && (
|
||||
<div className="p-6 text-center text-slate-500">暂无日志</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,14 +4,19 @@ from fastapi import APIRouter, Request
|
||||
|
||||
from web.core import GrantPointsRequest
|
||||
from web.services.ops_api import (
|
||||
extend_ops_subscription,
|
||||
get_ops_analytics_funnel,
|
||||
get_ops_config,
|
||||
get_ops_logs,
|
||||
get_ops_truth_history,
|
||||
get_ops_weekly_leaderboard,
|
||||
grant_ops_points,
|
||||
grant_ops_subscription,
|
||||
list_ops_memberships,
|
||||
list_ops_payment_incidents,
|
||||
resolve_ops_payment_incident,
|
||||
search_ops_users,
|
||||
update_ops_config,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["ops"])
|
||||
@@ -77,3 +82,57 @@ async def ops_truth_history(
|
||||
date_to=date_to,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
# ── Config ──────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/api/ops/config")
|
||||
async def ops_config(request: Request):
|
||||
return get_ops_config(request)
|
||||
|
||||
|
||||
@router.put("/api/ops/config")
|
||||
async def ops_update_config(request: Request):
|
||||
import json as _json
|
||||
body_bytes = await request.body()
|
||||
body = _json.loads(body_bytes.decode("utf-8"))
|
||||
key = str(body.get("key") or "").strip()
|
||||
value = str(body.get("value") or "")
|
||||
if not key:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=400, detail="key is required")
|
||||
return update_ops_config(request, key, value)
|
||||
|
||||
|
||||
# ── Subscriptions ───────────────────────────────────────────────────
|
||||
|
||||
@router.post("/api/ops/subscriptions/grant")
|
||||
async def ops_subscription_grant(request: Request):
|
||||
import json as _json
|
||||
body_bytes = await request.body()
|
||||
body = _json.loads(body_bytes.decode("utf-8"))
|
||||
email = str(body.get("email") or "").strip()
|
||||
plan_code = str(body.get("plan_code") or "pro_monthly").strip()
|
||||
days = int(body.get("days") or 30)
|
||||
return grant_ops_subscription(request, email=email, plan_code=plan_code, days=days)
|
||||
|
||||
|
||||
@router.post("/api/ops/subscriptions/extend")
|
||||
async def ops_subscription_extend(request: Request):
|
||||
import json as _json
|
||||
body_bytes = await request.body()
|
||||
body = _json.loads(body_bytes.decode("utf-8"))
|
||||
email = str(body.get("email") or "").strip()
|
||||
days = int(body.get("additional_days") or 30)
|
||||
return extend_ops_subscription(request, email=email, additional_days=days)
|
||||
|
||||
|
||||
# ── Logs ────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/api/ops/logs")
|
||||
async def ops_logs(
|
||||
request: Request,
|
||||
level: str = "",
|
||||
lines: int = 100,
|
||||
):
|
||||
return get_ops_logs(request, level=level, lines=lines)
|
||||
|
||||
@@ -226,3 +226,206 @@ def get_ops_truth_history(
|
||||
},
|
||||
"filtered_count": filtered_count,
|
||||
}
|
||||
|
||||
|
||||
# ── Config ──────────────────────────────────────────────────────────
|
||||
|
||||
_EDITABLE_CONFIG_KEYS: dict[str, str] = {
|
||||
"POLYWEATHER_AUTH_REQUIRED": "是否强制要求 Supabase 登录访问 API",
|
||||
"POLYWEATHER_PAYMENT_ENABLED": "是否启用支付功能",
|
||||
"POLYWEATHER_PAYMENT_POINTS_ENABLED": "是否启用积分抵扣",
|
||||
"POLYWEATHER_TELEGRAM_ALERT_PUSH_ENABLED": "是否启用 Telegram 告警推送",
|
||||
"POLYWEATHER_GROUP_MEMBER_PRICE_USDC": "群成员月费 (USDC)",
|
||||
"POLYWEATHER_PUBLIC_PRICE_USDC": "公开月费 (USDC)",
|
||||
"POLYWEATHER_PAYMENT_POINTS_PER_USDC": "积分兑换汇率 (积分/USDC)",
|
||||
"POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC": "积分最高抵扣金额 (USDC)",
|
||||
}
|
||||
|
||||
|
||||
def get_ops_config(request: Request) -> dict[str, Any]:
|
||||
_require_ops(request)
|
||||
import os
|
||||
configs: list[dict[str, str]] = []
|
||||
for key, desc in _EDITABLE_CONFIG_KEYS.items():
|
||||
configs.append({
|
||||
"key": key,
|
||||
"value": os.getenv(key) or "",
|
||||
"description": desc,
|
||||
})
|
||||
return {"configs": configs}
|
||||
|
||||
|
||||
def update_ops_config(request: Request, key: str, value: str) -> dict[str, Any]:
|
||||
_require_ops(request)
|
||||
import os
|
||||
if key not in _EDITABLE_CONFIG_KEYS:
|
||||
raise HTTPException(status_code=400, detail=f"config key '{key}' is not editable")
|
||||
os.environ[key] = str(value)
|
||||
return {"key": key, "value": value, "ok": True}
|
||||
|
||||
|
||||
# ── Subscriptions ───────────────────────────────────────────────────
|
||||
|
||||
def grant_ops_subscription(
|
||||
request: Request,
|
||||
email: str,
|
||||
plan_code: str = "pro_monthly",
|
||||
days: int = 30,
|
||||
) -> dict[str, Any]:
|
||||
_require_ops(request)
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
import requests as _requests
|
||||
|
||||
supabase_url = str(os.getenv("SUPABASE_URL") or "").strip().rstrip("/")
|
||||
service_role_key = str(os.getenv("SUPABASE_SERVICE_ROLE_KEY") or "").strip()
|
||||
if not supabase_url or not service_role_key:
|
||||
raise HTTPException(status_code=503, detail="Supabase not configured")
|
||||
|
||||
allowed_plans = {"pro_monthly", "pro_quarterly", "pro_yearly"}
|
||||
if plan_code not in allowed_plans:
|
||||
raise HTTPException(status_code=400, detail=f"invalid plan_code, allowed: {allowed_plans}")
|
||||
|
||||
safe_days = max(1, min(365, int(days or 30)))
|
||||
normalized_email = str(email or "").strip().lower()
|
||||
if not normalized_email:
|
||||
raise HTTPException(status_code=400, detail="email is required")
|
||||
|
||||
headers = {
|
||||
"apikey": service_role_key,
|
||||
"Authorization": f"Bearer {service_role_key}",
|
||||
"Content-Type": "application/json",
|
||||
"Prefer": "return=representation",
|
||||
}
|
||||
|
||||
# Look up user by email
|
||||
user_resp = _requests.get(
|
||||
f"{supabase_url}/auth/v1/admin/users",
|
||||
headers=headers,
|
||||
params={"filter": f"email.eq.{normalized_email}"},
|
||||
timeout=10,
|
||||
)
|
||||
users = user_resp.json().get("users", []) if user_resp.ok else []
|
||||
user_id = users[0].get("id") if users else None
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=404, detail=f"user not found: {normalized_email}")
|
||||
|
||||
now = datetime.utcnow()
|
||||
starts_at = now.isoformat() + "Z"
|
||||
expires_at = (now + timedelta(days=safe_days)).isoformat() + "Z"
|
||||
|
||||
payload = {
|
||||
"user_id": user_id,
|
||||
"email": normalized_email,
|
||||
"plan_code": plan_code,
|
||||
"starts_at": starts_at,
|
||||
"expires_at": expires_at,
|
||||
"source": "ops_manual_grant",
|
||||
"created_at": now.isoformat() + "Z",
|
||||
}
|
||||
|
||||
resp = _requests.post(
|
||||
f"{supabase_url}/rest/v1/subscriptions",
|
||||
headers=headers,
|
||||
json=payload,
|
||||
timeout=10,
|
||||
)
|
||||
if resp.ok:
|
||||
return {"ok": True, "user_id": user_id, "plan_code": plan_code, "days": safe_days, "expires_at": expires_at}
|
||||
raise HTTPException(status_code=500, detail=f"Supabase insert failed: {resp.text[:200]}")
|
||||
|
||||
|
||||
def extend_ops_subscription(
|
||||
request: Request,
|
||||
email: str,
|
||||
additional_days: int = 30,
|
||||
) -> dict[str, Any]:
|
||||
_require_ops(request)
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
import requests as _requests
|
||||
|
||||
supabase_url = str(os.getenv("SUPABASE_URL") or "").strip().rstrip("/")
|
||||
service_role_key = str(os.getenv("SUPABASE_SERVICE_ROLE_KEY") or "").strip()
|
||||
if not supabase_url or not service_role_key:
|
||||
raise HTTPException(status_code=503, detail="Supabase not configured")
|
||||
|
||||
safe_days = max(1, min(365, int(additional_days or 30)))
|
||||
normalized_email = str(email or "").strip().lower()
|
||||
if not normalized_email:
|
||||
raise HTTPException(status_code=400, detail="email is required")
|
||||
|
||||
headers = {
|
||||
"apikey": service_role_key,
|
||||
"Authorization": f"Bearer {service_role_key}",
|
||||
"Content-Type": "application/json",
|
||||
"Prefer": "return=representation",
|
||||
}
|
||||
|
||||
# Find latest active subscription
|
||||
subs_resp = _requests.get(
|
||||
f"{supabase_url}/rest/v1/subscriptions",
|
||||
headers=headers,
|
||||
params={
|
||||
"select": "*",
|
||||
"email": f"eq.{normalized_email}",
|
||||
"order": "expires_at.desc",
|
||||
"limit": "1",
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
subs = subs_resp.json() if subs_resp.ok else []
|
||||
if not subs:
|
||||
raise HTTPException(status_code=404, detail=f"no subscription found for {normalized_email}")
|
||||
|
||||
sub = subs[0]
|
||||
current_expiry = sub.get("expires_at", "")
|
||||
try:
|
||||
dt = datetime.fromisoformat(current_expiry.replace("Z", "+00:00"))
|
||||
new_expiry = (dt + timedelta(days=safe_days)).isoformat()
|
||||
except Exception:
|
||||
new_expiry = (datetime.utcnow() + timedelta(days=safe_days)).isoformat() + "Z"
|
||||
|
||||
patch_resp = _requests.patch(
|
||||
f"{supabase_url}/rest/v1/subscriptions?id=eq.{sub['id']}",
|
||||
headers=headers,
|
||||
json={"expires_at": new_expiry},
|
||||
timeout=10,
|
||||
)
|
||||
if patch_resp.ok:
|
||||
return {"ok": True, "email": normalized_email, "additional_days": safe_days, "new_expires_at": new_expiry}
|
||||
raise HTTPException(status_code=500, detail=f"Supabase update failed: {patch_resp.text[:200]}")
|
||||
|
||||
|
||||
# ── Logs ────────────────────────────────────────────────────────────
|
||||
|
||||
def get_ops_logs(
|
||||
request: Request,
|
||||
level: str = "",
|
||||
lines: int = 100,
|
||||
) -> dict[str, Any]:
|
||||
_require_ops(request)
|
||||
import subprocess
|
||||
safe_lines = max(10, min(1000, int(lines or 100)))
|
||||
try:
|
||||
# Read from Docker logs
|
||||
result = subprocess.run(
|
||||
["docker", "logs", "--tail", str(safe_lines), "polyweather_bot"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
log_text = result.stdout or result.stderr or ""
|
||||
except Exception:
|
||||
log_text = ""
|
||||
|
||||
log_lines = log_text.strip().split("\n") if log_text.strip() else []
|
||||
|
||||
if level:
|
||||
level_upper = level.upper()
|
||||
log_lines = [line for line in log_lines if level_upper in line.upper()]
|
||||
|
||||
return {
|
||||
"lines": log_lines[-safe_lines:],
|
||||
"total": len(log_lines),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user