Spaces:
Running
Running
Extend session visibility in admin dashboard with configurable retention
Browse files- Add retention_seconds param to SessionTracker (default 30min)
- Sessions now stay visible 30min after last request instead of 2min
- Add SESSION_RETENTION_MINUTES env var for configuration
- Show Active/Idle status in admin UI (<5min vs >5min idle)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- api/admin.py +4 -0
- api/services.py +5 -2
- config/settings.py +3 -0
- core/session_tracker.py +11 -6
- templates/admin.html +8 -0
api/admin.py
CHANGED
|
@@ -66,6 +66,10 @@ def _get_admin_data() -> dict[str, Any]:
|
|
| 66 |
"last_activity_seconds": last_activity_seconds,
|
| 67 |
"last_activity_display": _format_time(last_activity_seconds),
|
| 68 |
"requests_per_minute": int(req_per_min),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
}
|
| 70 |
)
|
| 71 |
|
|
|
|
| 66 |
"last_activity_seconds": last_activity_seconds,
|
| 67 |
"last_activity_display": _format_time(last_activity_seconds),
|
| 68 |
"requests_per_minute": int(req_per_min),
|
| 69 |
+
"status": "Active" if last_activity_seconds < 300 else "Idle",
|
| 70 |
+
"status_class": "status-active"
|
| 71 |
+
if last_activity_seconds < 300
|
| 72 |
+
else "status-idle",
|
| 73 |
}
|
| 74 |
)
|
| 75 |
|
api/services.py
CHANGED
|
@@ -11,7 +11,7 @@ from fastapi import HTTPException, Request
|
|
| 11 |
from fastapi.responses import StreamingResponse
|
| 12 |
from loguru import logger
|
| 13 |
|
| 14 |
-
from config.settings import Settings
|
| 15 |
from core.anthropic import get_token_count, get_user_facing_error_message
|
| 16 |
from core.anthropic.sse import ANTHROPIC_SSE_RESPONSE_HEADERS, format_sse_event
|
| 17 |
from core.session_tracker import SessionTracker
|
|
@@ -132,7 +132,10 @@ class ClaudeProxyService:
|
|
| 132 |
self._provider_getter = provider_getter
|
| 133 |
self._model_router = model_router or ModelRouter(settings)
|
| 134 |
self._token_counter = token_counter
|
| 135 |
-
|
|
|
|
|
|
|
|
|
|
| 136 |
|
| 137 |
def create_message(self, request: Request, request_data: MessagesRequest) -> object:
|
| 138 |
"""Create a message response or streaming response with optional failover."""
|
|
|
|
| 11 |
from fastapi.responses import StreamingResponse
|
| 12 |
from loguru import logger
|
| 13 |
|
| 14 |
+
from config.settings import Settings, get_settings
|
| 15 |
from core.anthropic import get_token_count, get_user_facing_error_message
|
| 16 |
from core.anthropic.sse import ANTHROPIC_SSE_RESPONSE_HEADERS, format_sse_event
|
| 17 |
from core.session_tracker import SessionTracker
|
|
|
|
| 132 |
self._provider_getter = provider_getter
|
| 133 |
self._model_router = model_router or ModelRouter(settings)
|
| 134 |
self._token_counter = token_counter
|
| 135 |
+
settings_local = get_settings()
|
| 136 |
+
self._session_tracker = SessionTracker.get_instance(
|
| 137 |
+
retention_seconds=settings_local.session_retention_minutes * 60
|
| 138 |
+
)
|
| 139 |
|
| 140 |
def create_message(self, request: Request, request_data: MessagesRequest) -> object:
|
| 141 |
"""Create a message response or streaming response with optional failover."""
|
config/settings.py
CHANGED
|
@@ -142,6 +142,9 @@ class Settings(BaseSettings):
|
|
| 142 |
)
|
| 143 |
# ==================== Zen/OpenCode Config ====================
|
| 144 |
zen_api_key: str = Field(default="", validation_alias="ZEN_API_KEY")
|
|
|
|
|
|
|
|
|
|
| 145 |
zen_base_url: str = Field(
|
| 146 |
default="https://opencode.ai/zen", validation_alias="ZEN_BASE_URL"
|
| 147 |
)
|
|
|
|
| 142 |
)
|
| 143 |
# ==================== Zen/OpenCode Config ====================
|
| 144 |
zen_api_key: str = Field(default="", validation_alias="ZEN_API_KEY")
|
| 145 |
+
session_retention_minutes: int = Field(
|
| 146 |
+
default=30, validation_alias="SESSION_RETENTION_MINUTES"
|
| 147 |
+
)
|
| 148 |
zen_base_url: str = Field(
|
| 149 |
default="https://opencode.ai/zen", validation_alias="ZEN_BASE_URL"
|
| 150 |
)
|
core/session_tracker.py
CHANGED
|
@@ -6,7 +6,7 @@ import asyncio
|
|
| 6 |
import time
|
| 7 |
from collections import defaultdict
|
| 8 |
from dataclasses import dataclass
|
| 9 |
-
from typing import ClassVar
|
| 10 |
|
| 11 |
from loguru import logger
|
| 12 |
|
|
@@ -56,6 +56,7 @@ class SessionTracker:
|
|
| 56 |
max_sessions: int = 50,
|
| 57 |
window_seconds: float = 60.0,
|
| 58 |
per_session_rate_limit: int = 30,
|
|
|
|
| 59 |
):
|
| 60 |
if hasattr(self, "_initialized"):
|
| 61 |
return
|
|
@@ -68,14 +69,18 @@ class SessionTracker:
|
|
| 68 |
self._max_sessions = max_sessions
|
| 69 |
self._window_seconds = window_seconds
|
| 70 |
self._per_session_rate_limit = per_session_rate_limit
|
|
|
|
|
|
|
|
|
|
| 71 |
self._lock = asyncio.Lock()
|
| 72 |
self._initialized = True
|
| 73 |
|
| 74 |
logger.info(
|
| 75 |
-
"SessionTracker initialized (max_sessions={}, window={}s, per_session_limit={}/min)",
|
| 76 |
max_sessions,
|
| 77 |
window_seconds,
|
| 78 |
per_session_rate_limit,
|
|
|
|
| 79 |
)
|
| 80 |
|
| 81 |
@classmethod
|
|
@@ -93,7 +98,7 @@ class SessionTracker:
|
|
| 93 |
def _cleanup_old_sessions(self) -> None:
|
| 94 |
"""Remove sessions with no recent activity (must be called with lock held)."""
|
| 95 |
now = time.monotonic()
|
| 96 |
-
cutoff = now -
|
| 97 |
to_remove = [
|
| 98 |
sid
|
| 99 |
for sid, state in self._sessions.items()
|
|
@@ -129,7 +134,7 @@ class SessionTracker:
|
|
| 129 |
|
| 130 |
async def track_request(self, session_id: str, provider_id: str) -> None:
|
| 131 |
"""Record a request for a session to a provider (async-safe)."""
|
| 132 |
-
self.
|
| 133 |
|
| 134 |
def track_request_sync(self, session_id: str, provider_id: str) -> None:
|
| 135 |
"""Record a request for a session to a provider (sync version for hot path)."""
|
|
@@ -151,7 +156,7 @@ class SessionTracker:
|
|
| 151 |
async def track_request_async(self, session_id: str, provider_id: str) -> None:
|
| 152 |
"""Async version with lock for when called from async contexts that need guarantees."""
|
| 153 |
async with self._lock:
|
| 154 |
-
self.
|
| 155 |
|
| 156 |
async def release_request(self, session_id: str, provider_id: str) -> None:
|
| 157 |
"""Release a request slot when streaming completes."""
|
|
@@ -214,7 +219,7 @@ class SessionTracker:
|
|
| 214 |
def get_all_session_loads(self) -> dict[str, SessionLoad]:
|
| 215 |
"""Get load information for all active sessions."""
|
| 216 |
return {
|
| 217 |
-
sid: self.get_session_load(sid)
|
| 218 |
for sid in self._sessions
|
| 219 |
if self.get_session_load(sid) is not None
|
| 220 |
}
|
|
|
|
| 6 |
import time
|
| 7 |
from collections import defaultdict
|
| 8 |
from dataclasses import dataclass
|
| 9 |
+
from typing import ClassVar, cast
|
| 10 |
|
| 11 |
from loguru import logger
|
| 12 |
|
|
|
|
| 56 |
max_sessions: int = 50,
|
| 57 |
window_seconds: float = 60.0,
|
| 58 |
per_session_rate_limit: int = 30,
|
| 59 |
+
retention_seconds: float | None = None,
|
| 60 |
):
|
| 61 |
if hasattr(self, "_initialized"):
|
| 62 |
return
|
|
|
|
| 69 |
self._max_sessions = max_sessions
|
| 70 |
self._window_seconds = window_seconds
|
| 71 |
self._per_session_rate_limit = per_session_rate_limit
|
| 72 |
+
self._retention_seconds = (
|
| 73 |
+
retention_seconds if retention_seconds is not None else window_seconds * 2
|
| 74 |
+
)
|
| 75 |
self._lock = asyncio.Lock()
|
| 76 |
self._initialized = True
|
| 77 |
|
| 78 |
logger.info(
|
| 79 |
+
"SessionTracker initialized (max_sessions={}, window={}s, per_session_limit={}/min, retention={}s)",
|
| 80 |
max_sessions,
|
| 81 |
window_seconds,
|
| 82 |
per_session_rate_limit,
|
| 83 |
+
self._retention_seconds,
|
| 84 |
)
|
| 85 |
|
| 86 |
@classmethod
|
|
|
|
| 98 |
def _cleanup_old_sessions(self) -> None:
|
| 99 |
"""Remove sessions with no recent activity (must be called with lock held)."""
|
| 100 |
now = time.monotonic()
|
| 101 |
+
cutoff = now - self._retention_seconds
|
| 102 |
to_remove = [
|
| 103 |
sid
|
| 104 |
for sid, state in self._sessions.items()
|
|
|
|
| 134 |
|
| 135 |
async def track_request(self, session_id: str, provider_id: str) -> None:
|
| 136 |
"""Record a request for a session to a provider (async-safe)."""
|
| 137 |
+
self.track_request_sync(session_id, provider_id)
|
| 138 |
|
| 139 |
def track_request_sync(self, session_id: str, provider_id: str) -> None:
|
| 140 |
"""Record a request for a session to a provider (sync version for hot path)."""
|
|
|
|
| 156 |
async def track_request_async(self, session_id: str, provider_id: str) -> None:
|
| 157 |
"""Async version with lock for when called from async contexts that need guarantees."""
|
| 158 |
async with self._lock:
|
| 159 |
+
self.track_request_sync(session_id, provider_id)
|
| 160 |
|
| 161 |
async def release_request(self, session_id: str, provider_id: str) -> None:
|
| 162 |
"""Release a request slot when streaming completes."""
|
|
|
|
| 219 |
def get_all_session_loads(self) -> dict[str, SessionLoad]:
|
| 220 |
"""Get load information for all active sessions."""
|
| 221 |
return {
|
| 222 |
+
sid: cast(SessionLoad, self.get_session_load(sid))
|
| 223 |
for sid in self._sessions
|
| 224 |
if self.get_session_load(sid) is not None
|
| 225 |
}
|
templates/admin.html
CHANGED
|
@@ -93,6 +93,12 @@
|
|
| 93 |
.status-limited {
|
| 94 |
color: #f85149;
|
| 95 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
.empty {
|
| 97 |
color: #8b949e;
|
| 98 |
font-style: italic;
|
|
@@ -140,6 +146,7 @@
|
|
| 140 |
<th>Providers</th>
|
| 141 |
<th>Last Active</th>
|
| 142 |
<th>Req/Min</th>
|
|
|
|
| 143 |
</tr>
|
| 144 |
</thead>
|
| 145 |
<tbody>
|
|
@@ -154,6 +161,7 @@
|
|
| 154 |
</td>
|
| 155 |
<td>{{ session.last_activity_display }}</td>
|
| 156 |
<td class="number">{{ session.requests_per_minute }}</td>
|
|
|
|
| 157 |
</tr>
|
| 158 |
{% endfor %}
|
| 159 |
</tbody>
|
|
|
|
| 93 |
.status-limited {
|
| 94 |
color: #f85149;
|
| 95 |
}
|
| 96 |
+
.status-active {
|
| 97 |
+
color: #3fb950;
|
| 98 |
+
}
|
| 99 |
+
.status-idle {
|
| 100 |
+
color: #8b949e;
|
| 101 |
+
}
|
| 102 |
.empty {
|
| 103 |
color: #8b949e;
|
| 104 |
font-style: italic;
|
|
|
|
| 146 |
<th>Providers</th>
|
| 147 |
<th>Last Active</th>
|
| 148 |
<th>Req/Min</th>
|
| 149 |
+
<th>Status</th>
|
| 150 |
</tr>
|
| 151 |
</thead>
|
| 152 |
<tbody>
|
|
|
|
| 161 |
</td>
|
| 162 |
<td>{{ session.last_activity_display }}</td>
|
| 163 |
<td class="number">{{ session.requests_per_minute }}</td>
|
| 164 |
+
<td class="{{ session.status_class }}">{{ session.status }}</td>
|
| 165 |
</tr>
|
| 166 |
{% endfor %}
|
| 167 |
</tbody>
|