mirror of
https://github.com/apidoorman/doorman.git
synced 2026-02-08 18:18:46 -06:00
Fix badges on user page. Fix logging page
This commit is contained in:
@@ -117,6 +117,28 @@ async def app_lifespan(app: FastAPI):
|
||||
|
||||
app.state._purger_task = asyncio.create_task(automatic_purger(1800))
|
||||
|
||||
# Restore persisted metrics (if available)
|
||||
METRICS_FILE = os.path.join(LOGS_DIR, 'metrics.json')
|
||||
try:
|
||||
metrics_store.load_from_file(METRICS_FILE)
|
||||
except Exception as e:
|
||||
gateway_logger.debug(f'Metrics restore skipped: {e}')
|
||||
|
||||
# Start periodic metrics saver
|
||||
async def _metrics_autosave(interval_s: int = 60):
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(interval_s)
|
||||
metrics_store.save_to_file(METRICS_FILE)
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
app.state._metrics_save_task = asyncio.create_task(_metrics_autosave(60))
|
||||
except Exception:
|
||||
app.state._metrics_save_task = None
|
||||
|
||||
try:
|
||||
await load_settings()
|
||||
await start_auto_save_task()
|
||||
@@ -213,6 +235,20 @@ async def app_lifespan(app: FastAPI):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Persist metrics on shutdown
|
||||
try:
|
||||
METRICS_FILE = os.path.join(LOGS_DIR, 'metrics.json')
|
||||
metrics_store.save_to_file(METRICS_FILE)
|
||||
except Exception:
|
||||
pass
|
||||
# Stop autosave task
|
||||
try:
|
||||
t = getattr(app.state, '_metrics_save_task', None)
|
||||
if t:
|
||||
t.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _generate_unique_id(route):
|
||||
try:
|
||||
name = getattr(route, 'name', 'op') or 'op'
|
||||
@@ -614,7 +650,7 @@ async def metrics_middleware(request: Request, call_next):
|
||||
if username:
|
||||
from utils.bandwidth_util import add_usage, _get_user
|
||||
u = _get_user(username)
|
||||
# Track usage only if not explicitly disabled and a limit is configured
|
||||
# Track usage when limit is set unless explicitly disabled
|
||||
if u and u.get('bandwidth_limit_bytes') and u.get('bandwidth_limit_enabled') is not False:
|
||||
add_usage(username, int(bytes_in) + int(clen), u.get('bandwidth_limit_window') or 'day')
|
||||
except Exception:
|
||||
|
||||
@@ -81,8 +81,7 @@ async def enforce_pre_request_limit(request: Request, username: Optional[str]) -
|
||||
user = _get_user(username)
|
||||
if not user:
|
||||
return
|
||||
# Only enforce if explicitly enabled or not disabled.
|
||||
# Backwards compatibility: if field is absent (None), treat as enabled when limit > 0
|
||||
# Enforce when limit is set unless explicitly disabled
|
||||
if user.get('bandwidth_limit_enabled') is False:
|
||||
return
|
||||
limit = user.get('bandwidth_limit_bytes')
|
||||
|
||||
@@ -59,10 +59,12 @@ async def limit_and_throttle(request: Request):
|
||||
if not user:
|
||||
user = user_collection.find_one({'username': username})
|
||||
now_ms = int(time.time() * 1000)
|
||||
# Rate limiting (skip if explicitly disabled)
|
||||
if user.get('rate_limit_enabled') is not False:
|
||||
rate = int(user.get('rate_limit_duration') or 1)
|
||||
duration = user.get('rate_limit_duration_type', 'minute')
|
||||
# Rate limiting (enabled if explicitly set true, or legacy values exist)
|
||||
rate_enabled = (user.get('rate_limit_enabled') is True) or bool(user.get('rate_limit_duration'))
|
||||
if rate_enabled:
|
||||
# Use user-set values; if explicitly enabled but missing values, fall back to sensible defaults
|
||||
rate = int(user.get('rate_limit_duration') or 60)
|
||||
duration = user.get('rate_limit_duration_type') or 'minute'
|
||||
window = duration_to_seconds(duration)
|
||||
key = f'rate_limit:{username}:{now_ms // (window * 1000)}'
|
||||
try:
|
||||
@@ -77,10 +79,11 @@ async def limit_and_throttle(request: Request):
|
||||
if count > rate:
|
||||
raise HTTPException(status_code=429, detail='Rate limit exceeded')
|
||||
|
||||
# Throttling (skip if explicitly disabled)
|
||||
if user.get('throttle_enabled') is not False:
|
||||
throttle_limit = int(user.get('throttle_duration') or 5)
|
||||
throttle_duration = user.get('throttle_duration_type', 'second')
|
||||
# Throttling (enabled if explicitly set true, or legacy values exist)
|
||||
throttle_enabled = (user.get('throttle_enabled') is True) or bool(user.get('throttle_duration'))
|
||||
if throttle_enabled:
|
||||
throttle_limit = int(user.get('throttle_duration') or 10)
|
||||
throttle_duration = user.get('throttle_duration_type') or 'second'
|
||||
throttle_window = duration_to_seconds(throttle_duration)
|
||||
throttle_key = f'throttle_limit:{username}:{now_ms // (throttle_window * 1000)}'
|
||||
try:
|
||||
|
||||
@@ -9,6 +9,8 @@ import time
|
||||
from collections import defaultdict, deque
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Deque, Dict, List, Optional
|
||||
import json
|
||||
import os
|
||||
|
||||
@dataclass
|
||||
class MinuteBucket:
|
||||
@@ -48,6 +50,39 @@ class MinuteBucket:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def to_dict(self) -> Dict:
|
||||
return {
|
||||
'start_ts': self.start_ts,
|
||||
'count': self.count,
|
||||
'error_count': self.error_count,
|
||||
'total_ms': self.total_ms,
|
||||
'bytes_in': self.bytes_in,
|
||||
'bytes_out': self.bytes_out,
|
||||
'status_counts': dict(self.status_counts or {}),
|
||||
'api_counts': dict(self.api_counts or {}),
|
||||
'api_error_counts': dict(self.api_error_counts or {}),
|
||||
'user_counts': dict(self.user_counts or {}),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_dict(d: Dict) -> 'MinuteBucket':
|
||||
mb = MinuteBucket(
|
||||
start_ts=int(d.get('start_ts', 0)),
|
||||
count=int(d.get('count', 0)),
|
||||
error_count=int(d.get('error_count', 0)),
|
||||
total_ms=float(d.get('total_ms', 0.0)),
|
||||
bytes_in=int(d.get('bytes_in', 0)),
|
||||
bytes_out=int(d.get('bytes_out', 0)),
|
||||
)
|
||||
try:
|
||||
mb.status_counts = dict(d.get('status_counts') or {})
|
||||
mb.api_counts = dict(d.get('api_counts') or {})
|
||||
mb.api_error_counts = dict(d.get('api_error_counts') or {})
|
||||
mb.user_counts = dict(d.get('user_counts') or {})
|
||||
except Exception:
|
||||
pass
|
||||
return mb
|
||||
|
||||
if username:
|
||||
try:
|
||||
self.user_counts[username] = self.user_counts.get(username, 0) + 1
|
||||
@@ -170,5 +205,60 @@ class MetricsStore:
|
||||
'top_apis': sorted(self.api_counts.items(), key=lambda kv: kv[1], reverse=True)[:10],
|
||||
}
|
||||
|
||||
def to_dict(self) -> Dict:
|
||||
return {
|
||||
'total_requests': int(self.total_requests),
|
||||
'total_ms': float(self.total_ms),
|
||||
'total_bytes_in': int(self.total_bytes_in),
|
||||
'total_bytes_out': int(self.total_bytes_out),
|
||||
'status_counts': dict(self.status_counts),
|
||||
'username_counts': dict(self.username_counts),
|
||||
'api_counts': dict(self.api_counts),
|
||||
'buckets': [b.to_dict() for b in list(self._buckets)],
|
||||
}
|
||||
|
||||
def load_dict(self, data: Dict) -> None:
|
||||
try:
|
||||
self.total_requests = int(data.get('total_requests', 0))
|
||||
self.total_ms = float(data.get('total_ms', 0.0))
|
||||
self.total_bytes_in = int(data.get('total_bytes_in', 0))
|
||||
self.total_bytes_out = int(data.get('total_bytes_out', 0))
|
||||
self.status_counts = defaultdict(int, data.get('status_counts') or {})
|
||||
self.username_counts = defaultdict(int, data.get('username_counts') or {})
|
||||
self.api_counts = defaultdict(int, data.get('api_counts') or {})
|
||||
self._buckets.clear()
|
||||
for bd in data.get('buckets', []):
|
||||
try:
|
||||
self._buckets.append(MinuteBucket.from_dict(bd))
|
||||
except Exception:
|
||||
continue
|
||||
except Exception:
|
||||
# If anything goes wrong, keep current in-memory metrics
|
||||
pass
|
||||
|
||||
def save_to_file(self, path: str) -> None:
|
||||
try:
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
tmp = path + '.tmp'
|
||||
with open(tmp, 'w', encoding='utf-8') as f:
|
||||
json.dump(self.to_dict(), f)
|
||||
os.replace(tmp, path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def load_from_file(self, path: str) -> None:
|
||||
try:
|
||||
if not os.path.exists(path):
|
||||
return
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
if isinstance(data, dict):
|
||||
self.load_dict(data)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Global metrics store
|
||||
metrics_store = MetricsStore()
|
||||
|
||||
@@ -68,9 +68,7 @@ export default function LogsPage() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showMoreFilters, setShowMoreFilters] = useState(false)
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const [filesLoading, setFilesLoading] = useState(false)
|
||||
const [filesError, setFilesError] = useState<string | null>(null)
|
||||
const [logFiles, setLogFiles] = useState<string[]>([])
|
||||
// Removed log files listing per requirements
|
||||
const [expandedRequests, setExpandedRequests] = useState<Set<string>>(new Set())
|
||||
const [loadingExpanded, setLoadingExpanded] = useState<Set<string>>(new Set())
|
||||
const [currentRequestId, setCurrentRequestId] = useState<string | null>(null)
|
||||
@@ -211,30 +209,7 @@ export default function LogsPage() {
|
||||
}
|
||||
}, [filters, logsPage, logsPageSize])
|
||||
|
||||
const fetchLogFiles = useCallback(async () => {
|
||||
try {
|
||||
setFilesLoading(true)
|
||||
setFilesError(null)
|
||||
const csrf = getCookie('csrf_token')
|
||||
const resp = await fetch(`${SERVER_URL}/platform/logging/logs/files`, {
|
||||
credentials: 'include',
|
||||
headers: { 'Accept': 'application/json', ...(csrf ? { 'X-CSRF-Token': csrf } : {}) }
|
||||
})
|
||||
if (!resp.ok) throw new Error('Failed to fetch log files')
|
||||
const data = await resp.json().catch(() => ({}))
|
||||
const files: string[] = data.response?.log_files || data.log_files || []
|
||||
setLogFiles(files)
|
||||
} catch (e:any) {
|
||||
setFilesError(e?.message || 'Failed to fetch log files')
|
||||
setLogFiles([])
|
||||
} finally {
|
||||
setFilesLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogFiles().catch(() => {})
|
||||
}, [fetchLogFiles])
|
||||
// (Log file listing removed)
|
||||
|
||||
const fetchLogsForRequestId = useCallback(async (requestId: string) => {
|
||||
try {
|
||||
@@ -487,16 +462,6 @@ export default function LogsPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2" />
|
||||
{logFiles.length > 0 && (
|
||||
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{logFiles.slice(0, 10).map((f, i) => (
|
||||
<span key={i} className="px-2 py-1 bg-gray-100 dark:bg-gray-800 rounded font-mono">{f}</span>
|
||||
))}
|
||||
{logFiles.length > 10 && <span className="italic">+{logFiles.length - 10} more</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
@@ -504,21 +469,7 @@ export default function LogsPage() {
|
||||
<h3 className="card-title">Filters</h3>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="mb-4 flex flex-col md:flex-row md:items-center md:justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={fetchLogFiles} className="btn btn-secondary" disabled={filesLoading}>
|
||||
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg>
|
||||
{filesLoading ? 'Refreshing Files...' : 'Refresh Files'}
|
||||
</button>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{filesError ? <span className="text-error-600">{filesError}</span> : (
|
||||
<>
|
||||
<span className="mr-2">Available log files:</span>
|
||||
<span className="font-mono">{logFiles.length}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4 flex flex-col md:flex-row md:items-center md:justify-start gap-3">
|
||||
{canExport && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button onClick={() => exportLogs('json')} disabled={exporting} className="btn btn-secondary">Download JSON</button>
|
||||
|
||||
@@ -88,13 +88,13 @@ const UserDetailPage = () => {
|
||||
groups: [...parsedUser.groups],
|
||||
rate_limit_duration: parsedUser.rate_limit_duration,
|
||||
rate_limit_duration_type: parsedUser.rate_limit_duration_type,
|
||||
rate_limit_enabled: (parsedUser as any).rate_limit_enabled,
|
||||
rate_limit_enabled: Boolean((parsedUser as any).rate_limit_enabled),
|
||||
throttle_duration: parsedUser.throttle_duration,
|
||||
throttle_duration_type: parsedUser.throttle_duration_type,
|
||||
throttle_wait_duration: (parsedUser as any).throttle_wait_duration,
|
||||
throttle_wait_duration_type: (parsedUser as any).throttle_wait_duration_type,
|
||||
throttle_queue_limit: (parsedUser as any).throttle_queue_limit,
|
||||
throttle_enabled: (parsedUser as any).throttle_enabled,
|
||||
throttle_enabled: Boolean((parsedUser as any).throttle_enabled),
|
||||
throttle_wait_duration: parsedUser.throttle_wait_duration,
|
||||
throttle_wait_duration_type: parsedUser.throttle_wait_duration_type,
|
||||
throttle_queue_limit: parsedUser.throttle_queue_limit,
|
||||
@@ -115,9 +115,9 @@ const UserDetailPage = () => {
|
||||
...prev,
|
||||
bandwidth_limit_bytes: refreshed.bandwidth_limit_bytes,
|
||||
bandwidth_limit_window: refreshed.bandwidth_limit_window,
|
||||
bandwidth_limit_enabled: (refreshed as any).bandwidth_limit_enabled,
|
||||
rate_limit_enabled: (refreshed as any).rate_limit_enabled,
|
||||
throttle_enabled: (refreshed as any).throttle_enabled,
|
||||
bandwidth_limit_enabled: Boolean((refreshed as any).bandwidth_limit_enabled),
|
||||
rate_limit_enabled: Boolean((refreshed as any).rate_limit_enabled),
|
||||
throttle_enabled: Boolean((refreshed as any).throttle_enabled),
|
||||
}))
|
||||
} catch {}
|
||||
})()
|
||||
@@ -153,17 +153,17 @@ const UserDetailPage = () => {
|
||||
groups: [...user.groups],
|
||||
rate_limit_duration: user.rate_limit_duration,
|
||||
rate_limit_duration_type: user.rate_limit_duration_type,
|
||||
rate_limit_enabled: (user as any).rate_limit_enabled,
|
||||
rate_limit_enabled: Boolean((user as any).rate_limit_enabled),
|
||||
throttle_duration: user.throttle_duration,
|
||||
throttle_duration_type: user.throttle_duration_type,
|
||||
throttle_wait_duration: user.throttle_wait_duration,
|
||||
throttle_wait_duration_type: user.throttle_wait_duration_type,
|
||||
throttle_queue_limit: user.throttle_queue_limit,
|
||||
throttle_enabled: (user as any).throttle_enabled,
|
||||
throttle_enabled: Boolean((user as any).throttle_enabled),
|
||||
custom_attributes: { ...user.custom_attributes },
|
||||
bandwidth_limit_bytes: user.bandwidth_limit_bytes,
|
||||
bandwidth_limit_window: user.bandwidth_limit_window,
|
||||
bandwidth_limit_enabled: (user as any).bandwidth_limit_enabled,
|
||||
bandwidth_limit_enabled: Boolean((user as any).bandwidth_limit_enabled),
|
||||
active: user.active,
|
||||
ui_access: user.ui_access
|
||||
})
|
||||
@@ -586,9 +586,14 @@ const UserDetailPage = () => {
|
||||
</label>
|
||||
</div>
|
||||
) : (
|
||||
<span className={`badge ${(user as any).bandwidth_limit_enabled === false ? 'badge-gray' : 'badge-success'}`}>
|
||||
{(user as any).bandwidth_limit_enabled === false ? 'Disabled' : 'Enabled'}
|
||||
</span>
|
||||
(() => {
|
||||
const bwEnabled = Boolean((user as any).bandwidth_limit_enabled) && (Number(user.bandwidth_limit_bytes || 0) > 0)
|
||||
return (
|
||||
<span className={`badge ${bwEnabled ? 'badge-success' : 'badge-gray'}`}>
|
||||
{bwEnabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
)
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
@@ -699,9 +704,14 @@ const UserDetailPage = () => {
|
||||
<label className="ml-2 text-sm text-gray-700 dark:text-gray-300">Enforce rate limiting for this user</label>
|
||||
</div>
|
||||
) : (
|
||||
<span className={`badge ${(user as any).rate_limit_enabled === false ? 'badge-gray' : 'badge-success'}`}>
|
||||
{(user as any).rate_limit_enabled === false ? 'Disabled' : 'Enabled'}
|
||||
</span>
|
||||
(() => {
|
||||
const enabled = Boolean((user as any).rate_limit_enabled)
|
||||
return (
|
||||
<span className={`badge ${enabled ? 'badge-success' : 'badge-gray'}`}>
|
||||
{enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
)
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
@@ -757,9 +767,14 @@ const UserDetailPage = () => {
|
||||
<label className="ml-2 text-sm text-gray-700 dark:text-gray-300">Enforce throttling for this user</label>
|
||||
</div>
|
||||
) : (
|
||||
<span className={`badge ${(user as any).throttle_enabled === false ? 'badge-gray' : 'badge-success'}`}>
|
||||
{(user as any).throttle_enabled === false ? 'Disabled' : 'Enabled'}
|
||||
</span>
|
||||
(() => {
|
||||
const enabled = Boolean((user as any).throttle_enabled)
|
||||
return (
|
||||
<span className={`badge ${enabled ? 'badge-success' : 'badge-gray'}`}>
|
||||
{enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
)
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
@@ -878,7 +893,7 @@ const UserDetailPage = () => {
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{Object.entries(isEditing ? editData.custom_attributes || {} : user.custom_attributes).map(([key, value]) => (
|
||||
{Object.entries(((isEditing ? editData.custom_attributes : user.custom_attributes) || {})).map(([key, value]) => (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<span className="text-sm bg-purple-100 dark:bg-purple-900/20 text-purple-800 dark:text-purple-200 px-3 py-1 rounded flex-1">
|
||||
<strong>{key}:</strong> {value}
|
||||
@@ -897,7 +912,7 @@ const UserDetailPage = () => {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{Object.keys(isEditing ? editData.custom_attributes || {} : user.custom_attributes).length === 0 && (
|
||||
{Object.keys(((isEditing ? editData.custom_attributes : user.custom_attributes) || {})).length === 0 && (
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm">No custom attributes</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -41,7 +41,16 @@ const AddUserPage = () => {
|
||||
role: '',
|
||||
groups: [],
|
||||
custom_attributes: {},
|
||||
rate_limit_enabled: false,
|
||||
// Defaults: enforce rate/throttle by default with reasonable values
|
||||
rate_limit_duration: 60,
|
||||
rate_limit_duration_type: 'minute',
|
||||
rate_limit_enabled: true,
|
||||
throttle_duration: 10,
|
||||
throttle_duration_type: 'second',
|
||||
throttle_wait_duration: 0.5,
|
||||
throttle_wait_duration_type: 'second',
|
||||
throttle_queue_limit: 10,
|
||||
throttle_enabled: true,
|
||||
bandwidth_limit_bytes: undefined,
|
||||
bandwidth_limit_window: 'day',
|
||||
bandwidth_limit_enabled: false,
|
||||
|
||||
Reference in New Issue
Block a user