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 return and_(AuditLog.notification == True, targeted) # noqa: E712
# Default channels shown in bell dropdown
_BELL_CHANNELS = ("notification", "alert")
@router.get("", response_model=NotificationListResponse) @router.get("", response_model=NotificationListResponse)
async def list_notifications( async def list_notifications(
limit: int = Query(20, ge=1, le=100), limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0), offset: int = Query(0, ge=0),
unread_only: bool = Query(False), 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), user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
vis = _visibility_filter(user) vis = _visibility_filter(user)
# Channel filter
if channel:
channel_filter = AuditLog.channel == channel
else:
channel_filter = AuditLog.channel.in_(_BELL_CHANNELS)
# Total count # 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: if unread_only:
total_q = total_q.where(AuditLog.read_at.is_(None)) total_q = total_q.where(AuditLog.read_at.is_(None))
total = (await db.execute(total_q)).scalar() or 0 total = (await db.execute(total_q)).scalar() or 0
# Unread count (always) # Unread count (always — only for bell channels, not activity)
unread_q = select(func.count(AuditLog.id)).where(vis, AuditLog.read_at.is_(None)) 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 unread_count = (await db.execute(unread_q)).scalar() or 0
# Items # Items
items_q = ( items_q = (
select(AuditLog) select(AuditLog)
.where(vis) .where(vis, channel_filter)
.order_by(AuditLog.timestamp.desc()) .order_by(AuditLog.timestamp.desc())
.offset(offset) .offset(offset)
.limit(limit) .limit(limit)
@@ -105,7 +117,8 @@ async def unread_count(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
vis = _visibility_filter(user) 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 count = (await db.execute(q)).scalar() or 0
return UnreadCountResponse(unread_count=count) return UnreadCountResponse(unread_count=count)
@@ -182,7 +195,9 @@ async def update_my_notification_config(
): ):
if channel not in ("in_app", "email"): if channel not in ("in_app", "email"):
raise HTTPException(status_code=400, detail="channel must be 'in_app' or '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]) @router.post("/config/reset", response_model=list[NotificationConfigOut])
+1
View File
@@ -683,3 +683,4 @@ async def update_worker_config(
enabled=cfg.enabled, enabled=cfg.enabled,
updated_at=cfg.updated_at.isoformat(), updated_at=cfg.updated_at.isoformat(),
) )
+9
View File
@@ -12,6 +12,15 @@ _DEFAULT_TENANT_CONFIG = {
"fallback_material": "SCHAEFFLER_059999_FailedMaterial", "fallback_material": "SCHAEFFLER_059999_FailedMaterial",
"notifications_enabled": True, "notifications_enabled": True,
"invoice_prefix": "INV", "invoice_prefix": "INV",
# Azure AI validation (per-tenant)
"ai_enabled": False,
"ai_api_key": None, # stored but never returned to frontend
"ai_endpoint": None, # e.g. https://myinstance.openai.azure.com
"ai_deployment": "gpt-4o",
"ai_api_version": "2024-02-01",
"ai_max_tokens": 500,
"ai_temperature": 0.1,
"ai_validation_prompt": None, # None = use DEFAULT_VALIDATION_PROMPT from azure_ai.py
} }
+124 -1
View File
@@ -1,11 +1,16 @@
import uuid import uuid
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import update
from app.database import get_db from app.database import get_db
from app.utils.auth import require_admin from app.utils.auth import require_admin
from app.domains.tenants.schemas import TenantCreate, TenantUpdate, TenantOut from app.domains.tenants.schemas import (
TenantCreate, TenantUpdate, TenantOut,
TenantAIConfigUpdate, TenantAIConfigOut,
)
from app.domains.tenants import service from app.domains.tenants import service
from app.domains.tenants.models import Tenant
router = APIRouter(prefix="/tenants", tags=["tenants"]) router = APIRouter(prefix="/tenants", tags=["tenants"])
@@ -77,3 +82,121 @@ async def delete_tenant(
status_code=status.HTTP_409_CONFLICT, status_code=status.HTTP_409_CONFLICT,
detail="Tenant not found or still has users assigned", detail="Tenant not found or still has users assigned",
) )
# ---------------------------------------------------------------------------
# Per-tenant Azure AI configuration
# ---------------------------------------------------------------------------
def _tenant_ai_config_out(tenant: Tenant) -> TenantAIConfigOut:
"""Build TenantAIConfigOut from a Tenant ORM object (never exposes api_key)."""
cfg = tenant.tenant_config or {}
return TenantAIConfigOut(
ai_enabled=cfg.get("ai_enabled", False),
ai_endpoint=cfg.get("ai_endpoint"),
ai_deployment=cfg.get("ai_deployment", "gpt-4o"),
ai_api_version=cfg.get("ai_api_version", "2024-02-01"),
has_api_key=bool(cfg.get("ai_api_key")),
ai_max_tokens=cfg.get("ai_max_tokens", 500),
ai_temperature=cfg.get("ai_temperature", 0.1),
ai_validation_prompt=cfg.get("ai_validation_prompt"),
)
@router.get("/{tenant_id}/ai-config", response_model=TenantAIConfigOut)
async def get_tenant_ai_config(
tenant_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
_: object = Depends(require_admin),
):
"""Return AI config for a tenant (without the raw api_key)."""
tenant = await service.get_tenant(db, tenant_id)
if not tenant:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
return _tenant_ai_config_out(tenant)
@router.put("/{tenant_id}/ai-config", response_model=TenantAIConfigOut)
async def update_tenant_ai_config(
tenant_id: uuid.UUID,
body: TenantAIConfigUpdate,
db: AsyncSession = Depends(get_db),
_: object = Depends(require_admin),
):
"""Merge AI configuration into tenant_config JSONB.
If ai_api_key is None in the request body, the existing key is preserved.
"""
tenant = await service.get_tenant(db, tenant_id)
if not tenant:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
# Build the merged config dict
current_cfg: dict = dict(tenant.tenant_config or {})
current_cfg["ai_enabled"] = body.ai_enabled
current_cfg["ai_endpoint"] = body.ai_endpoint
current_cfg["ai_deployment"] = body.ai_deployment
current_cfg["ai_api_version"] = body.ai_api_version
current_cfg["ai_max_tokens"] = body.ai_max_tokens
current_cfg["ai_temperature"] = body.ai_temperature
current_cfg["ai_validation_prompt"] = body.ai_validation_prompt
# Only overwrite the stored key if a new value was actually provided
if body.ai_api_key is not None:
current_cfg["ai_api_key"] = body.ai_api_key
# Use direct SQL UPDATE to reliably persist JSONB mutations
await db.execute(
update(Tenant)
.where(Tenant.id == tenant_id)
.values(tenant_config=current_cfg)
)
await db.commit()
await db.refresh(tenant)
return _tenant_ai_config_out(tenant)
@router.post("/{tenant_id}/ai-config/test")
async def test_tenant_ai_config(
tenant_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
_: object = Depends(require_admin),
):
"""Send a minimal ping to Azure OpenAI using the tenant's stored credentials.
Returns {"ok": true} or {"ok": false, "error": "human readable message"}.
"""
tenant = await service.get_tenant(db, tenant_id)
if not tenant:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
cfg = tenant.tenant_config or {}
api_key = cfg.get("ai_api_key")
endpoint = cfg.get("ai_endpoint")
deployment = cfg.get("ai_deployment", "gpt-4o")
api_version = cfg.get("ai_api_version", "2024-02-01")
if not api_key:
return {"ok": False, "error": "No API key configured for this tenant"}
if not endpoint:
return {"ok": False, "error": "No endpoint configured for this tenant"}
import asyncio
def _ping() -> dict:
try:
from openai import AzureOpenAI
client = AzureOpenAI(
api_key=api_key,
azure_endpoint=endpoint,
api_version=api_version,
)
client.chat.completions.create(
model=deployment,
messages=[{"role": "user", "content": "ping"}],
max_tokens=5,
)
return {"ok": True}
except Exception as exc:
return {"ok": False, "error": str(exc)}
return await asyncio.to_thread(_ping)
+22
View File
@@ -24,3 +24,25 @@ class TenantOut(BaseModel):
created_at: datetime created_at: datetime
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
class TenantAIConfigUpdate(BaseModel):
ai_enabled: bool = False
ai_endpoint: str | None = None
ai_deployment: str = "gpt-4o"
ai_api_version: str = "2024-02-01"
ai_api_key: str | None = None # optional — don't require re-entry
ai_max_tokens: int = 500
ai_temperature: float = 0.1
ai_validation_prompt: str | None = None
class TenantAIConfigOut(BaseModel):
ai_enabled: bool
ai_endpoint: str | None
ai_deployment: str
ai_api_version: str
has_api_key: bool # True if ai_api_key is set — never return the key itself
ai_max_tokens: int
ai_temperature: float
ai_validation_prompt: str | None
+42 -12
View File
@@ -24,10 +24,13 @@ Respond in JSON with exactly these fields:
}""" }"""
def validate_thumbnail(order_item_id: str) -> dict: def validate_thumbnail(order_item_id: str, tenant_config: dict | None = None) -> dict:
""" """
Validate thumbnail orientation using Azure GPT-4o Vision. Validate thumbnail orientation using Azure GPT-4o Vision.
Updates the order_item AI validation fields in DB. Updates the order_item AI validation fields in DB.
If tenant_config is provided and tenant_config["ai_enabled"] is True,
the tenant's own Azure credentials are used instead of global settings.
""" """
from app.config import settings from app.config import settings
from sqlalchemy import create_engine from sqlalchemy import create_engine
@@ -45,7 +48,7 @@ def validate_thumbnail(order_item_id: str) -> dict:
session.commit() session.commit()
try: try:
result = _call_azure_vision(item.thumbnail_path, settings) result = _call_azure_vision(item.thumbnail_path, settings, tenant_config)
item.ai_validation_status = AIValidationStatus.completed item.ai_validation_status = AIValidationStatus.completed
item.ai_validation_result = result item.ai_validation_result = result
except Exception as exc: except Exception as exc:
@@ -58,11 +61,38 @@ def validate_thumbnail(order_item_id: str) -> dict:
return result return result
def _call_azure_vision(thumbnail_path: str | None, settings) -> dict: def _call_azure_vision(
"""Call Azure OpenAI GPT-4o with a base64-encoded thumbnail.""" thumbnail_path: str | None,
settings,
tenant_config: dict | None = None,
) -> dict:
"""Call Azure OpenAI GPT-4o with a base64-encoded thumbnail.
Credential resolution order:
1. tenant_config (if provided and ai_enabled=True)
2. Global settings (azure_openai_* env vars)
"""
import json import json
if not settings.azure_openai_api_key or not settings.azure_openai_endpoint: # Resolve credentials from tenant config or global settings
if tenant_config and tenant_config.get("ai_enabled"):
api_key = tenant_config.get("ai_api_key") or settings.azure_openai_api_key
endpoint = tenant_config.get("ai_endpoint") or settings.azure_openai_endpoint
deployment = tenant_config.get("ai_deployment") or settings.azure_openai_deployment
api_version = tenant_config.get("ai_api_version") or settings.azure_openai_api_version
max_tokens = int(tenant_config.get("ai_max_tokens", 500))
temperature = float(tenant_config.get("ai_temperature", 0.1))
prompt = tenant_config.get("ai_validation_prompt") or VALIDATION_PROMPT
else:
api_key = settings.azure_openai_api_key
endpoint = settings.azure_openai_endpoint
deployment = settings.azure_openai_deployment
api_version = settings.azure_openai_api_version
max_tokens = 500
temperature = 0.1
prompt = VALIDATION_PROMPT
if not api_key or not endpoint:
raise ValueError("Azure OpenAI credentials not configured") raise ValueError("Azure OpenAI credentials not configured")
if not thumbnail_path or not Path(thumbnail_path).exists(): if not thumbnail_path or not Path(thumbnail_path).exists():
@@ -72,21 +102,21 @@ def _call_azure_vision(thumbnail_path: str | None, settings) -> dict:
from openai import AzureOpenAI from openai import AzureOpenAI
client = AzureOpenAI( client = AzureOpenAI(
api_key=settings.azure_openai_api_key, api_key=api_key,
azure_endpoint=settings.azure_openai_endpoint, azure_endpoint=endpoint,
api_version=settings.azure_openai_api_version, api_version=api_version,
) )
with open(thumbnail_path, "rb") as f: with open(thumbnail_path, "rb") as f:
image_b64 = base64.b64encode(f.read()).decode("utf-8") image_b64 = base64.b64encode(f.read()).decode("utf-8")
response = client.chat.completions.create( response = client.chat.completions.create(
model=settings.azure_openai_deployment, model=deployment,
messages=[ messages=[
{ {
"role": "user", "role": "user",
"content": [ "content": [
{"type": "text", "text": VALIDATION_PROMPT}, {"type": "text", "text": prompt},
{ {
"type": "image_url", "type": "image_url",
"image_url": {"url": f"data:image/png;base64,{image_b64}"}, "image_url": {"url": f"data:image/png;base64,{image_b64}"},
@@ -94,8 +124,8 @@ def _call_azure_vision(thumbnail_path: str | None, settings) -> dict:
], ],
} }
], ],
max_tokens=500, max_tokens=max_tokens,
temperature=0.1, temperature=temperature,
) )
content = response.choices[0].message.content or "" content = response.choices[0].message.content or ""
+25 -2
View File
@@ -7,11 +7,34 @@ logger = logging.getLogger(__name__)
@celery_app.task(bind=True, name="app.tasks.ai_tasks.validate_item", queue="ai_validation") @celery_app.task(bind=True, name="app.tasks.ai_tasks.validate_item", queue="ai_validation")
def validate_item(self, order_item_id: str): def validate_item(self, order_item_id: str):
"""Validate orientation of a rendered thumbnail via Azure GPT-4o Vision.""" """Validate orientation of a rendered thumbnail via Azure GPT-4o Vision.
Loads the order item's tenant config and passes it to validate_thumbnail()
so that per-tenant Azure credentials are used when configured.
"""
logger.info(f"AI validation for item: {order_item_id}") logger.info(f"AI validation for item: {order_item_id}")
try: try:
from app.config import settings
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from app.models.order_item import OrderItem
from app.services.azure_ai import validate_thumbnail from app.services.azure_ai import validate_thumbnail
validate_thumbnail(order_item_id)
# Load tenant config for this order item
tenant_config: dict | None = None
try:
engine = create_engine(settings.database_url_sync)
with Session(engine) as session:
item = session.get(OrderItem, __import__("uuid").UUID(order_item_id))
if item and hasattr(item, "order") and item.order and item.order.tenant_id:
from app.domains.tenants.models import Tenant
tenant = session.get(Tenant, item.order.tenant_id)
if tenant:
tenant_config = tenant.tenant_config or {}
except Exception as exc:
logger.warning(f"Could not load tenant config for {order_item_id}: {exc}")
validate_thumbnail(order_item_id, tenant_config=tenant_config)
except Exception as exc: except Exception as exc:
logger.error(f"AI validation failed for {order_item_id}: {exc}") logger.error(f"AI validation failed for {order_item_id}: {exc}")
raise self.retry(exc=exc, countdown=30, max_retries=3) raise self.retry(exc=exc, countdown=30, max_retries=3)
+44
View File
@@ -21,6 +21,28 @@ export interface TenantUpdate {
is_active?: boolean is_active?: boolean
} }
export interface TenantAIConfig {
ai_enabled: boolean
ai_endpoint: string | null
ai_deployment: string
ai_api_version: string
has_api_key: boolean
ai_max_tokens: number
ai_temperature: number
ai_validation_prompt: string | null
}
export interface TenantAIConfigUpdate {
ai_enabled: boolean
ai_endpoint?: string | null
ai_deployment?: string
ai_api_version?: string
ai_api_key?: string | null
ai_max_tokens?: number
ai_temperature?: number
ai_validation_prompt?: string | null
}
export async function getTenants(): Promise<Tenant[]> { export async function getTenants(): Promise<Tenant[]> {
const res = await api.get<Tenant[]>('/tenants/') const res = await api.get<Tenant[]>('/tenants/')
return res.data return res.data
@@ -44,3 +66,25 @@ export async function updateTenant(id: string, data: TenantUpdate): Promise<Tena
export async function deleteTenant(id: string): Promise<void> { export async function deleteTenant(id: string): Promise<void> {
await api.delete(`/tenants/${id}`) await api.delete(`/tenants/${id}`)
} }
export async function getTenantAIConfig(tenantId: string): Promise<TenantAIConfig> {
const res = await api.get<TenantAIConfig>(`/tenants/${tenantId}/ai-config`)
return res.data
}
export async function updateTenantAIConfig(
tenantId: string,
config: TenantAIConfigUpdate,
): Promise<TenantAIConfig> {
const res = await api.put<TenantAIConfig>(`/tenants/${tenantId}/ai-config`, config)
return res.data
}
export async function testTenantAIConfig(
tenantId: string,
): Promise<{ ok: boolean; error?: string }> {
const res = await api.post<{ ok: boolean; error?: string }>(
`/tenants/${tenantId}/ai-config/test`,
)
return res.data
}
+21
View File
@@ -197,3 +197,24 @@ export async function updateWorkerConfig(
const res = await api.put<WorkerConfig>(`/worker/configs/${queueName}`, update) const res = await api.put<WorkerConfig>(`/worker/configs/${queueName}`, update)
return res.data return res.data
} }
// ---------------------------------------------------------------------------
// GPU probe
// ---------------------------------------------------------------------------
export interface GPUProbeResult {
status: 'ok' | 'failed' | 'error' | 'unknown'
device_type?: string | null
error?: string | null
probed_at?: string | null
}
export async function getGpuProbeResult(): Promise<GPUProbeResult> {
const res = await api.get<GPUProbeResult>('/worker/gpu-probe')
return res.data
}
export async function triggerGpuProbe(): Promise<{ task_id: string; queued: boolean }> {
const res = await api.post<{ task_id: string; queued: boolean }>('/worker/gpu-probe')
return res.data
}
+154 -2
View File
@@ -1,7 +1,7 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react' import { useState, useRef } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { UserPlus, Trash2, Pencil, ChevronDown, ChevronUp, ChevronRight, Settings, RefreshCw, CheckCircle2, XCircle, Clock, DollarSign, Layers, AlertTriangle, Upload, FileBox, Plus, X, LayoutDashboard } from 'lucide-react' import { UserPlus, Trash2, Pencil, ChevronDown, ChevronUp, ChevronRight, Settings, RefreshCw, CheckCircle2, XCircle, Clock, DollarSign, Layers, AlertTriangle, Upload, FileBox, Plus, X, LayoutDashboard, Cpu, Zap } from 'lucide-react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import api from '../api/client' import api from '../api/client'
import ConfirmModal from '../components/ConfirmModal' import ConfirmModal from '../components/ConfirmModal'
@@ -20,6 +20,8 @@ import {
import { getTenantDefaultDashboard } from '../api/dashboard' import { getTenantDefaultDashboard } from '../api/dashboard'
import type { WidgetConfig } from '../api/dashboard' import type { WidgetConfig } from '../api/dashboard'
import DashboardCustomizeModal from '../components/dashboard/DashboardCustomizeModal' import DashboardCustomizeModal from '../components/dashboard/DashboardCustomizeModal'
import { getGpuProbeResult, triggerGpuProbe } from '../api/worker'
import type { GPUProbeResult } from '../api/worker'
export default function AdminPage() { export default function AdminPage() {
const qc = useQueryClient() const qc = useQueryClient()
@@ -202,6 +204,67 @@ export default function AdminPage() {
staleTime: 300_000, staleTime: 300_000,
}) })
// GPU Probe
const [gpuProbeExpanded, setGpuProbeExpanded] = useState(false)
const [gpuProbing, setGpuProbing] = useState(false)
const gpuPollRef = useRef<ReturnType<typeof setInterval> | null>(null)
const { data: gpuProbeResult, refetch: refetchGpuProbe } = useQuery<GPUProbeResult>({
queryKey: ['gpu-probe-result'],
queryFn: getGpuProbeResult,
enabled: isAdmin,
refetchInterval: gpuProbing ? 2000 : false,
staleTime: 0,
})
const handleRunGpuCheck = async () => {
if (!isAdmin) return
setGpuProbing(true)
try {
await triggerGpuProbe()
// Poll for up to 45 seconds
let elapsed = 0
const interval = setInterval(async () => {
elapsed += 2
await refetchGpuProbe()
if (elapsed >= 45) {
clearInterval(interval)
setGpuProbing(false)
}
}, 2000)
gpuPollRef.current = interval
} catch {
toast.error('Failed to trigger GPU check')
setGpuProbing(false)
}
}
const gpuStatusBadge = () => {
if (!gpuProbeResult) return null
const s = gpuProbeResult.status
if (s === 'ok') {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-status-success-bg text-status-success-text">
<CheckCircle2 size={11} />
GPU OK{gpuProbeResult.device_type ? ` (${gpuProbeResult.device_type})` : ''}
</span>
)
}
if (s === 'failed') {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200">
<Cpu size={11} />
CPU Fallback
</span>
)
}
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-status-error-bg text-status-error-text">
<XCircle size={11} />
{s === 'error' ? 'Error' : 'Unknown'}
</span>
)
}
return ( return (
<div className="p-8 space-y-8"> <div className="p-8 space-y-8">
<h1 className="text-2xl font-bold text-content">Admin</h1> <h1 className="text-2xl font-bold text-content">Admin</h1>
@@ -1523,6 +1586,95 @@ function AssetLibraryPanel() {
</div> </div>
)} )}
{/* ------------------------------------------------------------------ */}
{/* GPU Status (admin only) */}
{/* ------------------------------------------------------------------ */}
{isAdmin && (
<div className="card">
<button
className="w-full p-4 flex items-center justify-between text-left"
onClick={() => setGpuProbeExpanded((v) => !v)}
>
<div className="flex items-center gap-2">
<Zap size={16} className="text-content-muted" />
<div>
<h2 className="font-semibold text-content">GPU Status</h2>
<p className="text-xs text-content-muted mt-0.5">
Check Blender GPU availability on the render worker
</p>
</div>
</div>
<div className="flex items-center gap-3">
{gpuStatusBadge()}
{gpuProbeResult?.probed_at && (
<span className="text-xs text-content-muted">
Last checked: {Math.round((Date.now() - new Date(gpuProbeResult.probed_at).getTime()) / 60000)} min ago
</span>
)}
{gpuProbeExpanded ? <ChevronUp size={16} className="text-content-muted" /> : <ChevronDown size={16} className="text-content-muted" />}
</div>
</button>
{gpuProbeExpanded && (
<div className="px-6 pb-6 space-y-4 border-t border-border-default pt-4">
<div className="flex items-center gap-3">
<button
onClick={handleRunGpuCheck}
disabled={gpuProbing}
className="flex items-center gap-2 px-4 py-2 text-sm rounded-md bg-accent text-accent-text font-medium hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{gpuProbing
? <RefreshCw size={14} className="animate-spin" />
: <Zap size={14} />
}
{gpuProbing ? 'Checking…' : 'Run GPU Check'}
</button>
{gpuProbing && (
<span className="text-xs text-content-muted">
Polling for result (up to 45s)
</span>
)}
</div>
{gpuProbeResult && (
<div className="rounded-lg border border-border-default bg-surface-alt p-4 space-y-2 text-sm">
<div className="flex items-center gap-2">
<span className="text-content-secondary w-28 shrink-0">Status</span>
{gpuStatusBadge()}
</div>
{gpuProbeResult.device_type && (
<div className="flex items-center gap-2">
<span className="text-content-secondary w-28 shrink-0">Device type</span>
<span className="text-content font-mono text-xs">{gpuProbeResult.device_type}</span>
</div>
)}
{gpuProbeResult.error && (
<div className="flex items-start gap-2">
<span className="text-content-secondary w-28 shrink-0">Error</span>
<span className="text-status-error-text text-xs">{gpuProbeResult.error}</span>
</div>
)}
{gpuProbeResult.probed_at && (
<div className="flex items-center gap-2">
<span className="text-content-secondary w-28 shrink-0">Probed at</span>
<span className="text-content-muted text-xs">
{new Date(gpuProbeResult.probed_at).toLocaleString()}
</span>
</div>
)}
</div>
)}
{!gpuProbeResult && !gpuProbing && (
<p className="text-sm text-content-muted">
No probe result yet. Click "Run GPU Check" to trigger a check on the render worker.
</p>
)}
</div>
)}
</div>
)}
<ConfirmModal <ConfirmModal
open={confirmState.open} open={confirmState.open}
title={confirmState.title} title={confirmState.title}
+329 -1
View File
@@ -3,11 +3,13 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner' import { toast } from 'sonner'
import { import {
Plus, Trash2, Pencil, X, Building2, ChevronDown, Check, Users, Plus, Trash2, Pencil, X, Building2, ChevronDown, Check, Users,
Brain, CheckCircle2, XCircle, Loader2, Eye, EyeOff,
} from 'lucide-react' } from 'lucide-react'
import { import {
getTenants, createTenant, updateTenant, deleteTenant, getTenants, createTenant, updateTenant, deleteTenant,
getTenantAIConfig, updateTenantAIConfig, testTenantAIConfig,
} from '../api/tenants' } from '../api/tenants'
import type { Tenant, TenantCreate, TenantUpdate } from '../api/tenants' import type { Tenant, TenantCreate, TenantUpdate, TenantAIConfig, TenantAIConfigUpdate } from '../api/tenants'
const TENANT_CONTEXT_KEY = 'schaeffler_tenant_id' const TENANT_CONTEXT_KEY = 'schaeffler_tenant_id'
@@ -56,6 +58,105 @@ export default function TenantsPage() {
// --- Delete confirm state --- // --- Delete confirm state ---
const [deletingId, setDeletingId] = useState<string | null>(null) const [deletingId, setDeletingId] = useState<string | null>(null)
// --- AI Config panel state ---
const [aiConfigTenant, setAIConfigTenant] = useState<Tenant | null>(null)
const [aiForm, setAIForm] = useState<TenantAIConfigUpdate>({
ai_enabled: false,
ai_endpoint: null,
ai_deployment: 'gpt-4o',
ai_api_version: '2024-02-01',
ai_api_key: null,
ai_max_tokens: 500,
ai_temperature: 0.1,
ai_validation_prompt: null,
})
const [showApiKey, setShowApiKey] = useState(false)
const [urlPasteValue, setUrlPasteValue] = useState('')
const [testResult, setTestResult] = useState<{ ok: boolean; error?: string } | null>(null)
const [isTesting, setIsTesting] = useState(false)
const { data: aiConfig } = useQuery<TenantAIConfig>({
queryKey: ['tenant-ai-config', aiConfigTenant?.id],
queryFn: () => getTenantAIConfig(aiConfigTenant!.id),
enabled: !!aiConfigTenant,
})
// Sync aiForm when aiConfig loads
useEffect(() => {
if (aiConfig) {
setAIForm({
ai_enabled: aiConfig.ai_enabled,
ai_endpoint: aiConfig.ai_endpoint ?? null,
ai_deployment: aiConfig.ai_deployment,
ai_api_version: aiConfig.ai_api_version,
ai_api_key: null, // never pre-fill — has_api_key tells us if one is set
ai_max_tokens: aiConfig.ai_max_tokens,
ai_temperature: aiConfig.ai_temperature,
ai_validation_prompt: aiConfig.ai_validation_prompt ?? null,
})
}
}, [aiConfig])
const saveAIMut = useMutation({
mutationFn: ({ id, data }: { id: string; data: TenantAIConfigUpdate }) =>
updateTenantAIConfig(id, data),
onSuccess: () => {
toast.success('AI config saved')
qc.invalidateQueries({ queryKey: ['tenant-ai-config', aiConfigTenant?.id] })
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to save AI config'),
})
const handleUrlPaste = (raw: string) => {
setUrlPasteValue(raw)
try {
// e.g. https://myinstance.openai.azure.com/openai/deployments/gpt-4o/chat/completions?api-version=2024-02-01
const url = new URL(raw)
const endpoint = `${url.protocol}//${url.hostname}`
const parts = url.pathname.split('/')
// /openai/deployments/{deployment}/...
const deployIdx = parts.indexOf('deployments')
const deployment = deployIdx >= 0 ? parts[deployIdx + 1] : null
const apiVersion = url.searchParams.get('api-version') ?? null
setAIForm((prev) => ({
...prev,
ai_endpoint: endpoint || prev.ai_endpoint,
...(deployment ? { ai_deployment: deployment } : {}),
...(apiVersion ? { ai_api_version: apiVersion } : {}),
}))
} catch {
// Not a valid URL yet — ignore
}
}
const handleTestConnection = async () => {
if (!aiConfigTenant) return
setIsTesting(true)
setTestResult(null)
try {
const result = await testTenantAIConfig(aiConfigTenant.id)
setTestResult(result)
} catch (e: any) {
setTestResult({ ok: false, error: e.response?.data?.detail || 'Request failed' })
} finally {
setIsTesting(false)
}
}
const openAIConfig = (tenant: Tenant) => {
setAIConfigTenant(tenant)
setTestResult(null)
setShowApiKey(false)
setUrlPasteValue('')
}
const closeAIConfig = () => {
setAIConfigTenant(null)
setTestResult(null)
setShowApiKey(false)
setUrlPasteValue('')
}
const { data: tenants = [], isLoading } = useQuery({ const { data: tenants = [], isLoading } = useQuery({
queryKey: ['tenants'], queryKey: ['tenants'],
queryFn: getTenants, queryFn: getTenants,
@@ -241,6 +342,13 @@ export default function TenantsPage() {
<td className="px-4 py-3 text-content-muted">{formatDate(tenant.created_at)}</td> <td className="px-4 py-3 text-content-muted">{formatDate(tenant.created_at)}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex items-center justify-end gap-1"> <div className="flex items-center justify-end gap-1">
<button
onClick={() => openAIConfig(tenant)}
className="p-1.5 rounded hover:bg-surface-alt text-content-muted hover:text-content transition-colors"
title="Configure Azure AI"
>
<Brain size={15} />
</button>
<button <button
onClick={() => openEdit(tenant)} onClick={() => openEdit(tenant)}
className="p-1.5 rounded hover:bg-surface-alt text-content-muted hover:text-content transition-colors" className="p-1.5 rounded hover:bg-surface-alt text-content-muted hover:text-content transition-colors"
@@ -404,6 +512,226 @@ export default function TenantsPage() {
</div> </div>
)} )}
{/* AI Config Modal */}
{aiConfigTenant && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-surface rounded-xl shadow-xl w-full max-w-lg mx-4 p-6 max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-2">
<Brain size={18} className="text-accent" />
<h2 className="text-lg font-semibold text-content">
Azure AI Config {aiConfigTenant.name}
</h2>
</div>
<button
onClick={closeAIConfig}
className="p-1.5 rounded hover:bg-surface-alt text-content-muted transition-colors"
>
<X size={18} />
</button>
</div>
<div className="space-y-5">
{/* Enable toggle */}
<div className="flex items-center gap-3">
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={aiForm.ai_enabled ?? false}
onChange={(e) => setAIForm((prev) => ({ ...prev, ai_enabled: e.target.checked }))}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-surface-muted rounded-full peer peer-checked:bg-accent transition-colors after:content-[''] after:absolute after:top-0.5 after:left-0.5 after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:after:translate-x-4" />
</label>
<span className="text-sm font-medium text-content-secondary">Azure AI Validation</span>
</div>
{/* URL paste helper */}
<div>
<label className="block text-xs font-semibold text-content-muted uppercase tracking-wider mb-1">
Paste Azure URL (auto-fills fields below)
</label>
<input
type="text"
value={urlPasteValue}
onChange={(e) => handleUrlPaste(e.target.value)}
placeholder="https://myinstance.openai.azure.com/openai/deployments/gpt-4o/chat/completions?api-version=2024-02-01"
className="w-full px-3 py-2 rounded-md border border-border-default bg-surface-alt text-content text-xs font-mono focus:outline-none focus:ring-2 focus:ring-accent/50"
/>
</div>
<div className="border-t border-border-default pt-4 space-y-4">
{/* Endpoint */}
<div>
<label className="block text-sm font-medium text-content-secondary mb-1">
Endpoint
<span className="text-xs font-normal text-content-muted ml-1">
(e.g. https://myinstance.openai.azure.com)
</span>
</label>
<input
type="text"
value={aiForm.ai_endpoint ?? ''}
onChange={(e) => setAIForm((prev) => ({ ...prev, ai_endpoint: e.target.value || null }))}
placeholder="https://myinstance.openai.azure.com"
className="w-full px-3 py-2 rounded-md border border-border-default bg-surface text-content text-sm focus:outline-none focus:ring-2 focus:ring-accent/50"
/>
</div>
{/* Deployment */}
<div>
<label className="block text-sm font-medium text-content-secondary mb-1">
Deployment Name
</label>
<input
type="text"
value={aiForm.ai_deployment ?? 'gpt-4o'}
onChange={(e) => setAIForm((prev) => ({ ...prev, ai_deployment: e.target.value }))}
placeholder="gpt-4o"
className="w-full px-3 py-2 rounded-md border border-border-default bg-surface text-content text-sm focus:outline-none focus:ring-2 focus:ring-accent/50"
/>
</div>
{/* API Version */}
<div>
<label className="block text-sm font-medium text-content-secondary mb-1">
API Version
</label>
<input
type="text"
value={aiForm.ai_api_version ?? '2024-02-01'}
onChange={(e) => setAIForm((prev) => ({ ...prev, ai_api_version: e.target.value }))}
placeholder="2024-02-01"
className="w-full px-3 py-2 rounded-md border border-border-default bg-surface text-content text-sm font-mono focus:outline-none focus:ring-2 focus:ring-accent/50"
/>
</div>
{/* API Key */}
<div>
<label className="block text-sm font-medium text-content-secondary mb-1">
API Key
{aiConfig?.has_api_key && (
<span className="text-xs font-normal text-content-muted ml-2">
(already set enter new value to replace)
</span>
)}
</label>
<div className="relative">
<input
type={showApiKey ? 'text' : 'password'}
value={aiForm.ai_api_key ?? ''}
onChange={(e) => setAIForm((prev) => ({ ...prev, ai_api_key: e.target.value || null }))}
placeholder={aiConfig?.has_api_key ? '●●●●●●●●●●●●' : 'Enter API key'}
className="w-full px-3 py-2 pr-10 rounded-md border border-border-default bg-surface text-content text-sm focus:outline-none focus:ring-2 focus:ring-accent/50"
/>
<button
type="button"
onClick={() => setShowApiKey((v) => !v)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-content-muted hover:text-content transition-colors"
>
{showApiKey ? <EyeOff size={15} /> : <Eye size={15} />}
</button>
</div>
</div>
{/* Advanced: max_tokens and temperature */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-content-secondary mb-1">
Max Tokens
</label>
<input
type="number"
min={1}
max={4096}
value={aiForm.ai_max_tokens ?? 500}
onChange={(e) => setAIForm((prev) => ({ ...prev, ai_max_tokens: Number(e.target.value) }))}
className="w-full px-3 py-2 rounded-md border border-border-default bg-surface text-content text-sm focus:outline-none focus:ring-2 focus:ring-accent/50"
/>
</div>
<div>
<label className="block text-sm font-medium text-content-secondary mb-1">
Temperature
</label>
<input
type="number"
min={0}
max={2}
step={0.1}
value={aiForm.ai_temperature ?? 0.1}
onChange={(e) => setAIForm((prev) => ({ ...prev, ai_temperature: Number(e.target.value) }))}
className="w-full px-3 py-2 rounded-md border border-border-default bg-surface text-content text-sm focus:outline-none focus:ring-2 focus:ring-accent/50"
/>
</div>
</div>
{/* Custom validation prompt */}
<div>
<label className="block text-sm font-medium text-content-secondary mb-1">
Custom Validation Prompt
<span className="text-xs font-normal text-content-muted ml-1">
(leave empty to use default)
</span>
</label>
<textarea
rows={4}
value={aiForm.ai_validation_prompt ?? ''}
onChange={(e) => setAIForm((prev) => ({ ...prev, ai_validation_prompt: e.target.value || null }))}
placeholder="Optional: override the default Schaeffler bearing analysis prompt"
className="w-full px-3 py-2 rounded-md border border-border-default bg-surface text-content text-sm resize-y focus:outline-none focus:ring-2 focus:ring-accent/50"
/>
</div>
</div>
{/* Test connection result */}
{testResult && (
<div className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm ${
testResult.ok
? 'bg-status-success-bg text-status-success-text'
: 'bg-status-error-bg text-status-error-text'
}`}>
{testResult.ok
? <CheckCircle2 size={15} />
: <XCircle size={15} />
}
<span>{testResult.ok ? 'Connected successfully' : (testResult.error ?? 'Connection failed')}</span>
</div>
)}
</div>
<div className="flex items-center justify-between mt-6 pt-4 border-t border-border-default">
<button
onClick={handleTestConnection}
disabled={isTesting || !aiConfig?.has_api_key && !aiForm.ai_api_key}
className="flex items-center gap-2 px-4 py-2 text-sm rounded-md border border-border-default text-content-secondary hover:bg-surface-alt disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isTesting
? <Loader2 size={15} className="animate-spin" />
: <CheckCircle2 size={15} />
}
Test Connection
</button>
<div className="flex gap-2">
<button
onClick={closeAIConfig}
className="px-4 py-2 text-sm rounded-md border border-border-default text-content-secondary hover:bg-surface-alt transition-colors"
>
Cancel
</button>
<button
onClick={() => saveAIMut.mutate({ id: aiConfigTenant.id, data: aiForm })}
disabled={saveAIMut.isPending}
className="px-4 py-2 text-sm rounded-md bg-accent text-accent-text font-medium hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{saveAIMut.isPending ? 'Saving…' : 'Save'}
</button>
</div>
</div>
</div>
</div>
)}
{/* Delete Confirm Modal */} {/* Delete Confirm Modal */}
{deletingId && ( {deletingId && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">