Add operator Integrations page (integration registry console)
The operator surface for the integration registry: manage platform/pooled external-API credentials across every scope and inspect cross-tenant usage (metadata only — secrets are write-only). Talks to arcadia-llm-gateway's /api/v1/integrations* endpoints via a gateway-pointed ArcadiaClient. - gateway.ts: second ArcadiaClient at VITE_LLM_GATEWAY_URL, reusing the arcadia-app JWT (the gateway validates it via the shared Guardian secret; CORS already allows *.sky-ai.com + localhost — no proxy). - lib/arcadia/integrations.ts: operator API client (any-scope create, scope filter, cross-tenant usage). Pure functions over an injected client — extraction-ready to share with arcadia-console. - routes/integrations.tsx: scope filter + per-card scope badge, create platform/pooled credentials, credentials/usage, Test (surfaces the expiry/budget gate), enable toggle, delete. The route/nav/capability wiring (routes.ts, app-shell, capabilities.ts) lands with the in-flight capability framework, not here. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
205
app/lib/arcadia/integrations.ts
Normal file
205
app/lib/arcadia/integrations.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
// Integration-registry API client (operator surface).
|
||||
//
|
||||
// Talks to arcadia-llm-gateway's `/api/v1/integrations*` endpoints — the
|
||||
// platform operator manages pooled/platform credentials at ANY scope and
|
||||
// inspects cross-tenant usage *metadata* (never secrets; reads return
|
||||
// `has_secret`, never a value).
|
||||
//
|
||||
// The tenant mirror (`/api/v1/me/integrations*`, scoped to the caller) lives
|
||||
// in arcadia-console. Pure functions over an injected gateway `ArcadiaClient`
|
||||
// (see `~/lib/gateway`); no app coupling, so this lifts into a shared lib once
|
||||
// both apps exist.
|
||||
|
||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
||||
|
||||
export type Scope = "platform" | "tenant" | "app" | "user" | "agent"
|
||||
export type AuthKind = "bearer_static" | "basic" | "api_key_header" | "oauth2"
|
||||
export type CredentialSource = "byo" | "pooled"
|
||||
export type CredentialStatus = "active" | "expired" | "revoked"
|
||||
|
||||
export interface CostModel {
|
||||
unit?: "call" | "search" | "1k_tokens"
|
||||
price_usd?: string | number
|
||||
currency?: string
|
||||
}
|
||||
|
||||
export interface Constraints {
|
||||
rate_per_min?: number
|
||||
monthly_budget_usd?: string | number
|
||||
monthly_call_cap?: number
|
||||
}
|
||||
|
||||
export interface Credential {
|
||||
id: string
|
||||
integration_id: string
|
||||
secret_name: string
|
||||
auth_kind: AuthKind
|
||||
meta: Record<string, unknown>
|
||||
/** Presence, not value — the secret is write-only. */
|
||||
has_secret: boolean
|
||||
expires_at: string | null
|
||||
last_rotated_at: string | null
|
||||
source: CredentialSource
|
||||
status: CredentialStatus
|
||||
}
|
||||
|
||||
export interface Integration {
|
||||
id: string
|
||||
scope: Scope
|
||||
scope_id: string | null
|
||||
provider: string
|
||||
capability: string | null
|
||||
display_name: string | null
|
||||
description: string | null
|
||||
docs_url: string | null
|
||||
base_url: string | null
|
||||
enabled: boolean
|
||||
cost_model: CostModel
|
||||
constraints: Constraints
|
||||
created_by: string | null
|
||||
credentials: Credential[]
|
||||
}
|
||||
|
||||
export interface UsageEntry {
|
||||
integration_id: string
|
||||
scope: Scope
|
||||
scope_id: string | null
|
||||
provider: string
|
||||
capability: string | null
|
||||
units: number
|
||||
cost_usd: string
|
||||
calls: number
|
||||
}
|
||||
|
||||
export interface IntegrationInput {
|
||||
scope?: Scope
|
||||
scope_id?: string | null
|
||||
provider: string
|
||||
capability?: string | null
|
||||
display_name?: string | null
|
||||
description?: string | null
|
||||
docs_url?: string | null
|
||||
base_url?: string | null
|
||||
enabled?: boolean
|
||||
cost_model?: CostModel
|
||||
constraints?: Constraints
|
||||
}
|
||||
|
||||
export interface CredentialInput {
|
||||
secret_name: string
|
||||
auth_kind: AuthKind
|
||||
/** Plaintext, write-only — sent on create/rotate, never returned. */
|
||||
secret?: string
|
||||
source?: CredentialSource
|
||||
expires_at?: string | null
|
||||
meta?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface TestVerdict {
|
||||
status: string
|
||||
auth_kind?: AuthKind
|
||||
policy?: {
|
||||
within_budget: boolean
|
||||
within_rate: boolean
|
||||
remaining_budget_usd: string | null
|
||||
}
|
||||
}
|
||||
|
||||
export interface ScopeFilter {
|
||||
scope?: Scope
|
||||
scope_id?: string
|
||||
}
|
||||
|
||||
const BASE = "/api/v1/integrations"
|
||||
|
||||
export async function listIntegrations(
|
||||
c: ArcadiaClient,
|
||||
filter: ScopeFilter = {},
|
||||
): Promise<Integration[]> {
|
||||
const res = await c.GET<{ integrations: Integration[] }>(BASE, {
|
||||
params: { scope: filter.scope, scope_id: filter.scope_id },
|
||||
})
|
||||
return res.integrations
|
||||
}
|
||||
|
||||
export async function createIntegration(
|
||||
c: ArcadiaClient,
|
||||
input: IntegrationInput,
|
||||
): Promise<Integration> {
|
||||
const res = await c.POST<{ integration: Integration }>(BASE, { body: input })
|
||||
return res.integration
|
||||
}
|
||||
|
||||
export async function updateIntegration(
|
||||
c: ArcadiaClient,
|
||||
id: string,
|
||||
input: Partial<IntegrationInput>,
|
||||
): Promise<Integration> {
|
||||
const res = await c.PATCH<{ integration: Integration }>(`${BASE}/${id}`, { body: input })
|
||||
return res.integration
|
||||
}
|
||||
|
||||
export async function deleteIntegration(c: ArcadiaClient, id: string): Promise<void> {
|
||||
await c.DELETE(`${BASE}/${id}`)
|
||||
}
|
||||
|
||||
export async function addCredential(
|
||||
c: ArcadiaClient,
|
||||
integrationId: string,
|
||||
input: CredentialInput,
|
||||
): Promise<Credential> {
|
||||
const res = await c.POST<{ credential: Credential }>(`${BASE}/${integrationId}/credentials`, {
|
||||
body: input,
|
||||
})
|
||||
return res.credential
|
||||
}
|
||||
|
||||
export async function updateCredential(
|
||||
c: ArcadiaClient,
|
||||
credentialId: string,
|
||||
input: Partial<CredentialInput>,
|
||||
): Promise<Credential> {
|
||||
const res = await c.PATCH<{ credential: Credential }>(`/api/v1/credentials/${credentialId}`, {
|
||||
body: input,
|
||||
})
|
||||
return res.credential
|
||||
}
|
||||
|
||||
export async function deleteCredential(c: ArcadiaClient, credentialId: string): Promise<void> {
|
||||
await c.DELETE(`/api/v1/credentials/${credentialId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the registry's policy gate against an integration's credential. 200 with
|
||||
* a verdict; 4xx (409 expired / 429 over-budget / 404 no credential) is thrown
|
||||
* by `ArcadiaClient` as an `ArcadiaError` — inspect `err.status`.
|
||||
*/
|
||||
export async function testIntegration(c: ArcadiaClient, id: string): Promise<TestVerdict> {
|
||||
return c.POST<TestVerdict>(`${BASE}/${id}/test`)
|
||||
}
|
||||
|
||||
export async function usageSummary(
|
||||
c: ArcadiaClient,
|
||||
filter: ScopeFilter = {},
|
||||
): Promise<UsageEntry[]> {
|
||||
const res = await c.GET<{ usage: UsageEntry[] }>(`${BASE}/usage`, {
|
||||
params: { scope: filter.scope, scope_id: filter.scope_id },
|
||||
})
|
||||
return res.usage
|
||||
}
|
||||
|
||||
// ── display helpers ────────────────────────────────────────────────────
|
||||
|
||||
export function formatUsd(value: string | number | null | undefined): string {
|
||||
if (value === null || value === undefined) return "—"
|
||||
const n = typeof value === "string" ? Number(value) : value
|
||||
if (Number.isNaN(n)) return "—"
|
||||
return `$${n.toFixed(n < 0.01 && n > 0 ? 4 : 2)}`
|
||||
}
|
||||
|
||||
export function credentialHealth(cred: Credential): "ok" | "expired" | "revoked" | "missing" {
|
||||
if (cred.status === "revoked") return "revoked"
|
||||
if (!cred.has_secret) return "missing"
|
||||
if (cred.expires_at && new Date(cred.expires_at).getTime() < Date.now()) return "expired"
|
||||
return "ok"
|
||||
}
|
||||
38
app/lib/gateway.ts
Normal file
38
app/lib/gateway.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// Arcadia LLM-gateway client.
|
||||
//
|
||||
// The integration registry lives on arcadia-llm-gateway, not arcadia-app, so
|
||||
// it needs its own ArcadiaClient pointed at a different base URL. Everything
|
||||
// else is identical to the arcadia-app client: the same access token (the
|
||||
// gateway validates arcadia-app JWTs via the shared Guardian secret) and the
|
||||
// same 401 cleanup. The gateway's CORS already allows localhost + any
|
||||
// *.sky-ai.com origin, so the browser calls it directly.
|
||||
|
||||
import { createArcadiaClient, type ArcadiaClient } from "@crema/arcadia-client"
|
||||
|
||||
const GATEWAY_URL = import.meta.env.VITE_LLM_GATEWAY_URL ?? "http://localhost:4015"
|
||||
|
||||
const ACCESS_TOKEN_KEY = "arcadia_access_token"
|
||||
const REFRESH_TOKEN_KEY = "arcadia_refresh_token"
|
||||
|
||||
let client: ArcadiaClient | null = null
|
||||
|
||||
export function gatewayClient(): ArcadiaClient {
|
||||
if (!client) {
|
||||
client = createArcadiaClient({
|
||||
baseUrl: GATEWAY_URL,
|
||||
getToken: () =>
|
||||
typeof window === "undefined" ? null : sessionStorage.getItem(ACCESS_TOKEN_KEY),
|
||||
onUnauthorized: () => {
|
||||
if (typeof window !== "undefined") {
|
||||
sessionStorage.removeItem(ACCESS_TOKEN_KEY)
|
||||
sessionStorage.removeItem(REFRESH_TOKEN_KEY)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
export function useGatewayClient(): ArcadiaClient {
|
||||
return gatewayClient()
|
||||
}
|
||||
632
app/routes/integrations.tsx
Normal file
632
app/routes/integrations.tsx
Normal file
@@ -0,0 +1,632 @@
|
||||
// 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-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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user