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:
@@ -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])
|
||||||
|
|||||||
@@ -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(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ""
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user