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}
|
||||
|
||||
Reference in New Issue
Block a user