feat(admin-ui): provider detail form redesign

Extract the provider editor into a larger single-column ProviderForm with an
API-key show/hide toggle, an informational Models list (add + query-models
discovery), and inline Edit of existing managed backends (not just
create/delete). Providers.tsx now delegates to ProviderForm.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
whit3rabbit
2026-07-15 19:15:34 -05:00
co-authored by Claude Opus 4.8
parent 553c4d2586
commit 6c9e648eea
2 changed files with 305 additions and 184 deletions
@@ -0,0 +1,283 @@
import { useEffect, useState } from 'react'
import {
useCreateManagedBackend,
useUpdateManagedBackend,
useDiscoverModels,
} from '../../api/queries'
import type { CatalogProvider, ManagedBackend } from '../../api/types'
import { getProviderFields, resolveDiscoveryUrl } from '../../utils/providerFields'
import { AdminButton } from '../../components/shared/Performative'
// A provider is "local" when its default endpoint is a loopback address.
function isLocalProvider(p: CatalogProvider): boolean {
return /localhost|127\.0\.0\.1|0\.0\.0\.0/.test(p.default_base_url ?? '')
}
function errorMessage(err: Error | null, fallback: string): string {
if (!err) return fallback
try {
const parsed = JSON.parse(err.message)
if (parsed && typeof parsed.error === 'string') return parsed.error
} catch {
/* not JSON */
}
return err.message || fallback
}
/**
* Create or edit a managed backend for a given provider. Rendered inside the
* provider detail Modal (large, single column). On edit, non-secret fields are
* pre-seeded and resent on save: ManagedBackendPatch has no null sentinel, so
* omitting a field would silently keep the stale value.
*/
export default function ProviderForm({
provider,
existing,
existingCount,
onDone,
}: {
provider: CatalogProvider
existing?: ManagedBackend | null
existingCount: number
onDone?: () => void
}) {
const isEdit = !!existing
const create = useCreateManagedBackend()
const update = useUpdateManagedBackend()
const discover = useDiscoverModels()
const initialForm = (): Record<string, string> => {
if (existing) {
const seeded: Record<string, string> = { name: existing.name }
for (const k of ['api_base', 'deployment', 'api_version', 'project', 'region'] as const) {
if (existing[k] != null) seeded[k] = String(existing[k])
}
if (existing.rpm != null) seeded.rpm = String(existing.rpm)
if (existing.tpm != null) seeded.tpm = String(existing.tpm)
return seeded
}
const base: Record<string, string> = { name: `${provider.id}-${existingCount + 1}` }
if (isLocalProvider(provider) && provider.default_base_url) {
base.api_base = provider.default_base_url
}
return base
}
const [form, setForm] = useState<Record<string, string>>(initialForm)
const [shown, setShown] = useState<Record<string, boolean>>({})
const [models, setModels] = useState<string[]>([])
const [modelInput, setModelInput] = useState('')
useEffect(() => {
setForm(initialForm())
setModels([])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [provider.id, existing?.name])
const fields = getProviderFields(provider)
function set(name: string, value: string) {
setForm(p => ({ ...p, [name]: value }))
}
function addModel(name: string) {
const m = name.trim()
if (m && !models.includes(m)) setModels(p => [...p, m])
setModelInput('')
}
function queryModels() {
// Local providers relax SSRF (loopback/LAN) based on provider_id.
discover.mutate(
{
source: 'custom',
url: form.api_base || provider.default_base_url,
provider_id: provider.id,
api_key: form.api_key || undefined,
},
{
onSuccess: data => {
setModels(prev => {
const merged = new Set(prev)
for (const m of data.models) merged.add(m.id)
return [...merged]
})
},
},
)
}
function submit() {
const payload = {
api_key: form.api_key || undefined,
api_base: form.api_base ? form.api_base.trim().replace(/\/+$/, '') : undefined,
deployment: form.deployment || undefined,
api_version: form.api_version || undefined,
project: form.project || undefined,
region: form.region || undefined,
aws_access_key_id: form.aws_access_key_id || undefined,
aws_secret_access_key: form.aws_secret_access_key || undefined,
aws_session_token: form.aws_session_token || undefined,
rpm: form.rpm ? Number(form.rpm) : undefined,
tpm: form.tpm ? Number(form.tpm) : undefined,
}
if (isEdit && existing) {
update.mutate({ name: existing.name, data: payload }, { onSuccess: () => onDone?.() })
} else {
create.mutate(
{ name: form.name, provider_id: provider.id, ...payload },
{
onSuccess: () => {
setForm(initialForm())
setModels([])
onDone?.()
},
},
)
}
}
function credSet(fieldName: string): boolean {
if (!existing) return false
if (fieldName === 'api_key') return existing.api_key_set
if (fieldName === 'aws_access_key_id' || fieldName === 'aws_secret_access_key')
return existing.aws_creds_set
return false
}
const pending = create.isPending || update.isPending
const submitError = create.error || update.error
// The name input's `pattern` attr never fires (no <form>/submit event), so
// validate here to match what the backend enforces before sending.
const nameValid = isEdit || /^[a-zA-Z0-9_-]+$/.test(form.name ?? '')
return (
<div className="provider-add-form">
<div className="form-group">
<label className="form-label" htmlFor="pf-name">Name</label>
<input
id="pf-name"
type="text"
value={form.name ?? ''}
disabled={isEdit}
pattern="[a-zA-Z0-9_\-]+"
onChange={e => set('name', e.target.value)}
style={{ width: '100%' }}
/>
{!nameValid && form.name && (
<div className="form-hint" style={{ color: 'var(--danger, #d33)' }}>
Name may contain only letters, numbers, hyphens, and underscores.
</div>
)}
</div>
{fields.map(f => {
const isPassword = f.type === 'password'
const inputType = isPassword && !shown[f.name] ? 'password' : f.type === 'url' ? 'text' : f.type
return (
<div key={f.name} className="form-group">
<label className="form-label" htmlFor={`pf-${f.name}`}>{f.label}</label>
{f.hint && <div className="form-hint">{f.hint}</div>}
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<input
id={`pf-${f.name}`}
type={inputType}
placeholder={credSet(f.name) ? '••••••••' : f.placeholder}
value={form[f.name] ?? ''}
onChange={e => set(f.name, e.target.value)}
style={{ flex: 1 }}
/>
{isPassword && (
<AdminButton
size="sm"
type="button"
onClick={() => setShown(s => ({ ...s, [f.name]: !s[f.name] }))}
>
{shown[f.name] ? 'Hide' : 'Show'}
</AdminButton>
)}
</div>
{f.name === 'api_base' && (() => {
const target = resolveDiscoveryUrl(form.api_base || provider.default_base_url || '')
if (!target) return null
const unsupported = ['vertex_ai', 'gemini_native', 'bedrock_native'].includes(
provider.protocol,
)
return (
<div className="form-hint">
Query models will request: <span className="mono">{target}</span>
{unsupported && ' — model discovery may not work for this provider.'}
</div>
)
})()}
</div>
)
})}
{/* Models (informational): discovered or hand-added names. Not persisted
on the backend today; used to sanity-check connectivity. */}
<div className="form-group">
<label className="form-label" htmlFor="pf-model">Models</label>
<div style={{ display: 'flex', gap: 6 }}>
<input
id="pf-model"
type="text"
placeholder="Enter model name and press Enter to add"
value={modelInput}
onChange={e => setModelInput(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault()
addModel(modelInput)
}
}}
style={{ flex: 1 }}
/>
<AdminButton size="sm" type="button" onClick={() => addModel(modelInput)}>
Add Model
</AdminButton>
</div>
{models.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 8 }}>
{models.map(m => (
<span key={m} className="badge-cap active" style={{ cursor: 'pointer' }}
title="Remove"
onClick={() => setModels(prev => prev.filter(x => x !== m))}>
{m}
</span>
))}
</div>
)}
{discover.isError && (
<div className="inline-error">{errorMessage(discover.error, 'Failed to query models')}</div>
)}
</div>
{submitError && (
<div className="inline-error">{errorMessage(submitError, 'Failed to save backend')}</div>
)}
<div className="provider-add-actions">
<AdminButton
size="sm"
type="button"
onClick={queryModels}
disabled={discover.isPending || (!form.api_base && !provider.default_base_url)}
loading={discover.isPending}
>
Query models
</AdminButton>
<AdminButton
tone="primary"
size="sm"
type="button"
onClick={submit}
disabled={(!isEdit && (!form.name || !nameValid)) || pending}
loading={pending}
>
{isEdit ? 'Save' : 'Create'}
</AdminButton>
</div>
</div>
)
}
@@ -1,42 +1,21 @@
import { useEffect, useMemo, useState } from 'react'
import { useMemo, useState } from 'react'
import {
useCatalogProviders,
useManagedBackends,
useCreateManagedBackend,
useDeleteManagedBackend,
useUptime,
useFavorites,
useToggleFavorite,
useDiscoverModels,
} from '../../api/queries'
import type { CatalogProvider, ManagedBackend } from '../../api/types'
import { getProviderFields, resolveDiscoveryUrl } from '../../utils/providerFields'
import { groupSections } from '../../utils/providerTiers'
// A provider is "local" when its default endpoint is a loopback address.
// Used to pre-fill the endpoint field so local-LLM users don't type it.
function isLocalProvider(p: CatalogProvider): boolean {
return /localhost|127\.0\.0\.1|0\.0\.0\.0/.test(p.default_base_url ?? '')
}
// Backend errors arrive as `{"error":"..."}` JSON in the thrown Error's message.
// Surface the real message instead of a generic banner.
function errorMessage(err: Error | null, fallback: string): string {
if (!err) return fallback
try {
const parsed = JSON.parse(err.message)
if (parsed && typeof parsed.error === 'string') return parsed.error
} catch {
// not JSON; fall through to the raw message
}
return err.message || fallback
}
import AsyncBoundary from '../../components/shared/AsyncBoundary'
import ConfirmDialog from '../../components/shared/ConfirmDialog'
import Modal from '../../components/shared/Modal'
import StatusDot from '../../components/shared/StatusDot'
import ProviderIcon from '../../components/shared/ProviderIcon'
import { AdminButton, AdminSurface } from '../../components/shared/Performative'
import ProviderForm from './ProviderForm'
// ── Provider Tile ──────────────────────────────────────────────────────────────
@@ -99,10 +78,12 @@ function ProviderTile({
function BackendRow({
backend,
healthStatus,
onEdit,
onDelete,
}: {
backend: ManagedBackend
healthStatus?: string
onEdit: () => void
onDelete: () => void
}) {
return (
@@ -116,6 +97,9 @@ function BackendRow({
{backend.api_key_set ? 'key set' : 'no key'}
{backend.rpm != null && <> &middot; RPM {backend.rpm}</>}
</span>
<AdminButton size="sm" onClick={onEdit}>
Edit
</AdminButton>
<AdminButton tone="danger" size="sm" onClick={onDelete}>
Delete
</AdminButton>
@@ -123,165 +107,6 @@ function BackendRow({
)
}
// ── Add Backend Form (inside detail panel) ─────────────────────────────────────
/**
* Component providing fields to configure credentials and create a new managed backend.
*/
function AddBackendForm({
provider,
existingCount,
}: {
provider: CatalogProvider
existingCount: number
}) {
const create = useCreateManagedBackend()
const discover = useDiscoverModels()
// Local providers get their loopback endpoint pre-filled (editable); hosted ones don't,
// so we never send a redundant api_base for them.
const initialForm = (): Record<string, string> => {
const base: Record<string, string> = {
name: `${provider.id}-${existingCount + 1}`,
provider_id: provider.id,
}
if (isLocalProvider(provider) && provider.default_base_url) {
base.api_base = provider.default_base_url
}
return base
}
const [form, setForm] = useState<Record<string, string>>(initialForm)
// Reset form when the provider or count changes
useEffect(() => {
setForm(initialForm())
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [provider.id, existingCount])
function submit() {
create.mutate(
{
name: form.name,
provider_id: provider.id,
api_key: form.api_key || undefined,
api_base: form.api_base ? form.api_base.trim().replace(/\/+$/, '') : undefined,
deployment: form.deployment || undefined,
api_version: form.api_version || undefined,
project: form.project || undefined,
region: form.region || undefined,
aws_access_key_id: form.aws_access_key_id || undefined,
aws_secret_access_key: form.aws_secret_access_key || undefined,
aws_session_token: form.aws_session_token || undefined,
rpm: form.rpm ? Number(form.rpm) : undefined,
tpm: form.tpm ? Number(form.tpm) : undefined,
},
{
// useCreateManagedBackend invalidates ['managed-backends'], so the list refreshes itself.
// Reset the form so a second key can be added without stale field values.
onSuccess: () => setForm(initialForm()),
},
)
}
function queryModels() {
// For local providers the backend relaxes SSRF (loopback/LAN) based on provider_id.
discover.mutate({
source: 'custom',
url: form.api_base || provider.default_base_url,
provider_id: provider.id,
api_key: form.api_key || undefined,
})
}
const fields = getProviderFields(provider)
return (
<div className="provider-add-form">
<div className="form-group">
<label className="form-label" htmlFor="add-backend-name">Name</label>
<input
id="add-backend-name"
name="name"
type="text"
value={form.name}
onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))}
style={{ width: '100%' }}
/>
</div>
{fields.map((f) => (
<div key={f.name} className="form-group">
<label className="form-label" htmlFor={`add-${f.name}`}>{f.label}</label>
{f.hint && <div className="form-hint">{f.hint}</div>}
<input
id={`add-${f.name}`}
name={f.name}
type={f.type}
placeholder={f.placeholder}
value={form[f.name] ?? ''}
onChange={(e) => setForm((p) => ({ ...p, [f.name]: e.target.value }))}
style={{ width: '100%' }}
/>
{f.name === 'api_base' && (() => {
const target = resolveDiscoveryUrl(form.api_base || provider.default_base_url || '')
if (!target) return null
// /v1/models discovery only works for OpenAI-shaped and Anthropic-native providers.
const unsupported = ['vertex_ai', 'gemini_native', 'bedrock_native'].includes(
provider.protocol,
)
return (
<div className="form-hint">
Query models will request: <span className="mono">{target}</span>
{unsupported && ' — model discovery may not work for this provider.'}
</div>
)
})()}
</div>
))}
{discover.isError && (
<div className="inline-error">{errorMessage(discover.error, 'Failed to query models')}</div>
)}
{discover.isSuccess && (
<div className="form-hint">
{discover.data.models.length > 0
? `Found ${discover.data.models.length} model(s): ${discover.data.models
.slice(0, 8)
.map((m) => m.id)
.join(', ')}${discover.data.models.length > 8 ? ', …' : ''}`
: 'No models returned by the server.'}
</div>
)}
{create.isError && (
<div className="inline-error">{errorMessage(create.error, 'Failed to create backend')}</div>
)}
<div className="provider-add-actions">
<AdminButton
size="sm"
onClick={() => setForm(initialForm())}
disabled={create.isPending}
>
Reset
</AdminButton>
<AdminButton
size="sm"
onClick={queryModels}
disabled={discover.isPending || (!form.api_base && !provider.default_base_url)}
loading={discover.isPending}
>
Query models
</AdminButton>
<AdminButton
tone="primary"
size="sm"
onClick={submit}
disabled={!form.name || create.isPending}
loading={create.isPending}
>
Create
</AdminButton>
</div>
</div>
)
}
// ── Provider Detail Panel (lightbox overlay) ───────────────────────────────────
/**
@@ -300,6 +125,7 @@ function ProviderDetailPanel({
onClose: () => void
onDeleteBackend: (b: ManagedBackend) => void
}) {
const [editing, setEditing] = useState<ManagedBackend | null>(null)
const caps = provider.capabilities
const capList: [string, boolean][] = [
['chat', caps.chat_completions],
@@ -311,7 +137,7 @@ function ProviderDetailPanel({
]
return (
<Modal open onClose={onClose} title={`${provider.display_name} (${provider.id})`} size="md">
<Modal open onClose={onClose} title={`${provider.display_name} (${provider.id})`} size="lg">
{/* Capabilities */}
<div className="provider-panel-caps">
{capList.map(([label, active]) => (
@@ -357,13 +183,25 @@ function ProviderDetailPanel({
key={b.id}
backend={b}
healthStatus={healthMap.get(b.name)}
onEdit={() => setEditing(b)}
onDelete={() => onDeleteBackend(b)}
/>
))}
<AddBackendForm
<div className="provider-panel-section-label" style={{ marginTop: 16 }}>
{editing ? `Edit ${editing.name}` : 'Add credentials'}
</div>
<ProviderForm
key={editing?.name ?? 'new'}
provider={provider}
existing={editing}
existingCount={backends.length}
onDone={() => setEditing(null)}
/>
{editing && (
<AdminButton size="sm" onClick={() => setEditing(null)}>
Cancel edit
</AdminButton>
)}
</div>
</Modal>
)