Fix badges on user page. Fix logging page

This commit is contained in:
seniorswe
2025-10-04 15:36:32 -04:00
parent f5cc7e8168
commit 85192b8b3d
7 changed files with 186 additions and 83 deletions

View File

@@ -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:

View File

@@ -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')

View File

@@ -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:

View File

@@ -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()

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,