Disambiguates the Phoenix/auth client lib from lib-arcadia-agents-client. Dir lib-arcadia-client → lib-arcadia-core-client; alias updated in tsconfig paths, vite config, app.css @source, imports, CI and docs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
633 lines
21 KiB
TypeScript
633 lines
21 KiB
TypeScript
// Integrations (operator) — platform/pooled external-API arrangements across
|
||
// every scope, backed by the integration registry on arcadia-llm-gateway
|
||
// (`/api/v1/integrations*`). The operator manages pooled credentials and
|
||
// inspects cross-tenant usage metadata; secrets are write-only.
|
||
|
||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||
import {
|
||
AlertTriangle,
|
||
CheckCircle2,
|
||
FlaskConical,
|
||
KeyRound,
|
||
Pencil,
|
||
Plug,
|
||
Plus,
|
||
Trash2,
|
||
} from "lucide-react"
|
||
import { ArcadiaError } from "@crema/arcadia-core-client"
|
||
|
||
import { AppShell } from "~/components/layout/app-shell"
|
||
import { Badge } from "~/components/ui/badge"
|
||
import { Button } from "~/components/ui/button"
|
||
import {
|
||
Card,
|
||
CardContent,
|
||
CardDescription,
|
||
CardHeader,
|
||
CardTitle,
|
||
} from "~/components/ui/card"
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "~/components/ui/dialog"
|
||
import { Input } from "~/components/ui/input"
|
||
import { Label } from "~/components/ui/label"
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "~/components/ui/select"
|
||
import { Switch } from "~/components/ui/switch"
|
||
import { useGatewayClient } from "~/lib/gateway"
|
||
import {
|
||
addCredential,
|
||
createIntegration,
|
||
credentialHealth,
|
||
deleteIntegration,
|
||
formatUsd,
|
||
listIntegrations,
|
||
testIntegration,
|
||
updateIntegration,
|
||
usageSummary,
|
||
type AuthKind,
|
||
type Integration,
|
||
type Scope,
|
||
type UsageEntry,
|
||
} from "~/lib/arcadia/integrations"
|
||
|
||
const AUTH_KINDS: AuthKind[] = ["bearer_static", "api_key_header", "basic", "oauth2"]
|
||
const SCOPES: Scope[] = ["platform", "tenant", "app", "user", "agent"]
|
||
const SCOPE_FILTERS: Array<Scope | "all"> = ["all", ...SCOPES]
|
||
|
||
type Form = {
|
||
scope: Scope
|
||
scope_id: string
|
||
provider: string
|
||
capability: string
|
||
display_name: string
|
||
unit: string
|
||
price_usd: string
|
||
monthly_budget_usd: string
|
||
secret_name: string
|
||
auth_kind: AuthKind
|
||
secret: string
|
||
pooled: boolean
|
||
}
|
||
|
||
const emptyForm: Form = {
|
||
scope: "platform",
|
||
scope_id: "",
|
||
provider: "",
|
||
capability: "",
|
||
display_name: "",
|
||
unit: "call",
|
||
price_usd: "",
|
||
monthly_budget_usd: "",
|
||
secret_name: "",
|
||
auth_kind: "bearer_static",
|
||
secret: "",
|
||
pooled: true,
|
||
}
|
||
|
||
export default function IntegrationsRoute() {
|
||
const gw = useGatewayClient()
|
||
const [items, setItems] = useState<Integration[]>([])
|
||
const [usage, setUsage] = useState<UsageEntry[]>([])
|
||
const [scopeFilter, setScopeFilter] = useState<Scope | "all">("all")
|
||
const [loading, setLoading] = useState(true)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [editing, setEditing] = useState<Integration | "new" | null>(null)
|
||
const [tests, setTests] = useState<Record<string, { ok: boolean; message: string }>>({})
|
||
|
||
const refresh = useCallback(async () => {
|
||
setError(null)
|
||
const filter = scopeFilter === "all" ? {} : { scope: scopeFilter }
|
||
try {
|
||
const [list, use] = await Promise.all([
|
||
listIntegrations(gw, filter),
|
||
usageSummary(gw, filter).catch(() => [] as UsageEntry[]),
|
||
])
|
||
setItems(list)
|
||
setUsage(use)
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : "Failed to load integrations.")
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}, [gw, scopeFilter])
|
||
|
||
useEffect(() => {
|
||
void refresh()
|
||
}, [refresh])
|
||
|
||
const usageById = useMemo(
|
||
() => new Map(usage.map((u) => [u.integration_id, u] as const)),
|
||
[usage],
|
||
)
|
||
|
||
const runTest = useCallback(
|
||
async (it: Integration) => {
|
||
setTests((t) => ({ ...t, [it.id]: { ok: true, message: "Testing…" } }))
|
||
try {
|
||
const verdict = await testIntegration(gw, it.id)
|
||
const remaining = verdict.policy?.remaining_budget_usd
|
||
setTests((t) => ({
|
||
...t,
|
||
[it.id]: {
|
||
ok: true,
|
||
message:
|
||
verdict.status === "ok"
|
||
? `OK — within budget & rate${remaining ? ` (${formatUsd(remaining)} left)` : ""}`
|
||
: verdict.status,
|
||
},
|
||
}))
|
||
} catch (e) {
|
||
const msg =
|
||
e instanceof ArcadiaError
|
||
? e.status === 409
|
||
? "Credential expired — rotate it"
|
||
: e.status === 429
|
||
? "Over budget / rate limit"
|
||
: e.status === 404
|
||
? "No credential to test"
|
||
: e.message
|
||
: "Test failed"
|
||
setTests((t) => ({ ...t, [it.id]: { ok: false, message: msg } }))
|
||
}
|
||
},
|
||
[gw],
|
||
)
|
||
|
||
const toggleEnabled = useCallback(
|
||
async (it: Integration, enabled: boolean) => {
|
||
setItems((xs) => xs.map((x) => (x.id === it.id ? { ...x, enabled } : x)))
|
||
try {
|
||
await updateIntegration(gw, it.id, { enabled })
|
||
} catch {
|
||
setItems((xs) => xs.map((x) => (x.id === it.id ? { ...x, enabled: !enabled } : x)))
|
||
}
|
||
},
|
||
[gw],
|
||
)
|
||
|
||
const remove = useCallback(
|
||
async (it: Integration) => {
|
||
if (!window.confirm(`Delete ${it.display_name || it.provider} and its credentials?`)) return
|
||
await deleteIntegration(gw, it.id)
|
||
await refresh()
|
||
},
|
||
[gw, refresh],
|
||
)
|
||
|
||
return (
|
||
<AppShell>
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="flex items-center gap-3">
|
||
<div className="flex size-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||
<Plug className="size-5" />
|
||
</div>
|
||
<div>
|
||
<h1 className="text-2xl font-semibold">Integrations</h1>
|
||
<p className="text-sm text-muted-foreground">
|
||
Platform & pooled external-API credentials across every scope.
|
||
Keys are stored encrypted and never shown; usage is metadata only.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Select value={scopeFilter} onValueChange={(v) => setScopeFilter((v as Scope | "all") ?? "all")}>
|
||
<SelectTrigger className="w-36">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{SCOPE_FILTERS.map((s) => (
|
||
<SelectItem key={s} value={s}>
|
||
{s === "all" ? "All scopes" : s}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<Button onClick={() => setEditing("new")}>
|
||
<Plus className="size-4" /> Add integration
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{error ? (
|
||
<Card className="border-destructive/40">
|
||
<CardHeader>
|
||
<CardTitle className="text-destructive">Couldn’t load integrations</CardTitle>
|
||
<CardDescription>{error}</CardDescription>
|
||
</CardHeader>
|
||
</Card>
|
||
) : loading ? (
|
||
<p className="text-sm text-muted-foreground">Loading…</p>
|
||
) : items.length === 0 ? (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>No integrations in this scope</CardTitle>
|
||
<CardDescription>
|
||
Register a platform/pooled arrangement — a shared key the platform
|
||
meters and bills to tenants who opt in.
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<Button onClick={() => setEditing("new")}>
|
||
<Plus className="size-4" /> Add integration
|
||
</Button>
|
||
</CardContent>
|
||
</Card>
|
||
) : (
|
||
<div className="grid gap-4">
|
||
{items.map((it) => {
|
||
const u = usageById.get(it.id)
|
||
const test = tests[it.id]
|
||
return (
|
||
<Card key={it.id}>
|
||
<CardHeader>
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div>
|
||
<CardTitle className="flex items-center gap-2">
|
||
{it.display_name || it.provider}
|
||
<Badge>{it.scope}</Badge>
|
||
{it.scope_id ? (
|
||
<span className="font-mono text-xs text-muted-foreground">
|
||
{it.scope_id}
|
||
</span>
|
||
) : null}
|
||
{it.capability ? (
|
||
<Badge variant="secondary">{it.capability}</Badge>
|
||
) : null}
|
||
</CardTitle>
|
||
<CardDescription>
|
||
{it.provider}
|
||
{it.cost_model?.price_usd
|
||
? ` · ${formatUsd(it.cost_model.price_usd)}/${it.cost_model.unit ?? "call"}`
|
||
: ""}
|
||
{it.constraints?.monthly_budget_usd
|
||
? ` · budget ${formatUsd(it.constraints.monthly_budget_usd)}/mo`
|
||
: ""}
|
||
</CardDescription>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Label htmlFor={`en-${it.id}`} className="text-xs text-muted-foreground">
|
||
{it.enabled ? "Enabled" : "Disabled"}
|
||
</Label>
|
||
<Switch
|
||
id={`en-${it.id}`}
|
||
checked={it.enabled}
|
||
onCheckedChange={(v) => toggleEnabled(it, v)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3">
|
||
<div className="space-y-1">
|
||
{it.credentials.length === 0 ? (
|
||
<p className="text-sm text-muted-foreground">No credential set.</p>
|
||
) : (
|
||
it.credentials.map((cred) => {
|
||
const health = credentialHealth(cred)
|
||
return (
|
||
<div key={cred.id} className="flex items-center gap-2 text-sm">
|
||
<KeyRound className="size-4 text-muted-foreground" />
|
||
<span className="font-mono">{cred.secret_name}</span>
|
||
<Badge variant="outline">{cred.source}</Badge>
|
||
<HealthBadge health={health} />
|
||
{cred.expires_at ? (
|
||
<span className="text-xs text-muted-foreground">
|
||
expires {new Date(cred.expires_at).toLocaleDateString()}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
)
|
||
})
|
||
)}
|
||
</div>
|
||
|
||
<p className="text-sm text-muted-foreground">
|
||
{u ? `${u.calls} calls · ${formatUsd(u.cost_usd)} this month` : "No usage yet"}
|
||
</p>
|
||
|
||
{test ? (
|
||
<p
|
||
className={`text-sm ${test.ok ? "text-emerald-600 dark:text-emerald-400" : "text-destructive"}`}
|
||
>
|
||
{test.message}
|
||
</p>
|
||
) : null}
|
||
|
||
<div className="flex flex-wrap gap-2 pt-1">
|
||
<Button variant="outline" size="sm" onClick={() => runTest(it)}>
|
||
<FlaskConical className="size-4" /> Test
|
||
</Button>
|
||
<Button variant="outline" size="sm" onClick={() => setEditing(it)}>
|
||
<Pencil className="size-4" /> Edit
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="text-destructive"
|
||
onClick={() => remove(it)}
|
||
>
|
||
<Trash2 className="size-4" /> Delete
|
||
</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{editing ? (
|
||
<IntegrationDialog
|
||
mode={editing === "new" ? "new" : "edit"}
|
||
initial={editing === "new" ? null : editing}
|
||
onClose={() => setEditing(null)}
|
||
onSaved={async () => {
|
||
setEditing(null)
|
||
await refresh()
|
||
}}
|
||
/>
|
||
) : null}
|
||
</AppShell>
|
||
)
|
||
}
|
||
|
||
function HealthBadge({ health }: { health: ReturnType<typeof credentialHealth> }) {
|
||
if (health === "ok")
|
||
return (
|
||
<Badge variant="secondary" className="gap-1">
|
||
<CheckCircle2 className="size-3" /> healthy
|
||
</Badge>
|
||
)
|
||
const label = health === "missing" ? "no secret" : health
|
||
return (
|
||
<Badge variant="destructive" className="gap-1">
|
||
<AlertTriangle className="size-3" /> {label}
|
||
</Badge>
|
||
)
|
||
}
|
||
|
||
function IntegrationDialog({
|
||
mode,
|
||
initial,
|
||
onClose,
|
||
onSaved,
|
||
}: {
|
||
mode: "new" | "edit"
|
||
initial: Integration | null
|
||
onClose: () => void
|
||
onSaved: () => void | Promise<void>
|
||
}) {
|
||
const gw = useGatewayClient()
|
||
const [form, setForm] = useState<Form>(() =>
|
||
initial
|
||
? {
|
||
...emptyForm,
|
||
scope: initial.scope,
|
||
scope_id: initial.scope_id ?? "",
|
||
provider: initial.provider,
|
||
capability: initial.capability ?? "",
|
||
display_name: initial.display_name ?? "",
|
||
unit: initial.cost_model?.unit ?? "call",
|
||
price_usd: initial.cost_model?.price_usd?.toString() ?? "",
|
||
monthly_budget_usd: initial.constraints?.monthly_budget_usd?.toString() ?? "",
|
||
}
|
||
: emptyForm,
|
||
)
|
||
const [saving, setSaving] = useState(false)
|
||
const [err, setErr] = useState<string | null>(null)
|
||
const set = (patch: Partial<Form>) => setForm((f) => ({ ...f, ...patch }))
|
||
|
||
const needsScopeId = form.scope !== "platform"
|
||
|
||
const submit = async () => {
|
||
setSaving(true)
|
||
setErr(null)
|
||
try {
|
||
const cost_model = form.price_usd
|
||
? { unit: form.unit as "call" | "search" | "1k_tokens", price_usd: form.price_usd, currency: "USD" }
|
||
: undefined
|
||
const constraints = form.monthly_budget_usd
|
||
? { monthly_budget_usd: form.monthly_budget_usd }
|
||
: undefined
|
||
|
||
if (mode === "edit" && initial) {
|
||
await updateIntegration(gw, initial.id, {
|
||
provider: form.provider.trim(),
|
||
capability: form.capability.trim() || undefined,
|
||
display_name: form.display_name.trim() || undefined,
|
||
cost_model,
|
||
constraints,
|
||
})
|
||
} else {
|
||
const created = await createIntegration(gw, {
|
||
scope: form.scope,
|
||
scope_id: needsScopeId ? form.scope_id.trim() || undefined : undefined,
|
||
provider: form.provider.trim(),
|
||
capability: form.capability.trim() || undefined,
|
||
display_name: form.display_name.trim() || undefined,
|
||
cost_model,
|
||
constraints,
|
||
})
|
||
if (form.secret_name.trim() && form.secret.trim()) {
|
||
await addCredential(gw, created.id, {
|
||
secret_name: form.secret_name.trim(),
|
||
auth_kind: form.auth_kind,
|
||
secret: form.secret,
|
||
source: form.pooled ? "pooled" : "byo",
|
||
})
|
||
}
|
||
}
|
||
await onSaved()
|
||
} catch (e) {
|
||
setErr(e instanceof Error ? e.message : "Save failed.")
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<Dialog open onOpenChange={(o) => (!o ? onClose() : undefined)}>
|
||
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
||
<DialogHeader>
|
||
<DialogTitle>{mode === "new" ? "Add integration" : "Edit integration"}</DialogTitle>
|
||
<DialogDescription>
|
||
Register an external-API arrangement. Platform scope = a pooled key
|
||
the platform meters and bills.
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<div className="grid gap-4 py-2">
|
||
{mode === "new" ? (
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<Field label="Scope">
|
||
<Select value={form.scope} onValueChange={(v) => set({ scope: (v as Scope) ?? "platform" })}>
|
||
<SelectTrigger>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{SCOPES.map((s) => (
|
||
<SelectItem key={s} value={s}>
|
||
{s}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</Field>
|
||
<Field label="Scope ID" hint={needsScopeId ? "tenant/app/user/agent id" : "n/a for platform"}>
|
||
<Input
|
||
value={form.scope_id}
|
||
onChange={(e) => set({ scope_id: e.target.value })}
|
||
disabled={!needsScopeId}
|
||
placeholder={needsScopeId ? "acme" : "—"}
|
||
/>
|
||
</Field>
|
||
</div>
|
||
) : null}
|
||
|
||
<Field label="Provider" hint="e.g. tavily, google_maps, duffel">
|
||
<Input
|
||
value={form.provider}
|
||
onChange={(e) => set({ provider: e.target.value })}
|
||
placeholder="tavily"
|
||
/>
|
||
</Field>
|
||
<Field label="Capability (optional)" hint="e.g. web_search, geocode">
|
||
<Input
|
||
value={form.capability}
|
||
onChange={(e) => set({ capability: e.target.value })}
|
||
placeholder="web_search"
|
||
/>
|
||
</Field>
|
||
<Field label="Display name (optional)">
|
||
<Input
|
||
value={form.display_name}
|
||
onChange={(e) => set({ display_name: e.target.value })}
|
||
/>
|
||
</Field>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<Field label="Price (USD)" hint="per unit, for metering">
|
||
<Input
|
||
inputMode="decimal"
|
||
value={form.price_usd}
|
||
onChange={(e) => set({ price_usd: e.target.value })}
|
||
placeholder="0.01"
|
||
/>
|
||
</Field>
|
||
<Field label="Unit">
|
||
<Select value={form.unit} onValueChange={(v) => set({ unit: v ?? "call" })}>
|
||
<SelectTrigger>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="call">call</SelectItem>
|
||
<SelectItem value="search">search</SelectItem>
|
||
<SelectItem value="1k_tokens">1k_tokens</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</Field>
|
||
</div>
|
||
<Field label="Monthly budget (USD, optional)" hint="resolve is refused past this">
|
||
<Input
|
||
inputMode="decimal"
|
||
value={form.monthly_budget_usd}
|
||
onChange={(e) => set({ monthly_budget_usd: e.target.value })}
|
||
placeholder="500"
|
||
/>
|
||
</Field>
|
||
|
||
{mode === "new" ? (
|
||
<div className="space-y-3 rounded-lg border p-3">
|
||
<p className="text-sm font-medium">Credential (optional)</p>
|
||
<Field label="Secret name" hint="the stable handle tools resolve by">
|
||
<Input
|
||
value={form.secret_name}
|
||
onChange={(e) => set({ secret_name: e.target.value })}
|
||
placeholder="tavily_default"
|
||
/>
|
||
</Field>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<Field label="Auth kind">
|
||
<Select
|
||
value={form.auth_kind}
|
||
onValueChange={(v) => set({ auth_kind: (v as AuthKind) ?? "bearer_static" })}
|
||
>
|
||
<SelectTrigger>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{AUTH_KINDS.map((k) => (
|
||
<SelectItem key={k} value={k}>
|
||
{k}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</Field>
|
||
<Field label="Source">
|
||
<div className="flex h-9 items-center gap-2">
|
||
<Switch
|
||
id="pooled"
|
||
checked={form.pooled}
|
||
onCheckedChange={(v) => set({ pooled: v })}
|
||
/>
|
||
<Label htmlFor="pooled" className="text-sm">
|
||
{form.pooled ? "pooled (billed)" : "BYO key"}
|
||
</Label>
|
||
</div>
|
||
</Field>
|
||
</div>
|
||
<Field label="Secret value" hint="stored encrypted, never shown again">
|
||
<Input
|
||
type="password"
|
||
value={form.secret}
|
||
onChange={(e) => set({ secret: e.target.value })}
|
||
placeholder="sk-…"
|
||
/>
|
||
</Field>
|
||
</div>
|
||
) : null}
|
||
|
||
{err ? <p className="text-sm text-destructive">{err}</p> : null}
|
||
</div>
|
||
|
||
<DialogFooter>
|
||
<Button variant="ghost" onClick={onClose} disabled={saving}>
|
||
Cancel
|
||
</Button>
|
||
<Button onClick={submit} disabled={saving || !form.provider.trim()}>
|
||
{saving ? "Saving…" : mode === "new" ? "Create" : "Save"}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
)
|
||
}
|
||
|
||
function Field({
|
||
label,
|
||
hint,
|
||
children,
|
||
}: {
|
||
label: string
|
||
hint?: string
|
||
children: React.ReactNode
|
||
}) {
|
||
return (
|
||
<div className="grid gap-1.5">
|
||
<Label>{label}</Label>
|
||
{children}
|
||
{hint ? <p className="text-xs text-muted-foreground">{hint}</p> : null}
|
||
</div>
|
||
)
|
||
}
|