feat(azure-ai+gpu-ui): per-tenant Azure AI config + GPU health panel

- Per-tenant Azure AI config stored in tenants.tenant_config JSONB
- GET/PUT /api/tenants/{id}/ai-config + POST .../test connection
- api_key never returned to frontend (has_api_key: bool pattern)
- azure_ai.py resolves creds from tenant config when ai_enabled=True
- ai_tasks.py loads tenant config and passes it to validate_thumbnail
- Admin GPU Status section: probe button + status badge + last-checked time
- Notifications: _BELL_CHANNELS filter (notification+alert only in bell)
- Tenants.tsx: per-row Azure AI Config modal with URL auto-parse helper
- Remove duplicate in-memory /gpu-probe endpoints (kept DB-backed /probe/gpu)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 21:04:09 +01:00
parent 34f89cc225
commit 22c29d5655
11 changed files with 792 additions and 24 deletions
+21 -6
View File
@@ -51,30 +51,42 @@ def _visibility_filter(user: User):
return and_(AuditLog.notification == True, targeted) # noqa: E712
# Default channels shown in bell dropdown
_BELL_CHANNELS = ("notification", "alert")
@router.get("", response_model=NotificationListResponse)
async def list_notifications(
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
unread_only: bool = Query(False),
channel: Optional[str] = Query(None, description="Filter by channel: notification, activity, alert. Defaults to notification+alert."),
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
vis = _visibility_filter(user)
# Channel filter
if channel:
channel_filter = AuditLog.channel == channel
else:
channel_filter = AuditLog.channel.in_(_BELL_CHANNELS)
# Total count
total_q = select(func.count(AuditLog.id)).where(vis)
total_q = select(func.count(AuditLog.id)).where(vis, channel_filter)
if unread_only:
total_q = total_q.where(AuditLog.read_at.is_(None))
total = (await db.execute(total_q)).scalar() or 0
# Unread count (always)
unread_q = select(func.count(AuditLog.id)).where(vis, AuditLog.read_at.is_(None))
# Unread count (always — only for bell channels, not activity)
bell_channel_filter = AuditLog.channel.in_(_BELL_CHANNELS)
unread_q = select(func.count(AuditLog.id)).where(vis, bell_channel_filter, AuditLog.read_at.is_(None))
unread_count = (await db.execute(unread_q)).scalar() or 0
# Items
items_q = (
select(AuditLog)
.where(vis)
.where(vis, channel_filter)
.order_by(AuditLog.timestamp.desc())
.offset(offset)
.limit(limit)
@@ -105,7 +117,8 @@ async def unread_count(
db: AsyncSession = Depends(get_db),
):
vis = _visibility_filter(user)
q = select(func.count(AuditLog.id)).where(vis, AuditLog.read_at.is_(None))
bell_channel_filter = AuditLog.channel.in_(_BELL_CHANNELS)
q = select(func.count(AuditLog.id)).where(vis, bell_channel_filter, AuditLog.read_at.is_(None))
count = (await db.execute(q)).scalar() or 0
return UnreadCountResponse(unread_count=count)
@@ -182,7 +195,9 @@ async def update_my_notification_config(
):
if channel not in ("in_app", "email"):
raise HTTPException(status_code=400, detail="channel must be 'in_app' or 'email'")
return await upsert_notification_config(db, current_user.id, event_type, channel, body.enabled)
if body.frequency is not None and body.frequency not in ("immediate", "daily", "never"):
raise HTTPException(status_code=400, detail="frequency must be 'immediate', 'daily', or 'never'")
return await upsert_notification_config(db, current_user.id, event_type, channel, body.enabled, body.frequency)
@router.post("/config/reset", response_model=list[NotificationConfigOut])