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:
@@ -1,7 +1,7 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useState, useRef } from 'react'
|
||||
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 api from '../api/client'
|
||||
import ConfirmModal from '../components/ConfirmModal'
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
import { getTenantDefaultDashboard } from '../api/dashboard'
|
||||
import type { WidgetConfig } from '../api/dashboard'
|
||||
import DashboardCustomizeModal from '../components/dashboard/DashboardCustomizeModal'
|
||||
import { getGpuProbeResult, triggerGpuProbe } from '../api/worker'
|
||||
import type { GPUProbeResult } from '../api/worker'
|
||||
|
||||
export default function AdminPage() {
|
||||
const qc = useQueryClient()
|
||||
@@ -202,6 +204,67 @@ export default function AdminPage() {
|
||||
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 (
|
||||
<div className="p-8 space-y-8">
|
||||
<h1 className="text-2xl font-bold text-content">Admin</h1>
|
||||
@@ -1523,6 +1586,95 @@ function AssetLibraryPanel() {
|
||||
</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
|
||||
open={confirmState.open}
|
||||
title={confirmState.title}
|
||||
|
||||
@@ -3,11 +3,13 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Plus, Trash2, Pencil, X, Building2, ChevronDown, Check, Users,
|
||||
Brain, CheckCircle2, XCircle, Loader2, Eye, EyeOff,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
getTenants, createTenant, updateTenant, deleteTenant,
|
||||
getTenantAIConfig, updateTenantAIConfig, testTenantAIConfig,
|
||||
} 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'
|
||||
|
||||
@@ -56,6 +58,105 @@ export default function TenantsPage() {
|
||||
// --- Delete confirm state ---
|
||||
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({
|
||||
queryKey: ['tenants'],
|
||||
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">
|
||||
<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
|
||||
onClick={() => openEdit(tenant)}
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* 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 */}
|
||||
{deletingId && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
|
||||
Reference in New Issue
Block a user