mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-06 04:38:22 +00:00
feat: add log investigation feature and modern UI to admin dashboard
- Add 'Investigate Logs' button with modal for request ID input
- Implement /admin/logs/{request_id} endpoint for log viewing
- Parse and display JSON log entries with formatting
- Search through last 7 days of log files
- Add modern minimal CSS design system
- Improve typography with system font stack
- Add card layouts, shadows, and rounded corners
- Implement smooth transitions and hover effects
- Add emoji icons for visual interest
- Rename 'User's API Keys' to 'Temporary Balances'
This commit is contained in:
+315
-145
@@ -1,5 +1,7 @@
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
@@ -8,6 +10,9 @@ from sqlmodel import select
|
||||
|
||||
from ..wallet import get_balance, send_token
|
||||
from .db import ApiKey, create_session
|
||||
from .logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
admin_router = APIRouter(prefix="/admin", include_in_schema=False)
|
||||
|
||||
@@ -21,26 +26,14 @@ def login_form() -> str:
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
}
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
input[type="password"] {
|
||||
padding: 8px;
|
||||
}
|
||||
button {
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; background: #f5f7fa; }
|
||||
.login-card { background: white; padding: 2.5rem; border-radius: 12px; box-shadow: 0 10px 25px rgba(0,0,0,0.1); width: 320px; }
|
||||
h2 { margin-bottom: 1.5rem; color: #1a202c; text-align: center; }
|
||||
input[type="password"] { width: 100%; padding: 12px; border: 2px solid #e2e8f0; border-radius: 6px; font-size: 16px; transition: border 0.2s; }
|
||||
input[type="password"]:focus { outline: none; border-color: #4299e1; }
|
||||
button { width: 100%; padding: 12px; margin-top: 1rem; background: #4299e1; color: white; border: none; border-radius: 6px; font-size: 16px; font-weight: 600; cursor: pointer; transition: all 0.2s; }
|
||||
button:hover { background: #3182ce; transform: translateY(-1px); box-shadow: 0 4px 6px rgba(0,0,0,0.1); }
|
||||
</style>
|
||||
<script>
|
||||
function handleSubmit(e) {
|
||||
@@ -52,10 +45,13 @@ def login_form() -> str:
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<form onsubmit="handleSubmit(event)">
|
||||
<input type="password" id="password" placeholder="Admin Password" required>
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
<div class="login-card">
|
||||
<h2>🔐 Admin Login</h2>
|
||||
<form onsubmit="handleSubmit(event)">
|
||||
<input type="password" id="password" placeholder="Admin Password" required autofocus>
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
@@ -66,19 +62,15 @@ def info(content: str) -> str:
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body {{
|
||||
font-family: Arial, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
}}
|
||||
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; background: #f5f7fa; }}
|
||||
.info-card {{ background: white; padding: 2.5rem; border-radius: 12px; box-shadow: 0 10px 25px rgba(0,0,0,0.1); max-width: 500px; text-align: center; }}
|
||||
.info-card p {{ color: #4a5568; font-size: 1.1rem; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div style="text-align: center;">
|
||||
{content}
|
||||
<div class="info-card">
|
||||
<p>{content}</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -124,105 +116,39 @@ async def dashboard(request: Request) -> str:
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
table {{
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}}
|
||||
th, td {{
|
||||
border: 1px solid black;
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
}}
|
||||
button {{
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
margin-right: 10px;
|
||||
}}
|
||||
button:hover {{
|
||||
background-color: #0056b3;
|
||||
}}
|
||||
button:disabled {{
|
||||
background-color: #6c757d;
|
||||
cursor: not-allowed;
|
||||
}}
|
||||
#token-result {{
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
background-color: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 4px;
|
||||
word-break: break-all;
|
||||
display: none;
|
||||
max-width: 100%;
|
||||
}}
|
||||
#token-text {{
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
background-color: #e9ecef;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin: 10px 0;
|
||||
}}
|
||||
.copy-btn {{
|
||||
background-color: #28a745;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}}
|
||||
.copy-btn:hover {{
|
||||
background-color: #1e7e34;
|
||||
}}
|
||||
.refresh-btn {{
|
||||
background-color: #ffc107;
|
||||
color: black;
|
||||
}}
|
||||
.refresh-btn:hover {{
|
||||
background-color: #e0a800;
|
||||
}}
|
||||
.modal {{
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 1;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0,0,0,0.4);
|
||||
}}
|
||||
.modal-content {{
|
||||
background-color: #fefefe;
|
||||
margin: 15% auto;
|
||||
padding: 20px;
|
||||
border: 1px solid #888;
|
||||
width: 300px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
}}
|
||||
.close {{
|
||||
color: #aaa;
|
||||
float: right;
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
}}
|
||||
.close:hover {{
|
||||
color: black;
|
||||
}}
|
||||
input[type="number"] {{
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
margin: 10px 0;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}}
|
||||
.warning {{
|
||||
color: #dc3545;
|
||||
font-weight: bold;
|
||||
margin: 10px 0;
|
||||
}}
|
||||
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f7fa; color: #2c3e50; line-height: 1.6; padding: 2rem; }}
|
||||
h1, h2 {{ margin-bottom: 1rem; color: #1a202c; }}
|
||||
h1 {{ font-size: 2rem; }}
|
||||
h2 {{ font-size: 1.5rem; margin-top: 2rem; }}
|
||||
p {{ margin-bottom: 0.5rem; color: #4a5568; }}
|
||||
table {{ width: 100%; border-collapse: collapse; background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-top: 1rem; }}
|
||||
th {{ background: #4a5568; color: white; font-weight: 600; padding: 12px; text-align: left; }}
|
||||
td {{ padding: 12px; border-bottom: 1px solid #e2e8f0; }}
|
||||
tr:hover {{ background: #f7fafc; }}
|
||||
button {{ padding: 10px 20px; cursor: pointer; background: #4299e1; color: white; border: none; border-radius: 6px; font-weight: 600; margin-right: 10px; transition: all 0.2s; }}
|
||||
button:hover {{ background: #3182ce; transform: translateY(-1px); box-shadow: 0 2px 4px rgba(0,0,0,0.1); }}
|
||||
button:disabled {{ background: #a0aec0; cursor: not-allowed; transform: none; }}
|
||||
.refresh-btn {{ background: #48bb78; }}
|
||||
.refresh-btn:hover {{ background: #38a169; }}
|
||||
.investigate-btn {{ background: #4299e1; }}
|
||||
.balance-card {{ background: white; padding: 2rem; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 2rem; }}
|
||||
.balance-item {{ display: flex; justify-content: space-between; margin-bottom: 1rem; }}
|
||||
.balance-label {{ color: #718096; }}
|
||||
.balance-value {{ font-size: 1.5rem; font-weight: 700; color: #2d3748; }}
|
||||
.balance-primary {{ color: #48bb78; }}
|
||||
#token-result {{ margin-top: 20px; padding: 20px; background: #e6fffa; border: 1px solid #38b2ac; border-radius: 8px; display: none; }}
|
||||
#token-text {{ font-family: 'Monaco', monospace; font-size: 13px; background: #2d3748; color: #68d391; padding: 15px; border-radius: 6px; margin: 10px 0; word-break: break-all; }}
|
||||
.copy-btn {{ background: #38a169; padding: 6px 12px; font-size: 14px; }}
|
||||
.copy-btn:hover {{ background: #2f855a; }}
|
||||
.modal {{ display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); backdrop-filter: blur(4px); }}
|
||||
.modal-content {{ background: white; margin: 10% auto; padding: 2rem; width: 90%; max-width: 400px; border-radius: 12px; box-shadow: 0 20px 25px -5px rgba(0,0,0,0.1); animation: slideIn 0.3s ease; }}
|
||||
@keyframes slideIn {{ from {{ transform: translateY(-20px); opacity: 0; }} to {{ transform: translateY(0); opacity: 1; }} }}
|
||||
.close {{ color: #a0aec0; float: right; font-size: 28px; font-weight: bold; cursor: pointer; margin: -10px -10px 0 0; }}
|
||||
.close:hover {{ color: #2d3748; }}
|
||||
input[type="number"], input[type="text"] {{ width: 100%; padding: 10px; margin: 10px 0; border: 2px solid #e2e8f0; border-radius: 6px; font-size: 16px; transition: border 0.2s; }}
|
||||
input[type="number"]:focus, input[type="text"]:focus {{ outline: none; border-color: #4299e1; }}
|
||||
.warning {{ color: #e53e3e; font-weight: 600; margin: 10px 0; padding: 10px; background: #fff5f5; border-radius: 6px; }}
|
||||
</style>
|
||||
<script>
|
||||
function openWithdrawModal() {{
|
||||
@@ -312,27 +238,64 @@ async def dashboard(request: Request) -> str:
|
||||
window.location.reload();
|
||||
}}
|
||||
|
||||
function openInvestigateModal() {{
|
||||
const modal = document.getElementById('investigate-modal');
|
||||
modal.style.display = 'block';
|
||||
}}
|
||||
|
||||
function closeInvestigateModal() {{
|
||||
const modal = document.getElementById('investigate-modal');
|
||||
modal.style.display = 'none';
|
||||
}}
|
||||
|
||||
function investigateLogs() {{
|
||||
const requestId = document.getElementById('request-id').value.trim();
|
||||
if (!requestId) {{
|
||||
alert('Please enter a Request ID');
|
||||
return;
|
||||
}}
|
||||
window.location.href = `/admin/logs/${{requestId}}`;
|
||||
}}
|
||||
|
||||
window.onclick = function(event) {{
|
||||
const modal = document.getElementById('withdraw-modal');
|
||||
if (event.target == modal) {{
|
||||
const withdrawModal = document.getElementById('withdraw-modal');
|
||||
const investigateModal = document.getElementById('investigate-modal');
|
||||
if (event.target == withdrawModal) {{
|
||||
closeWithdrawModal();
|
||||
}} else if (event.target == investigateModal) {{
|
||||
closeInvestigateModal();
|
||||
}}
|
||||
}}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Admin Dashboard</h1>
|
||||
<h2>Current Cashu Balance</h2>
|
||||
<p>Your Balance: {owner_balance} sats</p>
|
||||
<p>The balance is calculated by subtracting the combined user balance from the total Cashu wallet balance.</p>
|
||||
<p>Total Cashu Balance: {current_balance} sats</p>
|
||||
<p>User Balance: {total_user_balance} sats</p>
|
||||
|
||||
<div class="balance-card">
|
||||
<h2>Cashu Wallet Balance</h2>
|
||||
<div class="balance-item">
|
||||
<span class="balance-label">Your Balance</span>
|
||||
<span class="balance-value balance-primary">{owner_balance} sats</span>
|
||||
</div>
|
||||
<div class="balance-item">
|
||||
<span class="balance-label">Total Wallet</span>
|
||||
<span class="balance-value">{current_balance} sats</span>
|
||||
</div>
|
||||
<div class="balance-item">
|
||||
<span class="balance-label">User Balance</span>
|
||||
<span class="balance-value">{total_user_balance} sats</span>
|
||||
</div>
|
||||
<p style="margin-top: 1rem; font-size: 0.9rem; color: #718096;">Your balance = Total wallet - User balance</p>
|
||||
</div>
|
||||
|
||||
<button id="withdraw-btn" onclick="openWithdrawModal()" {"disabled" if current_balance <= 0 else ""}>
|
||||
Withdraw Balance
|
||||
💸 Withdraw Balance
|
||||
</button>
|
||||
<button class="refresh-btn" onclick="refreshPage()">
|
||||
Refresh Dashboard
|
||||
🔄 Refresh
|
||||
</button>
|
||||
<button class="investigate-btn" onclick="openInvestigateModal()">
|
||||
🔍 Investigate Logs
|
||||
</button>
|
||||
|
||||
<div id="withdraw-modal" class="modal">
|
||||
@@ -346,8 +309,19 @@ async def dashboard(request: Request) -> str:
|
||||
<div id="withdraw-warning" class="warning" style="display: none;">
|
||||
⚠️ Warning: Withdrawing more than your balance will use user funds!
|
||||
</div>
|
||||
<button id="confirm-withdraw-btn" onclick="performWithdraw()">Withdraw</button>
|
||||
<button onclick="closeWithdrawModal()" style="background-color: #6c757d;">Cancel</button>
|
||||
<button id="confirm-withdraw-btn" onclick="performWithdraw()">💸 Withdraw</button>
|
||||
<button onclick="closeWithdrawModal()" style="background-color: #718096;">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="investigate-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close" onclick="closeInvestigateModal()">×</span>
|
||||
<h3>Investigate Logs</h3>
|
||||
<p>Enter Request ID to investigate:</p>
|
||||
<input type="text" id="request-id" placeholder="e.g., 123e4567-e89b-12d3-a456-426614174000" style="width: 100%; padding: 8px; margin: 10px 0; border: 1px solid #ddd; border-radius: 4px;">
|
||||
<button onclick="investigateLogs()">🔍 Investigate</button>
|
||||
<button onclick="closeInvestigateModal()" style="background-color: #718096;">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -358,7 +332,7 @@ async def dashboard(request: Request) -> str:
|
||||
<p><em>Save this token! It represents your withdrawn balance.</em></p>
|
||||
</div>
|
||||
|
||||
<h2>User's API Keys</h2>
|
||||
<h2>Temporary Balances</h2>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Hashed Key</th>
|
||||
@@ -383,6 +357,202 @@ async def admin(request: Request) -> str:
|
||||
return admin_auth()
|
||||
|
||||
|
||||
@admin_router.get("/logs/{request_id}", response_class=HTMLResponse)
|
||||
async def view_logs(request: Request, request_id: str) -> str:
|
||||
admin_cookie = request.cookies.get("admin_password")
|
||||
if not admin_cookie or admin_cookie != os.getenv("ADMIN_PASSWORD"):
|
||||
return admin_auth()
|
||||
|
||||
logger.info(f"Investigating logs for request_id: {request_id}")
|
||||
|
||||
# Search for log entries with this request_id
|
||||
log_entries = []
|
||||
logs_dir = Path("logs")
|
||||
|
||||
if logs_dir.exists():
|
||||
# Get all log files sorted by modification time (most recent first)
|
||||
log_files = sorted(
|
||||
logs_dir.glob("*.log"), key=lambda x: x.stat().st_mtime, reverse=True
|
||||
)
|
||||
|
||||
for log_file in log_files[:7]: # Check last 7 days of logs
|
||||
try:
|
||||
with open(log_file, "r") as f:
|
||||
for line in f:
|
||||
if request_id in line:
|
||||
try:
|
||||
# Parse JSON log entry
|
||||
log_data = json.loads(line.strip())
|
||||
log_entries.append(log_data)
|
||||
except json.JSONDecodeError:
|
||||
# If not JSON, include raw line
|
||||
log_entries.append({"raw": line.strip()})
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading log file {log_file}: {e}")
|
||||
|
||||
# Sort entries by timestamp if available
|
||||
log_entries.sort(key=lambda x: x.get("asctime", ""), reverse=False)
|
||||
|
||||
# Format log entries for display
|
||||
formatted_logs = []
|
||||
for entry in log_entries:
|
||||
if "raw" in entry:
|
||||
formatted_logs.append(f'<div class="log-entry">{entry["raw"]}</div>')
|
||||
else:
|
||||
# Format JSON log entry
|
||||
timestamp = entry.get("asctime", "Unknown time")
|
||||
level = entry.get("levelname", "INFO")
|
||||
message = entry.get("message", "")
|
||||
pathname = entry.get("pathname", "")
|
||||
lineno = entry.get("lineno", "")
|
||||
|
||||
# Extract additional fields
|
||||
extra_fields = {
|
||||
k: v
|
||||
for k, v in entry.items()
|
||||
if k
|
||||
not in [
|
||||
"asctime",
|
||||
"levelname",
|
||||
"message",
|
||||
"pathname",
|
||||
"lineno",
|
||||
"name",
|
||||
"version",
|
||||
"request_id",
|
||||
]
|
||||
}
|
||||
|
||||
level_class = level.lower()
|
||||
formatted_entry = f"""
|
||||
<div class="log-entry log-{level_class}">
|
||||
<div class="log-header">
|
||||
<span class="log-timestamp">{timestamp}</span>
|
||||
<span class="log-level">[{level}]</span>
|
||||
<span class="log-location">{pathname}:{lineno}</span>
|
||||
</div>
|
||||
<div class="log-message">{message}</div>
|
||||
"""
|
||||
|
||||
if extra_fields:
|
||||
formatted_entry += '<div class="log-extra">'
|
||||
for key, value in extra_fields.items():
|
||||
formatted_entry += f'<div class="log-field"><strong>{key}:</strong> {json.dumps(value) if isinstance(value, (dict, list)) else value}</div>'
|
||||
formatted_entry += "</div>"
|
||||
|
||||
formatted_entry += "</div>"
|
||||
formatted_logs.append(formatted_entry)
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body {{
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}}
|
||||
h1 {{
|
||||
color: #333;
|
||||
}}
|
||||
.back-btn {{
|
||||
padding: 8px 16px;
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
margin-bottom: 20px;
|
||||
}}
|
||||
.back-btn:hover {{
|
||||
background-color: #0056b3;
|
||||
}}
|
||||
.log-container {{
|
||||
background-color: white;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
}}
|
||||
.log-entry {{
|
||||
margin-bottom: 15px;
|
||||
padding: 10px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
background-color: #f9f9f9;
|
||||
}}
|
||||
.log-entry.log-error {{
|
||||
background-color: #fee;
|
||||
border-color: #fcc;
|
||||
}}
|
||||
.log-entry.log-warning {{
|
||||
background-color: #ffc;
|
||||
border-color: #ff9;
|
||||
}}
|
||||
.log-entry.log-debug, .log-entry.log-trace {{
|
||||
background-color: #f0f0f0;
|
||||
border-color: #ccc;
|
||||
}}
|
||||
.log-header {{
|
||||
margin-bottom: 5px;
|
||||
color: #666;
|
||||
}}
|
||||
.log-timestamp {{
|
||||
color: #0066cc;
|
||||
}}
|
||||
.log-level {{
|
||||
font-weight: bold;
|
||||
}}
|
||||
.log-message {{
|
||||
margin: 5px 0;
|
||||
color: #333;
|
||||
}}
|
||||
.log-extra {{
|
||||
margin-top: 5px;
|
||||
padding-top: 5px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}}
|
||||
.log-field {{
|
||||
margin: 2px 0;
|
||||
color: #666;
|
||||
word-break: break-all;
|
||||
}}
|
||||
.no-logs {{
|
||||
text-align: center;
|
||||
color: #666;
|
||||
padding: 40px;
|
||||
}}
|
||||
.request-id-display {{
|
||||
background-color: #e9ecef;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 20px;
|
||||
font-family: monospace;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<a href="/admin" class="back-btn">← Back to Dashboard</a>
|
||||
<h1>Log Investigation</h1>
|
||||
<div class="request-id-display">
|
||||
<strong>Request ID:</strong> {request_id}
|
||||
</div>
|
||||
<div class="log-container">
|
||||
{"".join(formatted_logs) if formatted_logs else '<div class="no-logs">No log entries found for this Request ID</div>'}
|
||||
</div>
|
||||
<p style="color: #666; margin-top: 20px;">
|
||||
Found {len(log_entries)} log entries • Searched last 7 days of logs
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
@admin_router.post("/withdraw")
|
||||
async def withdraw(
|
||||
request: Request, withdraw_request: WithdrawRequest
|
||||
|
||||
Reference in New Issue
Block a user