22c29d5655
- 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>
771 lines
34 KiB
TypeScript
771 lines
34 KiB
TypeScript
import { useState, useEffect } from 'react'
|
|
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, TenantAIConfig, TenantAIConfigUpdate } from '../api/tenants'
|
|
|
|
const TENANT_CONTEXT_KEY = 'schaeffler_tenant_id'
|
|
|
|
function slugify(name: string): string {
|
|
return name
|
|
.toLowerCase()
|
|
.trim()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-|-$/g, '')
|
|
}
|
|
|
|
function formatDate(iso: string): string {
|
|
return new Date(iso).toLocaleDateString('de-DE', {
|
|
day: '2-digit',
|
|
month: '2-digit',
|
|
year: 'numeric',
|
|
})
|
|
}
|
|
|
|
export default function TenantsPage() {
|
|
const qc = useQueryClient()
|
|
|
|
// --- Tenant selector state (persisted in localStorage) ---
|
|
const [activeTenantId, setActiveTenantId] = useState<string | null>(
|
|
() => localStorage.getItem(TENANT_CONTEXT_KEY),
|
|
)
|
|
const [selectorOpen, setSelectorOpen] = useState(false)
|
|
|
|
useEffect(() => {
|
|
if (activeTenantId) {
|
|
localStorage.setItem(TENANT_CONTEXT_KEY, activeTenantId)
|
|
} else {
|
|
localStorage.removeItem(TENANT_CONTEXT_KEY)
|
|
}
|
|
}, [activeTenantId])
|
|
|
|
// --- Create modal state ---
|
|
const [showCreate, setShowCreate] = useState(false)
|
|
const [createForm, setCreateForm] = useState<TenantCreate>({ name: '', slug: '', is_active: true })
|
|
const [slugEdited, setSlugEdited] = useState(false)
|
|
|
|
// --- Edit modal state ---
|
|
const [editingTenant, setEditingTenant] = useState<Tenant | null>(null)
|
|
const [editForm, setEditForm] = useState<TenantUpdate>({})
|
|
|
|
// --- 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,
|
|
})
|
|
|
|
const activeTenant = tenants.find((t) => t.id === activeTenantId) ?? null
|
|
|
|
const createMut = useMutation({
|
|
mutationFn: (data: TenantCreate) => createTenant(data),
|
|
onSuccess: () => {
|
|
toast.success('Tenant created')
|
|
qc.invalidateQueries({ queryKey: ['tenants'] })
|
|
setShowCreate(false)
|
|
setCreateForm({ name: '', slug: '', is_active: true })
|
|
setSlugEdited(false)
|
|
},
|
|
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to create'),
|
|
})
|
|
|
|
const updateMut = useMutation({
|
|
mutationFn: ({ id, data }: { id: string; data: TenantUpdate }) => updateTenant(id, data),
|
|
onSuccess: () => {
|
|
toast.success('Tenant updated')
|
|
qc.invalidateQueries({ queryKey: ['tenants'] })
|
|
setEditingTenant(null)
|
|
},
|
|
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to update'),
|
|
})
|
|
|
|
const deleteMut = useMutation({
|
|
mutationFn: (id: string) => deleteTenant(id),
|
|
onSuccess: (_data, id) => {
|
|
toast.success('Tenant deleted')
|
|
qc.invalidateQueries({ queryKey: ['tenants'] })
|
|
if (activeTenantId === id) setActiveTenantId(null)
|
|
setDeletingId(null)
|
|
},
|
|
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to delete'),
|
|
})
|
|
|
|
// Auto-generate slug from name unless manually edited
|
|
const handleCreateNameChange = (name: string) => {
|
|
setCreateForm((prev) => ({
|
|
...prev,
|
|
name,
|
|
slug: slugEdited ? prev.slug : slugify(name),
|
|
}))
|
|
}
|
|
|
|
const handleCreateSlugChange = (slug: string) => {
|
|
setSlugEdited(true)
|
|
setCreateForm((prev) => ({ ...prev, slug }))
|
|
}
|
|
|
|
const openEdit = (tenant: Tenant) => {
|
|
setEditingTenant(tenant)
|
|
setEditForm({ name: tenant.name, slug: tenant.slug, is_active: tenant.is_active })
|
|
}
|
|
|
|
return (
|
|
<div className="p-6">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div className="flex items-center gap-3">
|
|
<Building2 size={24} className="text-accent" />
|
|
<div>
|
|
<h1 className="text-xl font-bold text-content">Tenants</h1>
|
|
<p className="text-sm text-content-muted">Manage tenants and select context</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={() => { setShowCreate(true); setSlugEdited(false) }}
|
|
className="flex items-center gap-2 px-4 py-2 bg-accent text-accent-text rounded-md text-sm font-medium hover:bg-accent-hover transition-colors"
|
|
>
|
|
<Plus size={16} />
|
|
New Tenant
|
|
</button>
|
|
</div>
|
|
|
|
{/* Tenant Selector */}
|
|
<div className="mb-6 p-4 bg-surface border border-border-default rounded-lg">
|
|
<p className="text-xs font-semibold text-content-muted uppercase tracking-wider mb-2">
|
|
Admin Cross-Tenant View
|
|
</p>
|
|
<div className="relative inline-block">
|
|
<button
|
|
onClick={() => setSelectorOpen((o) => !o)}
|
|
className="flex items-center gap-2 px-3 py-2 bg-surface-alt border border-border-default rounded-md text-sm text-content hover:bg-surface-hover transition-colors min-w-[220px]"
|
|
>
|
|
<Building2 size={14} className="text-content-muted shrink-0" />
|
|
<span className="flex-1 text-left truncate">
|
|
{activeTenant ? activeTenant.name : 'All Tenants / Admin View'}
|
|
</span>
|
|
<ChevronDown size={14} className="text-content-muted shrink-0" />
|
|
</button>
|
|
{selectorOpen && (
|
|
<div className="absolute z-20 top-full left-0 mt-1 min-w-[220px] bg-surface border border-border-default rounded-md shadow-lg overflow-hidden">
|
|
<button
|
|
onClick={() => { setActiveTenantId(null); setSelectorOpen(false) }}
|
|
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-content hover:bg-surface-hover text-left"
|
|
>
|
|
{activeTenantId === null && <Check size={14} className="text-accent shrink-0" />}
|
|
{activeTenantId !== null && <span className="w-[14px] shrink-0" />}
|
|
<span>All Tenants / Admin View</span>
|
|
</button>
|
|
{tenants.map((t) => (
|
|
<button
|
|
key={t.id}
|
|
onClick={() => { setActiveTenantId(t.id); setSelectorOpen(false) }}
|
|
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-content hover:bg-surface-hover text-left"
|
|
>
|
|
{activeTenantId === t.id && <Check size={14} className="text-accent shrink-0" />}
|
|
{activeTenantId !== t.id && <span className="w-[14px] shrink-0" />}
|
|
<span className="flex-1 truncate">{t.name}</span>
|
|
{!t.is_active && (
|
|
<span className="text-xs px-1.5 py-0.5 rounded bg-surface-muted text-content-muted">inactive</span>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{activeTenant && (
|
|
<p className="mt-2 text-xs text-content-muted">
|
|
API requests are sent with header <code className="font-mono bg-surface-alt px-1 rounded">X-Tenant-ID: {activeTenant.id}</code>.
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Table */}
|
|
<div className="bg-surface border border-border-default rounded-lg overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-border-default bg-surface-alt">
|
|
<th className="px-4 py-3 text-left font-semibold text-content-secondary">Name</th>
|
|
<th className="px-4 py-3 text-left font-semibold text-content-secondary">Slug</th>
|
|
<th className="px-4 py-3 text-left font-semibold text-content-secondary">Status</th>
|
|
<th className="px-4 py-3 text-left font-semibold text-content-secondary">
|
|
<span className="flex items-center gap-1"><Users size={13} /> Users</span>
|
|
</th>
|
|
<th className="px-4 py-3 text-left font-semibold text-content-secondary">Created</th>
|
|
<th className="px-4 py-3" />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{isLoading && (
|
|
<tr>
|
|
<td colSpan={6} className="px-4 py-8 text-center text-content-muted">Loading tenants…</td>
|
|
</tr>
|
|
)}
|
|
{!isLoading && tenants.length === 0 && (
|
|
<tr>
|
|
<td colSpan={6} className="px-4 py-8 text-center text-content-muted">
|
|
No tenants yet.
|
|
</td>
|
|
</tr>
|
|
)}
|
|
{tenants.map((tenant) => (
|
|
<tr key={tenant.id} className="border-b border-border-default last:border-0 hover:bg-surface-hover transition-colors">
|
|
<td className="px-4 py-3 font-medium text-content">
|
|
<div className="flex items-center gap-2">
|
|
{activeTenantId === tenant.id && (
|
|
<span className="w-1.5 h-1.5 rounded-full bg-accent shrink-0" title="Active context" />
|
|
)}
|
|
{tenant.name}
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3 text-content-muted font-mono text-xs">{tenant.slug}</td>
|
|
<td className="px-4 py-3">
|
|
{tenant.is_active ? (
|
|
<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">
|
|
active
|
|
</span>
|
|
) : (
|
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-surface-muted text-content-muted">
|
|
inactive
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3 text-content-muted">
|
|
{tenant.user_count != null ? tenant.user_count : '—'}
|
|
</td>
|
|
<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"
|
|
title="Edit"
|
|
>
|
|
<Pencil size={15} />
|
|
</button>
|
|
<button
|
|
onClick={() => setDeletingId(tenant.id)}
|
|
className="p-1.5 rounded hover:bg-status-error-bg text-content-muted hover:text-status-error-text transition-colors"
|
|
title="Delete"
|
|
>
|
|
<Trash2 size={15} />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Create Modal */}
|
|
{showCreate && (
|
|
<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-md mx-4 p-6">
|
|
<div className="flex items-center justify-between mb-5">
|
|
<h2 className="text-lg font-semibold text-content">New Tenant</h2>
|
|
<button
|
|
onClick={() => setShowCreate(false)}
|
|
className="p-1.5 rounded hover:bg-surface-alt text-content-muted transition-colors"
|
|
>
|
|
<X size={18} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-content-secondary mb-1">Name *</label>
|
|
<input
|
|
type="text"
|
|
value={createForm.name}
|
|
onChange={(e) => handleCreateNameChange(e.target.value)}
|
|
placeholder="e.g. Schaeffler GmbH"
|
|
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">
|
|
Slug *
|
|
<span className="text-xs font-normal text-content-muted ml-1">(URL identifier, auto-generated)</span>
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={createForm.slug}
|
|
onChange={(e) => handleCreateSlugChange(e.target.value)}
|
|
placeholder="e.g. schaeffler-gmbh"
|
|
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>
|
|
|
|
<div className="flex items-center gap-3">
|
|
<label className="relative inline-flex items-center cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={createForm.is_active ?? true}
|
|
onChange={(e) => setCreateForm((prev) => ({ ...prev, is_active: 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 text-content-secondary">Active</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-2 mt-6">
|
|
<button
|
|
onClick={() => setShowCreate(false)}
|
|
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={() => createMut.mutate(createForm)}
|
|
disabled={!createForm.name.trim() || !createForm.slug.trim() || createMut.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"
|
|
>
|
|
{createMut.isPending ? 'Creating…' : 'Create'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Edit Modal */}
|
|
{editingTenant && (
|
|
<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-md mx-4 p-6">
|
|
<div className="flex items-center justify-between mb-5">
|
|
<h2 className="text-lg font-semibold text-content">Edit Tenant</h2>
|
|
<button
|
|
onClick={() => setEditingTenant(null)}
|
|
className="p-1.5 rounded hover:bg-surface-alt text-content-muted transition-colors"
|
|
>
|
|
<X size={18} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-content-secondary mb-1">Name</label>
|
|
<input
|
|
type="text"
|
|
value={editForm.name ?? ''}
|
|
onChange={(e) => setEditForm((prev) => ({ ...prev, name: 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">Slug</label>
|
|
<input
|
|
type="text"
|
|
value={editForm.slug ?? ''}
|
|
onChange={(e) => setEditForm((prev) => ({ ...prev, slug: e.target.value }))}
|
|
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>
|
|
|
|
<div className="flex items-center gap-3">
|
|
<label className="relative inline-flex items-center cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={editForm.is_active ?? true}
|
|
onChange={(e) => setEditForm((prev) => ({ ...prev, is_active: 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 text-content-secondary">Active</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-2 mt-6">
|
|
<button
|
|
onClick={() => setEditingTenant(null)}
|
|
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={() => updateMut.mutate({ id: editingTenant.id, data: editForm })}
|
|
disabled={updateMut.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"
|
|
>
|
|
{updateMut.isPending ? 'Saving…' : 'Save'}
|
|
</button>
|
|
</div>
|
|
</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 */}
|
|
{deletingId && (
|
|
<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-sm mx-4 p-6">
|
|
<div className="flex items-start gap-3 mb-4">
|
|
<div className="w-9 h-9 rounded-full bg-status-error-bg flex items-center justify-center shrink-0">
|
|
<Trash2 size={16} className="text-status-error-text" />
|
|
</div>
|
|
<div>
|
|
<h2 className="text-base font-semibold text-content">Delete tenant?</h2>
|
|
<p className="text-sm text-content-muted mt-1">
|
|
This action cannot be undone.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex justify-end gap-2">
|
|
<button
|
|
onClick={() => setDeletingId(null)}
|
|
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={() => deleteMut.mutate(deletingId)}
|
|
disabled={deleteMut.isPending}
|
|
className="px-4 py-2 text-sm rounded-md bg-status-error-bg text-status-error-text font-medium hover:opacity-80 disabled:opacity-50 disabled:cursor-not-allowed transition-opacity"
|
|
>
|
|
{deleteMut.isPending ? 'Deleting…' : 'Delete'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|