2 Commits

Author SHA1 Message Date
jules
7ba415d78e Wire @crema/llm-providers-ui: multi-provider picker + AI persistence
Replaces the single-base-URL LLM settings with the new providers lib
(OpenAI, Anthropic, DeepSeek, Qwen, LM Studio). Settings/LLM hosts the
catalog-aware card; the /ai route builds adapters via buildAdapter()
and resolves API keys from the arcadia vault per-call (direct mode).
Anthropic skips the /v1/models probe (no such endpoint) and uses
catalog defaults; failed probes for keyed providers fall back to the
catalog instead of dropping to mock.

AI conversation now persists across navigation and refresh via a new
crema.ai.live localStorage key (separate from the compact-snapshot
key). useChat hydrates from initialMessages on mount, saves on every
change, and "Clear conversation" wipes both state and storage.

Vite needs explicit resolve.alias for @crema/llm-ui and
@crema/llm-providers-ui — when a sibling lib imports another @crema/*,
tsconfigPaths can't resolve it (the importing file isn't in this
project's tsconfig scope).

Adds docs/LLM_PROXY_CONTRACT.md describing the
POST /api/v1/ai/llm/chat endpoint the backend needs for proxy mode
(keys never leave the server). Direct mode works against today's
arcadia; proxy mode unblocks once that endpoint ships.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 22:50:23 +10:00
jules
a907e25a7c Add Storage, Users, Secrets, Webhooks, Scheduled tasks, Audit log screens
Full management surfaces for the platform-admin tenant, mirroring the
existing Tenants pattern (DataTable + row actions + create/edit dialogs +
ConfirmDialog for destructive ops, all data-action tagged for the
command bus, useRegisterAdminContext publishing for the assistant).

- Storage (/storage): backends + credentials. Write-only secret fields,
  Validate/Activate/Deactivate/Set-default/Mark-degraded/Maintenance.
- Users (/users): tabs for Users, Invitations, Roles. Per-user View
  drawer with profile, role add/remove, API keys (one-time reveal on
  create), usage + quota.
- Secrets (/secrets): /api/v1/admin/secrets — create/rotate/rollback,
  versions dialog, enable/disable, generate-value helper.
- Webhooks (/webhooks): CRUD, pause/resume, regenerate-secret with
  one-time reveal, send test event, deliveries dialog.
- Scheduled tasks (/scheduled-tasks): cron CRUD, run-now trigger,
  enable/disable, expandable run history.
- Audit log (/activity): replaces the empty stub. Filter by severity,
  resource type, date range; click for full JSON detail.

All endpoints are hand-rolled HTTP because most aren't covered by the
generated OpenAPI typed paths yet — switch to arcadia.typed.* when the
backend wires them into OpenApiSpex.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 22:50:09 +10:00
25 changed files with 7878 additions and 246 deletions

View File

@@ -18,6 +18,7 @@
@source "../../lib-feedback-ui/src"; @source "../../lib-feedback-ui/src";
@source "../../lib-auth-ui/src"; @source "../../lib-auth-ui/src";
@source "../../lib-agent-ui/src"; @source "../../lib-agent-ui/src";
@source "../../lib-llm-providers-ui/src";
/* CREMA:SOURCES */ /* CREMA:SOURCES */
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));

View File

@@ -20,6 +20,11 @@ import {
HelpCircle, HelpCircle,
Menu, Menu,
Play, Play,
HardDrive,
Users as UsersIcon,
KeyRound,
Webhook as WebhookIcon,
CalendarClock,
// CREMA:NAV-ICONS // CREMA:NAV-ICONS
} from "lucide-react" } from "lucide-react"
@@ -88,6 +93,11 @@ type NavItem = {
const navItems: NavItem[] = [ const navItems: NavItem[] = [
{ to: "/", icon: LayoutDashboard, label: "Overview", end: true }, { to: "/", icon: LayoutDashboard, label: "Overview", end: true },
{ to: "/tenants", icon: Building2, label: "Tenants" }, { to: "/tenants", icon: Building2, label: "Tenants" },
{ to: "/storage", icon: HardDrive, label: "Storage" },
{ to: "/users", icon: UsersIcon, label: "Users" },
{ to: "/secrets", icon: KeyRound, label: "Secrets" },
{ to: "/webhooks", icon: WebhookIcon, label: "Webhooks" },
{ to: "/scheduled-tasks", icon: CalendarClock, label: "Scheduled" },
{ to: "/activity", icon: Activity, label: "Audit log" }, { to: "/activity", icon: Activity, label: "Audit log" },
{ to: "/ai", icon: Bot, label: "AI" }, { to: "/ai", icon: Bot, label: "AI" },
{ to: "/settings", icon: Settings, label: "Settings" }, { to: "/settings", icon: Settings, label: "Settings" },

View File

@@ -0,0 +1,679 @@
// Per-user drilldown: profile, role assignment, API keys, usage + quota.
// Opened from the Users tab via the row's "View" action.
import { useCallback, useEffect, useState } from "react"
import {
CheckCircle2,
Copy,
KeyRound,
Plus,
RefreshCw,
Shield,
ShieldCheck,
ShieldOff,
Trash2,
X,
} from "lucide-react"
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
import { AlertBanner, ConfirmDialog } from "@crema/feedback-ui"
import { Badge } from "~/components/ui/badge"
import { Button } from "~/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog"
import { Input } from "~/components/ui/input"
import { Label } from "~/components/ui/label"
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "~/components/ui/sheet"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"
import {
createUserApiKey,
listUserApiKeys,
revokeUserApiKey,
type ApiKey,
type ApiKeyCreated,
} from "~/lib/arcadia/api-keys"
import {
getUserQuota,
getUserUsage,
type UserQuota,
type UserUsage,
} from "~/lib/arcadia/user-stats"
import {
assignRole,
removeRole,
type User,
} from "~/lib/arcadia/users"
import type { Role } from "~/lib/arcadia/roles"
interface Props {
user: User | null
roles: Role[]
onClose: () => void
/** Called whenever something changes that the parent may want to re-fetch. */
onChanged: () => Promise<void>
}
export function UserDetailSheet({ user, roles, onClose, onChanged }: Props) {
const open = user !== null
return (
<Sheet open={open} onOpenChange={(o) => !o && onClose()}>
<SheetContent side="right" className="w-full sm:max-w-xl flex flex-col gap-0 p-0">
{user ? (
<UserDetailBody user={user} roles={roles} onChanged={onChanged} onClose={onClose} />
) : null}
</SheetContent>
</Sheet>
)
}
function UserDetailBody({
user,
roles,
onChanged,
onClose,
}: {
user: User
roles: Role[]
onChanged: () => Promise<void>
onClose: () => void
}) {
return (
<>
<SheetHeader className="border-b px-6 py-4">
<SheetTitle className="flex items-center justify-between gap-3">
<span className="flex flex-col">
<span>{user.full_name || user.email}</span>
<span className="text-xs font-normal text-muted-foreground">{user.email}</span>
</span>
<Badge variant={user.email_verified ? "default" : "secondary"}>
{user.email_verified ? "Verified" : "Unverified"}
</Badge>
</SheetTitle>
</SheetHeader>
<div className="flex-1 overflow-y-auto p-6">
<Tabs defaultValue="overview">
<TabsList>
<TabsTrigger value="overview" data-action={`user-${user.id}-detail-overview`}>
Overview
</TabsTrigger>
<TabsTrigger value="roles" data-action={`user-${user.id}-detail-roles`}>
Roles
</TabsTrigger>
<TabsTrigger value="api-keys" data-action={`user-${user.id}-detail-keys`}>
API keys
</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<OverviewPanel user={user} />
</TabsContent>
<TabsContent value="roles">
<RolesPanel user={user} roles={roles} onChanged={onChanged} />
</TabsContent>
<TabsContent value="api-keys">
<ApiKeysPanel user={user} onChanged={onChanged} />
</TabsContent>
</Tabs>
</div>
<div className="border-t px-6 py-3 flex justify-end">
<Button variant="outline" onClick={onClose} data-action={`user-${user.id}-detail-close`}>
Close
</Button>
</div>
</>
)
}
// --- Overview tab ------------------------------------------------------
function OverviewPanel({ user }: { user: User }) {
const arcadia = useArcadiaClient()
const [usage, setUsage] = useState<UserUsage | null>(null)
const [quota, setQuota] = useState<UserQuota | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let mounted = true
setLoading(true)
setError(null)
Promise.all([
getUserUsage(arcadia, user.id).catch((err) => {
throw err
}),
getUserQuota(arcadia, user.id),
])
.then(([u, q]) => {
if (!mounted) return
setUsage(u)
setQuota(q)
})
.catch((err) => {
if (mounted)
setError(err instanceof ArcadiaError ? err.message : "Failed to load stats.")
})
.finally(() => mounted && setLoading(false))
return () => {
mounted = false
}
}, [arcadia, user.id])
return (
<div className="flex flex-col gap-4 pt-4">
<dl className="grid grid-cols-2 gap-3 text-sm">
<Stat label="Status" value={user.status} />
<Stat
label="Email verified"
value={user.email_verified ? "Yes" : "No"}
/>
<Stat
label="Last sign-in"
value={user.last_sign_in_at ? new Date(user.last_sign_in_at).toLocaleString() : "Never"}
/>
<Stat label="Created" value={new Date(user.inserted_at).toLocaleString()} />
<Stat label="Tenant" value={user.tenant_id} mono />
<Stat label="ID" value={user.id} mono />
</dl>
<h3 className="mt-2 text-sm font-semibold">Storage</h3>
{error ? (
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
{error}
</AlertBanner>
) : null}
{loading ? (
<p className="text-sm text-muted-foreground">
<RefreshCw className="mr-1 inline size-3.5 animate-spin" /> Loading
</p>
) : (
<div className="grid grid-cols-2 gap-3 text-sm">
<Stat
label="Storage used"
value={
usage
? formatBytes(usage.storage_used_bytes) +
(quota?.storage_limit_bytes
? ` / ${formatBytes(quota.storage_limit_bytes)}`
: "")
: "—"
}
/>
<Stat
label="Object count"
value={
usage
? `${usage.object_count}` +
(quota?.object_count_limit ? ` / ${quota.object_count_limit}` : "")
: "—"
}
/>
{quota ? (
<>
<Stat
label="Storage usage"
value={
quota.storage_usage_percentage != null
? `${Math.round(quota.storage_usage_percentage)}%`
: "—"
}
/>
<Stat
label="Quota state"
value={quota.quota_exceeded ? "exceeded" : "ok"}
/>
</>
) : (
<Stat label="Quota" value="No quota set" />
)}
</div>
)}
</div>
)
}
function Stat({
label,
value,
mono,
}: {
label: string
value: string
mono?: boolean
}) {
return (
<div className="flex flex-col gap-0.5 rounded-md border bg-card/50 px-3 py-2">
<dt className="text-xs uppercase tracking-wide text-muted-foreground">{label}</dt>
<dd className={mono ? "truncate font-mono text-xs" : "text-sm"}>{value}</dd>
</div>
)
}
function formatBytes(n: number | null | undefined): string {
if (n == null) return "—"
const units = ["B", "KB", "MB", "GB", "TB"]
let i = 0
let v = n
while (v >= 1024 && i < units.length - 1) {
v /= 1024
i++
}
return `${v < 10 ? v.toFixed(1) : Math.round(v)} ${units[i]}`
}
// --- Roles tab ---------------------------------------------------------
function RolesPanel({
user,
roles,
onChanged,
}: {
user: User
roles: Role[]
onChanged: () => Promise<void>
}) {
const arcadia = useArcadiaClient()
const [busy, setBusy] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const assigned = new Set(user.roles.map((r) => r.id))
const toggle = useCallback(
async (role: Role) => {
setError(null)
setBusy(role.id)
try {
if (assigned.has(role.id)) await removeRole(arcadia, user.id, role.id)
else await assignRole(arcadia, user.id, role.id)
await onChanged()
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Role change failed.")
} finally {
setBusy(null)
}
},
[arcadia, assigned, onChanged, user.id],
)
return (
<div className="flex flex-col gap-3 pt-4">
{error ? (
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
{error}
</AlertBanner>
) : null}
{roles.length === 0 ? (
<p className="text-sm text-muted-foreground">No roles defined yet.</p>
) : (
<ul className="flex flex-col divide-y rounded-md border">
{roles.map((r) => {
const has = assigned.has(r.id)
return (
<li
key={r.id}
className="flex items-start justify-between gap-3 px-3 py-2"
>
<div className="flex flex-col">
<span className="flex items-center gap-2 text-sm font-medium">
{has ? (
<ShieldCheck className="size-4 text-emerald-500" />
) : (
<Shield className="size-4 text-muted-foreground" />
)}
{r.name}{" "}
<code className="rounded bg-muted px-1 font-mono text-xs">{r.slug}</code>
</span>
{r.description ? (
<span className="text-xs text-muted-foreground">{r.description}</span>
) : null}
</div>
<Button
variant={has ? "outline" : "default"}
size="sm"
disabled={busy !== null}
onClick={() => toggle(r)}
data-action={`user-${user.id}-role-${r.slug}-${has ? "remove" : "add"}`}
>
{busy === r.id ? (
<RefreshCw className="size-3.5 animate-spin" />
) : has ? (
<ShieldOff className="size-3.5" />
) : (
<Plus className="size-3.5" />
)}
{has ? "Remove" : "Add"}
</Button>
</li>
)
})}
</ul>
)}
</div>
)
}
// --- API keys tab ------------------------------------------------------
function ApiKeysPanel({
user,
onChanged,
}: {
user: User
onChanged: () => Promise<void>
}) {
const arcadia = useArcadiaClient()
const [keys, setKeys] = useState<ApiKey[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [createOpen, setCreateOpen] = useState(false)
const [revealed, setRevealed] = useState<ApiKeyCreated | null>(null)
const [pendingRevoke, setPendingRevoke] = useState<ApiKey | null>(null)
const refresh = useCallback(async () => {
setLoading(true)
setError(null)
try {
setKeys(await listUserApiKeys(arcadia, user.id))
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Failed to load keys.")
} finally {
setLoading(false)
}
}, [arcadia, user.id])
useEffect(() => {
refresh()
}, [refresh])
return (
<div className="flex flex-col gap-3 pt-4">
{error ? (
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
{error}
</AlertBanner>
) : null}
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">
Keys are shown in full <strong>only once</strong>, on creation. Treat them like
passwords.
</p>
<Button
size="sm"
onClick={() => setCreateOpen(true)}
data-action={`user-${user.id}-api-key-create`}
>
<Plus className="size-4" />
New key
</Button>
</div>
{loading ? (
<p className="text-sm text-muted-foreground">
<RefreshCw className="mr-1 inline size-3.5 animate-spin" /> Loading
</p>
) : keys.length === 0 ? (
<p className="rounded-md border bg-muted/30 p-4 text-center text-sm text-muted-foreground">
No API keys yet.
</p>
) : (
<ul className="flex flex-col divide-y rounded-md border">
{keys.map((k) => (
<li key={k.id} className="flex items-start justify-between gap-3 px-3 py-2">
<div className="flex flex-col">
<span className="flex items-center gap-2 font-mono text-xs">
<KeyRound className="size-3.5 text-muted-foreground" />
{k.key_prefix}
{!k.is_active ? (
<Badge variant="secondary" className="ml-1">
revoked
</Badge>
) : null}
</span>
<span className="text-xs text-muted-foreground">
{k.description ?? "(no description)"}
</span>
<span className="text-[11px] text-muted-foreground">
Created {new Date(k.created_at).toLocaleDateString()}
{k.last_used_at
? ` · last used ${new Date(k.last_used_at).toLocaleDateString()}`
: " · never used"}
{k.expires_at
? ` · expires ${new Date(k.expires_at).toLocaleDateString()}`
: ""}
</span>
</div>
{k.is_active ? (
<Button
variant="ghost"
size="sm"
onClick={() => setPendingRevoke(k)}
data-action={`user-${user.id}-api-key-${k.id}-revoke`}
>
<Trash2 className="size-3.5" />
Revoke
</Button>
) : null}
</li>
))}
</ul>
)}
<CreateApiKeyDialog
open={createOpen}
userId={user.id}
onClose={() => setCreateOpen(false)}
onCreated={async (created) => {
setCreateOpen(false)
setRevealed(created)
await refresh()
await onChanged()
}}
/>
<RevealKeyDialog created={revealed} onClose={() => setRevealed(null)} />
<ConfirmDialog
open={pendingRevoke !== null}
onOpenChange={(o) => !o && setPendingRevoke(null)}
title="Revoke API key?"
description={
pendingRevoke
? `Key ${pendingRevoke.key_prefix}… will stop working immediately. This cannot be undone.`
: ""
}
confirmLabel="Revoke"
variant="danger"
onConfirm={async () => {
if (!pendingRevoke) return
try {
await revokeUserApiKey(arcadia, user.id, pendingRevoke.id)
setPendingRevoke(null)
await refresh()
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Revoke failed.")
setPendingRevoke(null)
}
}}
/>
</div>
)
}
function CreateApiKeyDialog({
open,
userId,
onClose,
onCreated,
}: {
open: boolean
userId: string
onClose: () => void
onCreated: (k: ApiKeyCreated) => Promise<void>
}) {
const arcadia = useArcadiaClient()
const [description, setDescription] = useState("")
const [expiresAt, setExpiresAt] = useState("")
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!open) {
setDescription("")
setExpiresAt("")
setError(null)
}
}, [open])
const submit = async () => {
setError(null)
setBusy(true)
try {
const created = await createUserApiKey(arcadia, userId, {
description: description || undefined,
expires_at: expiresAt ? new Date(expiresAt).toISOString() : null,
})
await onCreated(created)
} catch (err) {
setError(
err instanceof ArcadiaError
? err.message
: err instanceof Error
? err.message
: "Create failed.",
)
} finally {
setBusy(false)
}
}
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>New API key</DialogTitle>
<DialogDescription>
The full key value will be shown once after creation. Copy it then; we don't store it
in cleartext.
</DialogDescription>
</DialogHeader>
{error ? (
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
{error}
</AlertBanner>
) : null}
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="apikey-description">Description</Label>
<Input
id="apikey-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="e.g. CI deploy key"
data-action="api-key-form-description"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="apikey-expires">Expires at (optional)</Label>
<Input
id="apikey-expires"
type="datetime-local"
value={expiresAt}
onChange={(e) => setExpiresAt(e.target.value)}
data-action="api-key-form-expires"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={busy}>
Cancel
</Button>
<Button onClick={submit} disabled={busy} data-action="api-key-form-create">
{busy ? <RefreshCw className="size-4 animate-spin" /> : <KeyRound className="size-4" />}
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function RevealKeyDialog({
created,
onClose,
}: {
created: ApiKeyCreated | null
onClose: () => void
}) {
const [copied, setCopied] = useState(false)
useEffect(() => {
if (!created) setCopied(false)
}, [created])
if (!created) return null
const copy = async () => {
try {
await navigator.clipboard.writeText(created.api_key)
setCopied(true)
} catch {
// ignore
}
}
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<CheckCircle2 className="size-5 text-emerald-500" />
API key created
</DialogTitle>
<DialogDescription>
<strong>This is the only time the key will be shown.</strong> Copy and store it now.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2 rounded-md border bg-muted/30 p-3">
<code className="select-all break-all font-mono text-xs">{created.api_key}</code>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
Prefix: <code className="font-mono">{created.key_prefix}</code>
{created.expires_at
? ` · expires ${new Date(created.expires_at).toLocaleString()}`
: " · never expires"}
</span>
<Button size="sm" variant="outline" onClick={copy} data-action="api-key-reveal-copy">
{copied ? <CheckCircle2 className="size-3.5" /> : <Copy className="size-3.5" />}
{copied ? "Copied" : "Copy"}
</Button>
</div>
</div>
<p className="text-xs text-muted-foreground">{created.warning}</p>
<DialogFooter>
<Button onClick={onClose} data-action="api-key-reveal-close">
<X className="size-4" />
Done
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,67 @@
// Arcadia per-user API key helpers (v2 multi-key path).
//
// `POST /api/v1/users/:user_id/api_keys` returns the raw key value exactly
// once — list/show endpoints only return the prefix. Callers must surface
// the value to the user immediately on create.
import type { ArcadiaClient } from "@crema/arcadia-client"
export interface ApiKey {
id: string
key_prefix: string
description: string | null
created_at: string
last_used_at: string | null
expires_at: string | null
revoked_at: string | null
is_active: boolean
}
export interface ApiKeyCreateInput {
description?: string
expires_at?: string | null
}
export interface ApiKeyCreated {
api_key: string
key_id: string
key_prefix: string
user_id: string
description: string | null
created_at: string
expires_at: string | null
warning: string
}
export async function listUserApiKeys(
arcadia: ArcadiaClient,
userId: string,
): Promise<ApiKey[]> {
const res = await arcadia.GET<{ data: ApiKey[] }>(
`/api/v1/users/${userId}/api_keys`,
)
return res.data
}
export async function createUserApiKey(
arcadia: ArcadiaClient,
userId: string,
input: ApiKeyCreateInput,
): Promise<ApiKeyCreated> {
const res = await arcadia.POST<{ data: ApiKeyCreated }>(
`/api/v1/users/${userId}/api_keys`,
{ body: input },
)
return res.data
}
export async function revokeUserApiKey(
arcadia: ArcadiaClient,
userId: string,
keyId: string,
reason?: string,
): Promise<void> {
await arcadia.DELETE(`/api/v1/users/${userId}/api_keys/${keyId}`, {
body: reason ? { reason } : undefined,
})
}

View File

@@ -0,0 +1,76 @@
// Audit log + observability helpers.
// All endpoints are read-only; the backend writes audit events itself.
import type { ArcadiaClient } from "@crema/arcadia-client"
export type AuditSeverity = "info" | "warning" | "error" | "critical" | string
export interface AuditUser {
id: string
email: string
name: string
}
export interface AuditLog {
id: string
tenant_id: string
user_id: string | null
user: AuditUser | null
action: string
resource_type: string
resource_id: string | null
changes: Record<string, unknown> | null
metadata: Record<string, unknown> | null
severity: AuditSeverity
ip_address: string | null
user_agent: string | null
inserted_at: string
}
export interface AuditListParams {
action?: string
resource_type?: string
severity?: AuditSeverity
user_id?: string
from?: string // ISO8601
to?: string
limit?: number
offset?: number
}
export interface AuditStats {
total: number
by_action: Record<string, number>
by_severity: Record<string, number>
by_resource_type: Record<string, number>
[key: string]: unknown
}
export async function listAuditLogs(
arcadia: ArcadiaClient,
params?: AuditListParams,
): Promise<AuditLog[]> {
const res = await arcadia.GET<{ data: AuditLog[] }>(
"/api/v1/observability/audit_logs",
{ params: params as Record<string, string | number | boolean | null | undefined> },
)
return res.data
}
export async function getAuditLog(arcadia: ArcadiaClient, id: string): Promise<AuditLog> {
const res = await arcadia.GET<{ data: AuditLog }>(
`/api/v1/observability/audit_logs/${id}`,
)
return res.data
}
export async function getAuditStats(
arcadia: ArcadiaClient,
params?: { from?: string; to?: string },
): Promise<AuditStats> {
const res = await arcadia.GET<{ data: AuditStats }>(
"/api/v1/observability/audit_stats",
{ params: params as Record<string, string | undefined> },
)
return res.data
}

View File

@@ -0,0 +1,65 @@
// Arcadia invitations API helpers.
// Backed by /api/v1/invitations.
import type { ArcadiaClient } from "@crema/arcadia-client"
export interface InvitationRole {
id: string
name: string
slug: string
}
export interface InvitationInviter {
id: string
email: string
name: string
}
export interface Invitation {
id: string
email: string
role: InvitationRole
invited_by: InvitationInviter | null
expires_at: string | null
accepted_at: string | null
revoked_at: string | null
revocation_reason: string | null
inserted_at: string
}
export type InvitationStatus = "pending" | "accepted" | "revoked" | "expired"
export function invitationStatus(inv: Invitation): InvitationStatus {
if (inv.accepted_at) return "accepted"
if (inv.revoked_at) return "revoked"
if (inv.expires_at && new Date(inv.expires_at).getTime() < Date.now()) return "expired"
return "pending"
}
export interface InvitationInput {
email: string
role_id: string
}
export async function listInvitations(arcadia: ArcadiaClient): Promise<Invitation[]> {
const res = await arcadia.GET<{ data: Invitation[] }>("/api/v1/invitations")
return res.data
}
export async function createInvitation(
arcadia: ArcadiaClient,
input: InvitationInput,
): Promise<Invitation> {
const res = await arcadia.POST<{ data: Invitation }>("/api/v1/invitations", {
body: { invitation: input },
})
return res.data
}
export async function revokeInvitation(arcadia: ArcadiaClient, id: string): Promise<void> {
await arcadia.DELETE(`/api/v1/invitations/${id}`)
}
export async function resendInvitation(arcadia: ArcadiaClient, id: string): Promise<void> {
await arcadia.POST(`/api/v1/invitations/${id}/resend`)
}

55
app/lib/arcadia/roles.ts Normal file
View File

@@ -0,0 +1,55 @@
// Arcadia roles API helpers.
// Backed by /api/v1/roles (resources route, except :new and :edit).
import type { ArcadiaClient } from "@crema/arcadia-client"
export interface Role {
id: string
name: string
slug: string
description: string | null
permissions: string[]
is_system: boolean
metadata: Record<string, unknown>
tenant_id: string
inserted_at: string
updated_at: string
}
export interface RoleInput {
name: string
slug: string
description?: string | null
permissions?: string[]
metadata?: Record<string, unknown>
}
export async function listRoles(arcadia: ArcadiaClient): Promise<Role[]> {
const res = await arcadia.GET<{ data: Role[] }>("/api/v1/roles")
return res.data
}
export async function getRole(arcadia: ArcadiaClient, id: string): Promise<Role> {
const res = await arcadia.GET<{ data: Role }>(`/api/v1/roles/${id}`)
return res.data
}
export async function createRole(arcadia: ArcadiaClient, input: RoleInput): Promise<Role> {
const res = await arcadia.POST<{ data: Role }>("/api/v1/roles", { body: { role: input } })
return res.data
}
export async function updateRole(
arcadia: ArcadiaClient,
id: string,
input: Partial<RoleInput>,
): Promise<Role> {
const res = await arcadia.PATCH<{ data: Role }>(`/api/v1/roles/${id}`, {
body: { role: input },
})
return res.data
}
export async function deleteRole(arcadia: ArcadiaClient, id: string): Promise<void> {
await arcadia.DELETE(`/api/v1/roles/${id}`)
}

View File

@@ -0,0 +1,130 @@
// Scheduled tasks (cron) helpers.
// Backend: /api/v1/admin/scheduled-tasks (CRUD + runs/enable/disable/trigger).
import type { ArcadiaClient } from "@crema/arcadia-client"
export type ScheduledTaskAction = "webhook" | "event"
export interface ScheduledTask {
id: string
tenant_id: string | null
name: string
description: string | null
cron_expression: string
timezone: string
action_type: ScheduledTaskAction
/** Backend-encrypted; rendered as null on read but accepted on writes. */
action_config?: Record<string, unknown> | null
tags: string[]
enabled: boolean
last_run_at: string | null
next_run_at: string | null
max_retries: number
timeout_seconds: number
inserted_at: string
updated_at: string
}
export interface ScheduledTaskInput {
name: string
description?: string | null
cron_expression: string
timezone?: string
action_type: ScheduledTaskAction
action_config: Record<string, unknown>
tags?: string[]
enabled?: boolean
max_retries?: number
timeout_seconds?: number
}
export interface TaskRun {
id: string
task_id: string
status: "pending" | "running" | "succeeded" | "failed" | string
attempt: number
started_at: string | null
finished_at: string | null
response_status: number | null
response_body: string | null
error: string | null
inserted_at: string
}
const BASE = "/api/v1/admin/scheduled-tasks"
export async function listScheduledTasks(arcadia: ArcadiaClient): Promise<ScheduledTask[]> {
const res = await arcadia.GET<{ data: ScheduledTask[] }>(BASE)
return res.data
}
export async function getScheduledTask(
arcadia: ArcadiaClient,
id: string,
): Promise<ScheduledTask> {
const res = await arcadia.GET<{ data: ScheduledTask }>(`${BASE}/${id}`)
return res.data
}
export async function createScheduledTask(
arcadia: ArcadiaClient,
input: ScheduledTaskInput,
): Promise<ScheduledTask> {
const res = await arcadia.POST<{ data: ScheduledTask }>(BASE, {
body: { scheduled_task: input },
})
return res.data
}
export async function updateScheduledTask(
arcadia: ArcadiaClient,
id: string,
input: Partial<ScheduledTaskInput>,
): Promise<ScheduledTask> {
const res = await arcadia.PATCH<{ data: ScheduledTask }>(`${BASE}/${id}`, {
body: { scheduled_task: input },
})
return res.data
}
export async function deleteScheduledTask(
arcadia: ArcadiaClient,
id: string,
): Promise<void> {
await arcadia.DELETE(`${BASE}/${id}`)
}
export async function enableScheduledTask(
arcadia: ArcadiaClient,
id: string,
): Promise<ScheduledTask> {
const res = await arcadia.POST<{ data: ScheduledTask }>(`${BASE}/${id}/enable`)
return res.data
}
export async function disableScheduledTask(
arcadia: ArcadiaClient,
id: string,
): Promise<ScheduledTask> {
const res = await arcadia.POST<{ data: ScheduledTask }>(`${BASE}/${id}/disable`)
return res.data
}
export async function triggerScheduledTask(
arcadia: ArcadiaClient,
id: string,
): Promise<TaskRun> {
const res = await arcadia.POST<{ data: TaskRun }>(`${BASE}/${id}/trigger`)
return res.data
}
export async function listTaskRuns(
arcadia: ArcadiaClient,
id: string,
params?: { limit?: number; offset?: number },
): Promise<TaskRun[]> {
const res = await arcadia.GET<{ data: TaskRun[] }>(`${BASE}/${id}/runs`, {
params: params as Record<string, number | undefined>,
})
return res.data
}

171
app/lib/arcadia/secrets.ts Normal file
View File

@@ -0,0 +1,171 @@
// Arcadia secrets API helpers.
//
// Backed by /api/v1/admin/secrets — the platform Secrets Manager. Values are
// AES-encrypted at rest and never returned by index/show; only metadata is
// exposed by these endpoints. Tenant-side resolution (returning the value)
// goes through a separate runtime endpoint that's not used by the admin UI.
import type { ArcadiaClient } from "@crema/arcadia-client"
export type SecretCategory =
| "api_key"
| "smtp"
| "oauth_token"
| "webhook_secret"
| "generic"
export type SecretEnvironment = "production" | "staging" | "development" | "all"
export interface Secret {
id: string
tenant_id: string | null
name: string
description: string | null
category: SecretCategory
environment: SecretEnvironment
tags: string[]
used_by: string[]
allowed_ips: string[]
read_once: boolean
read_once_consumed: boolean
expires_at: string | null
last_rotated_at: string | null
rotation_interval_days: number | null
rotation_due: boolean
expired: boolean
enabled: boolean
inserted_at: string
updated_at: string
}
export interface SecretVersion {
id: string
secret_id: string
version: number
note: string | null
inserted_by: string | null
inserted_at: string
}
export interface SecretCreateInput {
name: string
value: string
category?: SecretCategory
description?: string | null
environment?: SecretEnvironment
tags?: string[]
used_by?: string[]
allowed_ips?: string[]
read_once?: boolean
expires_at?: string | null
rotation_interval_days?: number | null
}
export type SecretMetaInput = Omit<Partial<SecretCreateInput>, "value" | "name">
export interface RotateInput {
value: string
note?: string
}
export async function listSecrets(arcadia: ArcadiaClient): Promise<Secret[]> {
const res = await arcadia.GET<{ data: Secret[] }>("/api/v1/admin/secrets")
return res.data
}
export async function getSecret(arcadia: ArcadiaClient, id: string): Promise<Secret> {
const res = await arcadia.GET<{ data: Secret }>(`/api/v1/admin/secrets/${id}`)
return res.data
}
export async function createSecret(
arcadia: ArcadiaClient,
input: SecretCreateInput,
): Promise<Secret> {
const res = await arcadia.POST<{ data: Secret }>("/api/v1/admin/secrets", {
body: { secret: input },
})
return res.data
}
export async function updateSecretMeta(
arcadia: ArcadiaClient,
id: string,
input: SecretMetaInput,
): Promise<Secret> {
const res = await arcadia.PATCH<{ data: Secret }>(`/api/v1/admin/secrets/${id}`, {
body: { secret: input },
})
return res.data
}
export async function deleteSecret(arcadia: ArcadiaClient, id: string): Promise<void> {
await arcadia.DELETE(`/api/v1/admin/secrets/${id}`)
}
export async function rotateSecret(
arcadia: ArcadiaClient,
id: string,
input: RotateInput,
): Promise<Secret> {
const res = await arcadia.POST<{ data: Secret }>(`/api/v1/admin/secrets/${id}/rotate`, {
body: { secret: input },
})
return res.data
}
export async function rollbackSecret(
arcadia: ArcadiaClient,
id: string,
version: number,
): Promise<Secret> {
const res = await arcadia.POST<{ data: Secret }>(
`/api/v1/admin/secrets/${id}/rollback/${version}`,
)
return res.data
}
export async function enableSecret(arcadia: ArcadiaClient, id: string): Promise<Secret> {
const res = await arcadia.POST<{ data: Secret }>(`/api/v1/admin/secrets/${id}/enable`)
return res.data
}
export async function disableSecret(arcadia: ArcadiaClient, id: string): Promise<Secret> {
const res = await arcadia.POST<{ data: Secret }>(`/api/v1/admin/secrets/${id}/disable`)
return res.data
}
export async function listSecretVersions(
arcadia: ArcadiaClient,
id: string,
): Promise<SecretVersion[]> {
const res = await arcadia.GET<{ data: SecretVersion[] }>(
`/api/v1/admin/secrets/${id}/versions`,
)
return res.data
}
export async function generateSecretValue(
arcadia: ArcadiaClient,
params?: { length?: number; charset?: string },
): Promise<string> {
const res = await arcadia.GET<{ data: { value: string } }>("/api/v1/admin/secrets/generate", {
params: params as Record<string, string | number | boolean | null | undefined>,
})
return res.data.value
}
export const SECRET_CATEGORIES: { value: SecretCategory; label: string }[] = [
{ value: "api_key", label: "API key" },
{ value: "oauth_token", label: "OAuth token" },
{ value: "smtp", label: "SMTP credentials" },
{ value: "webhook_secret", label: "Webhook secret" },
{ value: "generic", label: "Generic" },
]
export const SECRET_ENVIRONMENTS: { value: SecretEnvironment; label: string }[] = [
{ value: "all", label: "All environments" },
{ value: "production", label: "Production" },
{ value: "staging", label: "Staging" },
{ value: "development", label: "Development" },
]

View File

@@ -0,0 +1,167 @@
// Arcadia storage configs API helpers.
//
// `GET /api/v1/storage_configs` and `POST /api/v1/storage_configs` are the
// only operations with full OpenAPI coverage today. Update/delete and the
// state-transition actions (activate, deactivate, mark-degraded,
// mark-maintenance, set-default, validate) are listed in the spec but their
// operations are still stubbed as `never`, so we hand-roll types and use the
// generic `arcadia.GET<T>` / `arcadia.POST<T>` / etc. — same pattern as
// `tenants.ts`. Switch to `arcadia.typed.*` when the spec gains coverage.
import type { ArcadiaClient } from "@crema/arcadia-client"
export type StorageBackend = "s3" | "local" | "gcs"
export type StorageStatus = "active" | "inactive" | "degraded" | "maintenance"
export interface StorageConfig {
id: string
tenant_id: string
name: string
backend_type: StorageBackend
status: StorageStatus
is_default: boolean
max_file_size_bytes: number | null
allowed_content_types: string[] | null
// Backend-specific fields. Secret fields are returned as "***" by the API.
config: Record<string, unknown>
inserted_at: string
updated_at: string
}
export interface StorageConfigInput {
name: string
backend_type: StorageBackend
config: Record<string, unknown>
is_default?: boolean
max_file_size_bytes?: number | null
allowed_content_types?: string[]
}
export interface StorageStats {
total_objects: number
total_size_bytes: number
by_backend: Record<string, unknown>
by_user: Record<string, unknown>
}
export interface StorageProvidersResponse {
data: Record<StorageBackend, { required_fields: string[]; optional_fields?: string[] }>
}
export async function listStorageConfigs(arcadia: ArcadiaClient): Promise<StorageConfig[]> {
const res = await arcadia.GET<{ data: StorageConfig[] }>("/api/v1/storage_configs")
return res.data
}
export async function getStorageConfig(arcadia: ArcadiaClient, id: string): Promise<StorageConfig> {
const res = await arcadia.GET<{ data: StorageConfig }>(`/api/v1/storage_configs/${id}`)
return res.data
}
export async function createStorageConfig(
arcadia: ArcadiaClient,
input: StorageConfigInput,
): Promise<StorageConfig> {
const res = await arcadia.POST<{ data: StorageConfig }>("/api/v1/storage_configs", {
body: { storage_config: input },
})
return res.data
}
export async function updateStorageConfig(
arcadia: ArcadiaClient,
id: string,
input: Partial<StorageConfigInput>,
): Promise<StorageConfig> {
const res = await arcadia.PATCH<{ data: StorageConfig }>(`/api/v1/storage_configs/${id}`, {
body: { storage_config: input },
})
return res.data
}
export async function deleteStorageConfig(arcadia: ArcadiaClient, id: string): Promise<void> {
await arcadia.DELETE(`/api/v1/storage_configs/${id}`)
}
export async function activateStorageConfig(arcadia: ArcadiaClient, id: string): Promise<StorageConfig> {
const res = await arcadia.POST<{ data: StorageConfig }>(`/api/v1/storage_configs/${id}/activate`)
return res.data
}
export async function deactivateStorageConfig(arcadia: ArcadiaClient, id: string): Promise<StorageConfig> {
const res = await arcadia.POST<{ data: StorageConfig }>(`/api/v1/storage_configs/${id}/deactivate`)
return res.data
}
export async function markStorageConfigDegraded(
arcadia: ArcadiaClient,
id: string,
): Promise<StorageConfig> {
const res = await arcadia.POST<{ data: StorageConfig }>(`/api/v1/storage_configs/${id}/mark-degraded`)
return res.data
}
export async function markStorageConfigMaintenance(
arcadia: ArcadiaClient,
id: string,
): Promise<StorageConfig> {
const res = await arcadia.POST<{ data: StorageConfig }>(
`/api/v1/storage_configs/${id}/mark-maintenance`,
)
return res.data
}
export async function setDefaultStorageConfig(
arcadia: ArcadiaClient,
id: string,
): Promise<StorageConfig> {
const res = await arcadia.POST<{ data: StorageConfig }>(`/api/v1/storage_configs/${id}/set-default`)
return res.data
}
export interface ValidateResult {
ok: boolean
message?: string
details?: unknown
}
export async function validateStorageConfig(
arcadia: ArcadiaClient,
id: string,
): Promise<ValidateResult> {
return arcadia.POST<ValidateResult>(`/api/v1/storage_configs/${id}/validate`)
}
export async function getStorageStats(arcadia: ArcadiaClient): Promise<StorageStats> {
const res = await arcadia.GET<{ data: StorageStats }>("/api/v1/storage_configs/stats")
return res.data
}
// Backend-specific config field schemas. Secret fields appear as "***" on
// reads — the form treats them as write-only and only sends a value when the
// user has typed a fresh one.
export const SECRET_FIELDS: Record<StorageBackend, readonly string[]> = {
s3: ["secret_access_key"],
gcs: ["service_account_json"],
local: [],
}
export const REQUIRED_FIELDS: Record<StorageBackend, readonly string[]> = {
s3: ["bucket", "region", "access_key_id", "secret_access_key"],
gcs: ["bucket", "service_account_json"],
local: ["path"],
}
export const OPTIONAL_FIELDS: Record<StorageBackend, readonly string[]> = {
s3: ["endpoint", "prefix"],
gcs: ["prefix"],
local: [],
}
export function isSecretField(backend: StorageBackend, field: string): boolean {
return SECRET_FIELDS[backend].includes(field)
}
export function isMaskedSecret(value: unknown): boolean {
return typeof value === "string" && value === "***"
}

View File

@@ -0,0 +1,56 @@
// Per-user usage + quota helpers.
import type { ArcadiaClient } from "@crema/arcadia-client"
export interface UserUsage {
storage_used_bytes: number
object_count: number
}
export interface UserQuota {
id: string
tenant_id: string
user_id: string
storage_limit_bytes: number | null
storage_used_bytes: number
object_count_limit: number | null
object_count: number
storage_remaining: number | null
objects_remaining: number | null
storage_usage_percentage: number | null
object_count_usage_percentage: number | null
storage_exceeded: boolean
object_count_exceeded: boolean
quota_exceeded: boolean
metadata: Record<string, unknown>
last_calculated_at: string | null
inserted_at: string
updated_at: string
}
export async function getUserUsage(
arcadia: ArcadiaClient,
userId: string,
): Promise<UserUsage> {
const res = await arcadia.GET<{ data: UserUsage }>(
`/api/v1/users/${userId}/usage`,
)
return res.data
}
export async function getUserQuota(
arcadia: ArcadiaClient,
userId: string,
): Promise<UserQuota | null> {
try {
const res = await arcadia.GET<{ data: UserQuota }>(
`/api/v1/users/${userId}/quota`,
)
return res.data
} catch (err) {
// 404 == no quota set for this user. Treat as null rather than throwing.
const msg = err instanceof Error ? err.message : String(err)
if (/404|not[_ ]found/i.test(msg)) return null
throw err
}
}

111
app/lib/arcadia/users.ts Normal file
View File

@@ -0,0 +1,111 @@
// Arcadia users API helpers.
//
// Backed by /api/v1/users (resources route). The OpenAPI spec doesn't yet
// describe these operations as typed paths, so we hand-roll types and use
// the generic verb methods on the client. Same pattern as tenants.ts.
import type { ArcadiaClient } from "@crema/arcadia-client"
export type UserStatus = "active" | "inactive" | "suspended"
export interface UserRoleSummary {
id: string
slug: string
name: string
permissions: string[]
}
export interface User {
id: string
email: string
first_name: string | null
last_name: string | null
full_name: string
status: UserStatus
email_verified: boolean
email_verified_at: string | null
last_sign_in_at: string | null
tenant_id: string
roles: UserRoleSummary[]
inserted_at: string
updated_at: string
}
export interface UserListParams {
status?: UserStatus
email_verified?: boolean
}
export interface UserInput {
email: string
first_name?: string | null
last_name?: string | null
status?: UserStatus
password?: string
role_ids?: string[]
}
export async function listUsers(
arcadia: ArcadiaClient,
params?: UserListParams,
): Promise<User[]> {
const queryParams = params
? {
status: params.status,
email_verified: params.email_verified == null ? undefined : String(params.email_verified),
}
: undefined
const res = await arcadia.GET<{ data: User[] }>("/api/v1/users", { params: queryParams })
return res.data
}
export async function getUser(arcadia: ArcadiaClient, id: string): Promise<User> {
const res = await arcadia.GET<{ data: User }>(`/api/v1/users/${id}`)
return res.data
}
export async function createUser(arcadia: ArcadiaClient, input: UserInput): Promise<User> {
const res = await arcadia.POST<{ data: User }>("/api/v1/users", { body: { user: input } })
return res.data
}
export async function updateUser(
arcadia: ArcadiaClient,
id: string,
input: Partial<UserInput>,
): Promise<User> {
const res = await arcadia.PATCH<{ data: User }>(`/api/v1/users/${id}`, {
body: { user: input },
})
return res.data
}
export async function deleteUser(arcadia: ArcadiaClient, id: string): Promise<void> {
await arcadia.DELETE(`/api/v1/users/${id}`)
}
export async function assignRole(
arcadia: ArcadiaClient,
userId: string,
roleId: string,
): Promise<User> {
const res = await arcadia.POST<{ data: User }>(`/api/v1/users/${userId}/roles/${roleId}`)
return res.data
}
export async function removeRole(
arcadia: ArcadiaClient,
userId: string,
roleId: string,
): Promise<User> {
const res = await arcadia.DELETE<{ data: User }>(`/api/v1/users/${userId}/roles/${roleId}`)
return res.data
}
export async function setUserStatus(
arcadia: ArcadiaClient,
id: string,
status: UserStatus,
): Promise<User> {
return updateUser(arcadia, id, { status })
}

161
app/lib/arcadia/webhooks.ts Normal file
View File

@@ -0,0 +1,161 @@
// Outbound webhook helpers.
// Backend: /api/v1/webhooks (CRUD + pause/resume/regenerate-secret/deliveries/stats/test).
import type { ArcadiaClient } from "@crema/arcadia-client"
export type WebhookStatus = "active" | "paused" | "disabled"
export type WebhookRetryStrategy = "linear" | "exponential"
export interface Webhook {
id: string
tenant_id: string
url: string
description: string | null
status: WebhookStatus
events: string[]
headers: Record<string, string>
max_retries: number
retry_strategy: WebhookRetryStrategy
last_triggered_at: string | null
success_count: number
failure_count: number
/** Only populated on create / regenerate-secret responses. */
secret?: string | null
inserted_at: string
updated_at: string
}
export interface WebhookInput {
url: string
description?: string | null
events?: string[]
headers?: Record<string, string>
max_retries?: number
retry_strategy?: WebhookRetryStrategy
}
export interface WebhookDelivery {
id: string
webhook_endpoint_id: string
event_type: string
status: "pending" | "delivered" | "failed" | string
attempt: number
request_url: string
request_headers: Record<string, string>
response_status: number | null
response_time_ms: number | null
error_message: string | null
sent_at: string | null
completed_at: string | null
next_retry_at: string | null
inserted_at: string
}
export interface WebhookStats {
success_rate: number
delivery_count: number
failure_count: number
avg_response_time_ms: number
[key: string]: unknown
}
export async function listWebhooks(arcadia: ArcadiaClient): Promise<Webhook[]> {
const res = await arcadia.GET<{ data: Webhook[] }>("/api/v1/webhooks")
return res.data
}
export async function getWebhook(arcadia: ArcadiaClient, id: string): Promise<Webhook> {
const res = await arcadia.GET<{ data: Webhook }>(`/api/v1/webhooks/${id}`)
return res.data
}
export async function createWebhook(
arcadia: ArcadiaClient,
input: WebhookInput,
): Promise<Webhook> {
const res = await arcadia.POST<{ data: Webhook }>("/api/v1/webhooks", {
body: { webhook_endpoint: input },
})
return res.data
}
export async function updateWebhook(
arcadia: ArcadiaClient,
id: string,
input: Partial<WebhookInput>,
): Promise<Webhook> {
const res = await arcadia.PATCH<{ data: Webhook }>(`/api/v1/webhooks/${id}`, {
body: { webhook_endpoint: input },
})
return res.data
}
export async function deleteWebhook(arcadia: ArcadiaClient, id: string): Promise<void> {
await arcadia.DELETE(`/api/v1/webhooks/${id}`)
}
export async function pauseWebhook(arcadia: ArcadiaClient, id: string): Promise<Webhook> {
const res = await arcadia.POST<{ data: Webhook }>(`/api/v1/webhooks/${id}/pause`)
return res.data
}
export async function resumeWebhook(arcadia: ArcadiaClient, id: string): Promise<Webhook> {
const res = await arcadia.POST<{ data: Webhook }>(`/api/v1/webhooks/${id}/resume`)
return res.data
}
export async function regenerateWebhookSecret(
arcadia: ArcadiaClient,
id: string,
): Promise<Webhook> {
const res = await arcadia.POST<{ data: Webhook }>(
`/api/v1/webhooks/${id}/regenerate-secret`,
)
return res.data
}
export async function listWebhookDeliveries(
arcadia: ArcadiaClient,
id: string,
params?: { limit?: number; offset?: number },
): Promise<WebhookDelivery[]> {
const res = await arcadia.GET<{ data: WebhookDelivery[] }>(
`/api/v1/webhooks/${id}/deliveries`,
{ params: params as Record<string, number | undefined> },
)
return res.data
}
export async function getWebhookStats(
arcadia: ArcadiaClient,
id: string,
): Promise<WebhookStats> {
const res = await arcadia.GET<{ data: WebhookStats }>(
`/api/v1/webhooks/${id}/stats`,
)
return res.data
}
export async function testWebhook(
arcadia: ArcadiaClient,
id: string,
): Promise<{ ok: boolean; message?: string; details?: unknown }> {
return arcadia.POST(`/api/v1/webhooks/${id}/test`)
}
// A starter list of platform events. Free-form by design — different deployments
// emit different events. Users can type custom values.
export const COMMON_WEBHOOK_EVENTS = [
"user.created",
"user.updated",
"user.deleted",
"tenant.created",
"tenant.updated",
"object.uploaded",
"object.deleted",
"secret.rotated",
"invitation.sent",
"invitation.accepted",
"scheduled_task.completed",
"scheduled_task.failed",
]

View File

@@ -11,5 +11,10 @@ export default [
route("profile", "routes/profile.tsx"), route("profile", "routes/profile.tsx"),
route("login", "routes/login.tsx"), route("login", "routes/login.tsx"),
route("tenants", "routes/tenants.tsx"), route("tenants", "routes/tenants.tsx"),
route("storage", "routes/storage.tsx"),
route("users", "routes/users.tsx"),
route("secrets", "routes/secrets.tsx"),
route("webhooks", "routes/webhooks.tsx"),
route("scheduled-tasks", "routes/scheduled-tasks.tsx"),
// CREMA:ROUTES // CREMA:ROUTES
] satisfies RouteConfig ] satisfies RouteConfig

View File

@@ -1,6 +1,23 @@
import { Activity } from "lucide-react" import { useCallback, useEffect, useMemo, useState } from "react"
import { Link } from "react-router"
import { Activity, Eye, RefreshCw } from "lucide-react"
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
import {
ActionsCell,
BadgeCell,
DataTable,
DateCell,
Pagination,
useTable,
type BadgeTone,
type Column,
} from "@crema/table-ui"
import { SearchInput } from "@crema/search-ui"
import { AlertBanner, EmptyState, LoadingOverlay } from "@crema/feedback-ui"
import { AppShell } from "~/components/layout/app-shell" import { AppShell } from "~/components/layout/app-shell"
import { Button } from "~/components/ui/button"
import { import {
Card, Card,
CardContent, CardContent,
@@ -8,36 +25,405 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "~/components/ui/card" } from "~/components/ui/card"
import {
Dialog,
DialogContent,
DialogDescription,
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 {
listAuditLogs,
type AuditLog,
type AuditSeverity,
} from "~/lib/arcadia/audit-logs"
import { pageTitle } from "~/lib/page-meta" import { pageTitle } from "~/lib/page-meta"
import { useSession } from "~/lib/session"
import { useRegisterAdminContext } from "~/lib/admin-context"
export const meta = () => pageTitle("Activity") export const meta = () => pageTitle("Audit log")
export default function ActivityRoute() { export default function ActivityRoute() {
const session = useSession()
const arcadia = useArcadiaClient()
const [logs, setLogs] = useState<AuditLog[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [search, setSearch] = useState("")
const [severityFilter, setSeverityFilter] = useState<"all" | AuditSeverity>("all")
const [resourceFilter, setResourceFilter] = useState("")
const [from, setFrom] = useState("")
const [to, setTo] = useState("")
const [detail, setDetail] = useState<AuditLog | null>(null)
const refresh = useCallback(async () => {
setError(null)
setLoading(true)
try {
const list = await listAuditLogs(arcadia, {
severity: severityFilter === "all" ? undefined : severityFilter,
resource_type: resourceFilter || undefined,
from: from ? new Date(from).toISOString() : undefined,
to: to ? new Date(to).toISOString() : undefined,
limit: 200,
})
setLogs(list)
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Failed to load audit logs.")
} finally {
setLoading(false)
}
}, [arcadia, severityFilter, resourceFilter, from, to])
useEffect(() => {
if (session) refresh()
}, [session, refresh])
const columns = useMemo<Column<AuditLog>[]>(
() => [
{
id: "time",
header: "Time",
accessor: "inserted_at",
sortable: true,
cell: (l) => <DateCell value={l.inserted_at} format="datetime" />,
},
{
id: "user",
header: "User",
accessor: (l) => l.user?.email ?? "",
sortable: true,
cell: (l) => (
<span className="text-sm">
{l.user?.email ?? <span className="text-muted-foreground">system</span>}
</span>
),
},
{
id: "action",
header: "Action",
accessor: "action",
sortable: true,
cell: (l) => (
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">{l.action}</code>
),
},
{
id: "resource",
header: "Resource",
accessor: "resource_type",
sortable: true,
cell: (l) => (
<span className="text-sm">
<span className="font-medium">{l.resource_type}</span>
{l.resource_id ? (
<span className="ml-1 font-mono text-xs text-muted-foreground">
{l.resource_id.slice(0, 8)}
</span>
) : null}
</span>
),
},
{
id: "severity",
header: "Severity",
accessor: "severity",
sortable: true,
cell: (l) => <BadgeCell label={l.severity} tone={severityTone(l.severity)} />,
},
{
id: "ip",
header: "IP",
accessor: "ip_address",
cell: (l) => (
<span className="font-mono text-xs text-muted-foreground">
{l.ip_address ?? "—"}
</span>
),
},
{
id: "actions",
header: "",
align: "right",
cell: (l) => (
<ActionsCell
items={[
{
id: "view",
label: "View details",
icon: <Eye className="size-4" />,
dataAction: `audit-${l.id}-view`,
onSelect: () => setDetail(l),
},
]}
triggerDataAction={`audit-${l.id}-actions`}
/>
),
},
],
[],
)
const summary = useMemo(
() => ({
total: logs.length,
bySeverity: countBy(logs, (l) => l.severity || "info"),
byResource: countBy(logs, (l) => l.resource_type),
latest: logs.slice(0, 5).map((l) => ({
time: l.inserted_at,
user: l.user?.email ?? "system",
action: l.action,
resource: `${l.resource_type}${l.resource_id ? `/${l.resource_id}` : ""}`,
})),
}),
[logs],
)
useRegisterAdminContext("audit_log", summary)
const table = useTable<AuditLog>({
data: logs,
columns,
getRowId: (l) => l.id,
initialPageSize: 50,
initialSearch: search,
})
useEffect(() => {
table.setSearch(search)
}, [search, table])
if (!session) {
return (
<AppShell title="Audit log">
<div className="p-8">
<Card className="max-w-md">
<CardHeader>
<CardTitle>Sign in required</CardTitle>
<CardDescription>The audit log requires an admin session.</CardDescription>
</CardHeader>
<CardContent>
<Button asChild>
<Link to="/login?next=/activity">Sign in</Link>
</Button>
</CardContent>
</Card>
</div>
</AppShell>
)
}
return ( return (
<AppShell title="Activity"> <AppShell title="Audit log">
<Card> <div className="flex flex-col gap-4 p-6">
<CardHeader> <header className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<CardTitle>Activity</CardTitle> <div>
<CardDescription> <h1 className="text-2xl font-semibold tracking-tight">Audit log</h1>
Event stream, audit log, recent changes. <p className="text-sm text-muted-foreground">
</CardDescription> Every authenticated action against the platform. Filter by date, severity, or
</CardHeader> resource type.
<CardContent> </p>
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed border-muted-foreground/20 bg-muted/30 p-12 text-center">
<div className="flex size-12 items-center justify-center rounded-xl bg-background text-muted-foreground">
<Activity className="size-6" />
</div>
<div className="max-w-md">
<p className="font-medium">No activity yet</p>
<p className="mt-1 text-sm text-muted-foreground">
Once your app is doing things, this is where audit events,
webhook deliveries, and recent changes show up pair with{" "}
<code className="font-mono text-xs">@crema/log-ui</code>.
</p>
</div>
</div> </div>
</CardContent> <Button
</Card> variant="outline"
size="sm"
onClick={refresh}
disabled={loading}
data-action="audit-refresh"
>
<RefreshCw className={`size-4 ${loading ? "animate-spin" : ""}`} />
Refresh
</Button>
</header>
{error ? (
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
{error}
</AlertBanner>
) : null}
<Card>
<CardHeader className="flex flex-col gap-3 lg:flex-row lg:flex-wrap lg:items-end">
<SearchInput
value={search}
onValueChange={setSearch}
placeholder="Search by action, resource, or user"
data-action="audit-search"
className="max-w-sm flex-1"
/>
<div className="flex flex-col gap-1.5">
<Label htmlFor="audit-severity" className="text-xs">
Severity
</Label>
<Select
value={severityFilter}
onValueChange={(v) => setSeverityFilter(v as typeof severityFilter)}
>
<SelectTrigger id="audit-severity" className="w-36" data-action="audit-severity-filter">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="info">Info</SelectItem>
<SelectItem value="warning">Warning</SelectItem>
<SelectItem value="error">Error</SelectItem>
<SelectItem value="critical">Critical</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="audit-resource" className="text-xs">
Resource type
</Label>
<Input
id="audit-resource"
value={resourceFilter}
onChange={(e) => setResourceFilter(e.target.value)}
placeholder="e.g. user"
className="w-40"
data-action="audit-resource-filter"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="audit-from" className="text-xs">
From
</Label>
<Input
id="audit-from"
type="datetime-local"
value={from}
onChange={(e) => setFrom(e.target.value)}
className="w-44"
data-action="audit-from-filter"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="audit-to" className="text-xs">
To
</Label>
<Input
id="audit-to"
type="datetime-local"
value={to}
onChange={(e) => setTo(e.target.value)}
className="w-44"
data-action="audit-to-filter"
/>
</div>
</CardHeader>
<CardContent className="relative p-0">
<LoadingOverlay active={loading && logs.length === 0} label="Loading audit log…" />
{table.total === 0 && !loading ? (
<EmptyState
icon={<Activity className="size-6" />}
title="No events match those filters."
description="Loosen the filter set or wait for new platform activity."
className="py-12"
/>
) : (
<>
<DataTable
columns={columns}
rows={table.pageRows}
getRowId={(l) => l.id}
sort={table.sort}
onSortToggle={table.toggleSort}
loading={loading && logs.length > 0}
stickyHeader
/>
<Pagination
page={table.page}
pageSize={table.pageSize}
total={table.total}
onPageChange={table.setPage}
onPageSizeChange={table.setPageSize}
/>
</>
)}
</CardContent>
</Card>
</div>
<Dialog open={detail !== null} onOpenChange={(o) => !o && setDetail(null)}>
<DialogContent className="sm:max-w-2xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Audit event</DialogTitle>
<DialogDescription>
{detail
? `${detail.action} on ${detail.resource_type} at ${new Date(
detail.inserted_at,
).toLocaleString()}`
: ""}
</DialogDescription>
</DialogHeader>
{detail ? <AuditDetailBody log={detail} /> : null}
</DialogContent>
</Dialog>
</AppShell> </AppShell>
) )
} }
function AuditDetailBody({ log }: { log: AuditLog }) {
const rows: { k: string; v: string }[] = [
{ k: "ID", v: log.id },
{ k: "Tenant", v: log.tenant_id },
{ k: "User", v: log.user?.email ?? log.user_id ?? "—" },
{ k: "Action", v: log.action },
{ k: "Resource", v: `${log.resource_type}${log.resource_id ? `/${log.resource_id}` : ""}` },
{ k: "Severity", v: log.severity },
{ k: "IP", v: log.ip_address ?? "—" },
{ k: "User agent", v: log.user_agent ?? "—" },
{ k: "Time", v: new Date(log.inserted_at).toISOString() },
]
return (
<div className="flex flex-col gap-4">
<dl className="grid grid-cols-[8rem_1fr] gap-y-1 text-sm">
{rows.map((r) => (
<div key={r.k} className="contents">
<dt className="text-xs uppercase tracking-wide text-muted-foreground">{r.k}</dt>
<dd className="break-all font-mono text-xs">{r.v}</dd>
</div>
))}
</dl>
{log.changes ? (
<div>
<h3 className="mb-1.5 text-sm font-semibold">Changes</h3>
<pre className="overflow-x-auto rounded-md border bg-muted/50 p-3 text-xs">
{JSON.stringify(log.changes, null, 2)}
</pre>
</div>
) : null}
{log.metadata && Object.keys(log.metadata).length > 0 ? (
<div>
<h3 className="mb-1.5 text-sm font-semibold">Metadata</h3>
<pre className="overflow-x-auto rounded-md border bg-muted/50 p-3 text-xs">
{JSON.stringify(log.metadata, null, 2)}
</pre>
</div>
) : null}
</div>
)
}
function severityTone(s: AuditSeverity): BadgeTone {
if (s === "critical" || s === "error") return "danger"
if (s === "warning") return "warning"
return "default"
}
function countBy<T>(arr: T[], key: (x: T) => string): Record<string, number> {
return arr.reduce<Record<string, number>>((acc, x) => {
const k = key(x)
acc[k] = (acc[k] ?? 0) + 1
return acc
}, {})
}

View File

@@ -1,7 +1,6 @@
import { import {
useCallback, useCallback,
useEffect, useEffect,
useMemo,
useRef, useRef,
useState, useState,
} from "react" } from "react"
@@ -29,12 +28,16 @@ import {
import { import {
LLMProvider, LLMProvider,
MockLLM, MockLLM,
OpenAICompatibleAdapter,
listModels, listModels,
useChat, useChat,
useCompletion, useCompletion,
type LLMAdapter, type LLMAdapter,
} from "@crema/llm-ui" } from "@crema/llm-ui"
import {
buildAdapter,
getProvider,
useSettings as useProviderSettings,
} from "@crema/llm-providers-ui"
import { TypingIndicator } from "@crema/chat-ui" import { TypingIndicator } from "@crema/chat-ui"
import { AppShell } from "~/components/layout/app-shell" import { AppShell } from "~/components/layout/app-shell"
@@ -51,7 +54,6 @@ import {
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from "~/components/ui/popover" } from "~/components/ui/popover"
import { useLLMSettings } from "~/lib/llm-settings"
import { import {
loadActiveAgentId, loadActiveAgentId,
saveActiveAgentId, saveActiveAgentId,
@@ -87,6 +89,37 @@ function ToolResultBlock({ name, result }: { name: string; result: unknown }) {
} }
const SNAPSHOT_KEY = "crema.ai.snapshot" const SNAPSHOT_KEY = "crema.ai.snapshot"
// Separate key for the live conversation that survives navigation. The
// compact snapshot is reserved for the user-triggered Compact/Restore flow.
const LIVE_KEY = "crema.ai.live"
function loadLive(): LLMMessage[] | null {
if (typeof window === "undefined") return null
try {
const raw = localStorage.getItem(LIVE_KEY)
if (!raw) return null
const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) return parsed as LLMMessage[]
} catch {}
return null
}
function saveLive(msgs: LLMMessage[]) {
if (typeof window === "undefined") return
if (msgs.length === 0) {
localStorage.removeItem(LIVE_KEY)
return
}
try {
localStorage.setItem(LIVE_KEY, JSON.stringify(msgs))
} catch {
// Quota exceeded or similar — silently drop persistence.
}
}
function clearLive() {
if (typeof window === "undefined") return
localStorage.removeItem(LIVE_KEY)
}
type StoredMessage = { role: "user" | "assistant"; content: string } type StoredMessage = { role: "user" | "assistant"; content: string }
function loadAISnapshot(): StoredMessage[] | null { function loadAISnapshot(): StoredMessage[] | null {
if (typeof window === "undefined") return null if (typeof window === "undefined") return null
@@ -146,13 +179,16 @@ function withTimeout<T>(p: Promise<T>, ms: number, signal: AbortSignal) {
} }
export default function AIRoute() { export default function AIRoute() {
const settings = useLLMSettings() const settings = useProviderSettings()
const arcadia = useArcadiaClient()
const provider = getProvider(settings.providerId)
const agents = useAgents() const agents = useAgents()
const [status, setStatus] = useState<Status>({ kind: "probing" }) const [status, setStatus] = useState<Status>({ kind: "probing" })
const [model, setModel] = useState<string>(() => { const [model, setModel] = useState<string>(() => {
if (typeof window === "undefined") return "" if (typeof window === "undefined") return ""
return localStorage.getItem(MODEL_KEY) ?? "" return localStorage.getItem(MODEL_KEY) ?? ""
}) })
const [adapter, setAdapter] = useState<LLMAdapter>(mockAdapter)
const [activeAgentId, setActiveAgentIdState] = useState<string>(() => const [activeAgentId, setActiveAgentIdState] = useState<string>(() =>
loadActiveAgentId(), loadActiveAgentId(),
) )
@@ -163,28 +199,110 @@ export default function AIRoute() {
const activeAgent = const activeAgent =
agents.find((a) => a.id === activeAgentId) ?? agents[0] agents.find((a) => a.id === activeAgentId) ?? agents[0]
// When the user changes provider/model in Settings, follow along.
useEffect(() => {
if (settings.model) setModel(settings.model)
}, [settings.providerId, settings.model])
// Resolve the API key from the vault (direct mode) or build the proxy
// adapter (proxy mode), then refresh the model list.
const probe = useCallback(() => { const probe = useCallback(() => {
const ac = new AbortController() const ac = new AbortController()
setStatus({ kind: "probing" }) setStatus({ kind: "probing" })
withTimeout(
listModels({ baseURL: settings.baseURL, signal: ac.signal }), const resolveSecret = async (name: string): Promise<string> => {
PROBE_TIMEOUT_MS, const res = await arcadia.GET<{ data: { value: string } }>(
ac.signal, `/api/v1/secrets/${encodeURIComponent(name)}`,
) )
.then((rows) => { return res.data.value
}
const arcadiaBaseURL =
(import.meta.env.VITE_ARCADIA_URL as string | undefined) ?? "http://localhost:4000"
const arcadiaTenantId =
(import.meta.env.VITE_ARCADIA_TENANT as string | undefined) ?? "default"
const arcadiaAuthToken =
typeof window !== "undefined"
? sessionStorage.getItem("arcadia_access_token") ?? undefined
: undefined
;(async () => {
// Build the adapter first so chat works even if the model probe fails.
try {
const a = await buildAdapter({
settings,
resolveSecret,
arcadiaBaseURL,
arcadiaAuthToken,
arcadiaTenantId,
})
setAdapter(a)
} catch {
setAdapter(mockAdapter)
}
// Probe for a live model list. Anthropic has no /models endpoint, so
// fall back to the provider catalog's default models.
if (provider.transport === "anthropic") {
const ids = provider.defaultModels.length
? provider.defaultModels
: ["claude-opus-4-7"]
setStatus({ kind: "live", models: ids })
setModel((cur) => (cur && ids.includes(cur) ? cur : settings.model || ids[0]))
return
}
const baseURL = settings.baseURL || provider.baseURL
let apiKey: string | undefined
if (provider.requiresKey && settings.secretName) {
try {
apiKey = await resolveSecret(settings.secretName)
} catch {
// Fall through; listModels may still work for some providers without a key.
}
}
try {
const rows = await withTimeout(
listModels({ baseURL, apiKey, signal: ac.signal }),
PROBE_TIMEOUT_MS,
ac.signal,
)
const ids = rows.map((m) => m.id) const ids = rows.map((m) => m.id)
if (ids.length === 0) { if (ids.length === 0) {
setStatus({ kind: "mock", reason: "endpoint returned no models" }) setStatus({ kind: "mock", reason: "endpoint returned no models" })
return return
} }
setStatus({ kind: "live", models: ids }) setStatus({ kind: "live", models: ids })
setModel((cur) => (cur && ids.includes(cur) ? cur : ids[0])) setModel((cur) => (cur && ids.includes(cur) ? cur : settings.model || ids[0]))
}) } catch {
.catch(() => { // Probe failed but adapter may still be usable; show the catalog default
setStatus({ kind: "mock", reason: "endpoint unreachable" }) // models so the user can pick one and just try sending.
}) if (provider.defaultModels.length) {
setStatus({ kind: "live", models: provider.defaultModels })
setModel((cur) =>
cur && provider.defaultModels.includes(cur)
? cur
: settings.model || provider.defaultModels[0],
)
} else {
setStatus({ kind: "mock", reason: "endpoint unreachable" })
}
}
})()
return () => ac.abort() return () => ac.abort()
}, [settings.baseURL]) // eslint-disable-next-line react-hooks/exhaustive-deps
}, [
arcadia,
settings.providerId,
settings.baseURL,
settings.secretName,
settings.mode,
settings.model,
provider.transport,
provider.baseURL,
provider.requiresKey,
])
useEffect(() => probe(), [probe]) useEffect(() => probe(), [probe])
@@ -192,16 +310,6 @@ export default function AIRoute() {
if (model) localStorage.setItem(MODEL_KEY, model) if (model) localStorage.setItem(MODEL_KEY, model)
}, [model]) }, [model])
const adapter: LLMAdapter = useMemo(() => {
if (status.kind === "live") {
return new OpenAICompatibleAdapter({
baseURL: settings.baseURL,
apiKey: settings.apiKey || "lm-studio",
})
}
return mockAdapter
}, [status.kind, settings.baseURL, settings.apiKey])
const activeModel = const activeModel =
status.kind === "live" ? model || status.models[0] : "mock" status.kind === "live" ? model || status.models[0] : "mock"
@@ -256,10 +364,29 @@ function ChatSurface({
.filter(Boolean) .filter(Boolean)
.join("\n\n") .join("\n\n")
const arcadia = useArcadiaClient() const arcadia = useArcadiaClient()
// Hydrate from the persisted live conversation so navigating away and
// back doesn't reset the chat. Read once on mount.
const initialLive = useRef<LLMMessage[] | null>(null)
if (initialLive.current === null) {
initialLive.current = loadLive() ?? []
}
const { messages, setMessages, send, continueChat, abort, isStreaming, reset } = useChat({ const { messages, setMessages, send, continueChat, abort, isStreaming, reset } = useChat({
system: systemPrompt, system: systemPrompt,
initialMessages: initialLive.current,
}) })
// Persist on every change. Streaming partials get saved too, which is what
// we want — refreshing mid-stream restores the partial assistant message.
useEffect(() => {
saveLive(messages)
}, [messages])
// Wrap reset so "Clear conversation" also drops the persisted snapshot.
const resetAndClear = useCallback(() => {
reset()
clearLive()
}, [reset])
// Auto tool-loop using native function calls. Reads run automatically; // Auto tool-loop using native function calls. Reads run automatically;
// writes are held in `pendingConfirm` until the operator clicks Confirm // writes are held in `pendingConfirm` until the operator clicks Confirm
// or Deny in the inline ConfirmCard. // or Deny in the inline ConfirmCard.
@@ -642,7 +769,7 @@ function ChatSurface({
onSaveToLibrary={saveToLibrary} onSaveToLibrary={saveToLibrary}
onShowPrompt={() => setShowPromptOpen(true)} onShowPrompt={() => setShowPromptOpen(true)}
onRetryProbe={onRetryProbe} onRetryProbe={onRetryProbe}
onClear={reset} onClear={resetAndClear}
hasMessages={messages.length > 0} hasMessages={messages.length > 0}
hasUserMessage={messages.some((m) => m.role === "user")} hasUserMessage={messages.some((m) => m.role === "user")}
hasCompactSnapshot={hasCompactSnapshot} hasCompactSnapshot={hasCompactSnapshot}

View File

@@ -0,0 +1,879 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { Link } from "react-router"
import {
CalendarClock,
CheckCircle2,
Clock,
History,
Pause,
Play,
Plus,
RefreshCw,
Trash2,
Zap,
} from "lucide-react"
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
import {
ActionsCell,
BadgeCell,
DataTable,
DateCell,
Pagination,
useTable,
type ActionItem,
type BadgeTone,
type Column,
} from "@crema/table-ui"
import { SearchInput } from "@crema/search-ui"
import { AlertBanner, ConfirmDialog, EmptyState, LoadingOverlay } from "@crema/feedback-ui"
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 { Textarea } from "~/components/ui/textarea"
import {
createScheduledTask,
deleteScheduledTask,
disableScheduledTask,
enableScheduledTask,
listScheduledTasks,
listTaskRuns,
triggerScheduledTask,
updateScheduledTask,
type ScheduledTask,
type ScheduledTaskAction,
type ScheduledTaskInput,
type TaskRun,
} from "~/lib/arcadia/scheduled-tasks"
import { pageTitle } from "~/lib/page-meta"
import { useSession } from "~/lib/session"
import { useRegisterAdminContext } from "~/lib/admin-context"
export const meta = () => pageTitle("Scheduled tasks")
type EditorState =
| { mode: "create" }
| { mode: "edit"; task: ScheduledTask }
| null
export default function ScheduledTasksRoute() {
const session = useSession()
const arcadia = useArcadiaClient()
const [tasks, setTasks] = useState<ScheduledTask[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [info, setInfo] = useState<string | null>(null)
const [search, setSearch] = useState("")
const [editor, setEditor] = useState<EditorState>(null)
const [pendingDelete, setPendingDelete] = useState<ScheduledTask | null>(null)
const [runsFor, setRunsFor] = useState<ScheduledTask | null>(null)
const refresh = useCallback(async () => {
setError(null)
setLoading(true)
try {
setTasks(await listScheduledTasks(arcadia))
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Failed to load scheduled tasks.")
} finally {
setLoading(false)
}
}, [arcadia])
useEffect(() => {
if (session) refresh()
}, [session, refresh])
const columns = useMemo<Column<ScheduledTask>[]>(
() => [
{
id: "name",
header: "Name",
accessor: "name",
sortable: true,
cell: (t) => (
<div className="flex flex-col">
<span className="font-medium">{t.name}</span>
{t.description ? (
<span className="text-xs text-muted-foreground">{t.description}</span>
) : null}
</div>
),
},
{
id: "cron",
header: "Schedule",
accessor: "cron_expression",
sortable: true,
cell: (t) => (
<div className="flex flex-col">
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">
{t.cron_expression}
</code>
<span className="text-[11px] text-muted-foreground">{t.timezone}</span>
</div>
),
},
{
id: "action",
header: "Action",
accessor: "action_type",
sortable: true,
cell: (t) => (
<Badge variant="secondary" className="font-mono text-xs">
{t.action_type}
</Badge>
),
},
{
id: "status",
header: "Status",
accessor: "enabled",
sortable: true,
cell: (t) => (
<BadgeCell
label={t.enabled ? "enabled" : "disabled"}
tone={t.enabled ? "success" : "default"}
/>
),
},
{
id: "last",
header: "Last run",
accessor: "last_run_at",
sortable: true,
cell: (t) =>
t.last_run_at ? (
<DateCell value={t.last_run_at} format="short" />
) : (
<span className="text-muted-foreground">never</span>
),
},
{
id: "next",
header: "Next run",
accessor: "next_run_at",
sortable: true,
cell: (t) =>
t.enabled && t.next_run_at ? (
<DateCell value={t.next_run_at} format="short" />
) : (
<span className="text-muted-foreground"></span>
),
},
{
id: "actions",
header: "",
align: "right",
cell: (t) => (
<ActionsCell
items={rowActions(t, {
arcadia,
refresh,
setEditor,
setPendingDelete,
setRunsFor,
setError,
setInfo,
})}
triggerDataAction={`task-${t.id}-actions`}
/>
),
},
],
[arcadia, refresh],
)
const summary = useMemo(
() => ({
total: tasks.length,
enabled: tasks.filter((t) => t.enabled).length,
byAction: countBy(tasks, (t) => t.action_type),
tasks: tasks.map((t) => ({
name: t.name,
cron: t.cron_expression,
timezone: t.timezone,
action_type: t.action_type,
enabled: t.enabled,
last_run_at: t.last_run_at,
next_run_at: t.next_run_at,
})),
}),
[tasks],
)
useRegisterAdminContext("scheduled_tasks", summary)
const table = useTable<ScheduledTask>({
data: tasks,
columns,
getRowId: (t) => t.id,
initialPageSize: 25,
initialSearch: search,
})
useEffect(() => {
table.setSearch(search)
}, [search, table])
if (!session) {
return (
<AppShell title="Scheduled tasks">
<div className="p-8">
<Card className="max-w-md">
<CardHeader>
<CardTitle>Sign in required</CardTitle>
<CardDescription>
Scheduled task administration requires an admin session.
</CardDescription>
</CardHeader>
<CardContent>
<Button asChild>
<Link to="/login?next=/scheduled-tasks">Sign in</Link>
</Button>
</CardContent>
</Card>
</div>
</AppShell>
)
}
return (
<AppShell title="Scheduled tasks">
<div className="flex flex-col gap-4 p-6">
<header className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Scheduled tasks</h1>
<p className="text-sm text-muted-foreground">
Cron-driven jobs run by arcadia. Trigger a task manually to test it without waiting
for the next scheduled run.
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={refresh}
disabled={loading}
data-action="tasks-refresh"
>
<RefreshCw className={`size-4 ${loading ? "animate-spin" : ""}`} />
Refresh
</Button>
<Button
size="sm"
onClick={() => setEditor({ mode: "create" })}
data-action="tasks-create"
>
<Plus className="size-4" />
New task
</Button>
</div>
</header>
{error ? (
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
{error}
</AlertBanner>
) : null}
{info ? (
<AlertBanner variant="success" dismissible onDismiss={() => setInfo(null)}>
{info}
</AlertBanner>
) : null}
<Card>
<CardHeader className="flex flex-row items-center gap-3">
<SearchInput
value={search}
onValueChange={setSearch}
placeholder="Search by name, cron, or action"
data-action="tasks-search"
className="max-w-sm flex-1"
/>
<div className="ml-auto text-xs text-muted-foreground">
{table.total} of {tasks.length}
</div>
</CardHeader>
<CardContent className="relative p-0">
<LoadingOverlay active={loading && tasks.length === 0} label="Loading tasks…" />
{table.total === 0 && !loading ? (
<EmptyState
icon={<CalendarClock className="size-6" />}
title={search ? "No tasks match." : "No scheduled tasks yet."}
description={
search
? "Try a different search."
: "Schedule a recurring webhook or platform event."
}
className="py-12"
/>
) : (
<>
<DataTable
columns={columns}
rows={table.pageRows}
getRowId={(t) => t.id}
sort={table.sort}
onSortToggle={table.toggleSort}
loading={loading && tasks.length > 0}
stickyHeader
/>
<Pagination
page={table.page}
pageSize={table.pageSize}
total={table.total}
onPageChange={table.setPage}
onPageSizeChange={table.setPageSize}
/>
</>
)}
</CardContent>
</Card>
</div>
<ConfirmDialog
open={pendingDelete !== null}
onOpenChange={(o) => !o && setPendingDelete(null)}
title="Delete scheduled task?"
description={
pendingDelete
? `${pendingDelete.name} will be permanently removed. Run history is retained.`
: ""
}
confirmLabel="Delete"
variant="danger"
onConfirm={async () => {
if (!pendingDelete) return
try {
await deleteScheduledTask(arcadia, pendingDelete.id)
setPendingDelete(null)
setInfo("Task deleted.")
await refresh()
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Delete failed.")
setPendingDelete(null)
}
}}
/>
<TaskEditorDialog
state={editor}
onClose={() => setEditor(null)}
onSaved={async () => {
setEditor(null)
await refresh()
}}
onError={setError}
/>
<RunsDialog task={runsFor} onClose={() => setRunsFor(null)} onError={setError} />
</AppShell>
)
}
function rowActions(
t: ScheduledTask,
ctx: {
arcadia: ReturnType<typeof useArcadiaClient>
refresh: () => Promise<void>
setEditor: (s: EditorState) => void
setPendingDelete: (t: ScheduledTask | null) => void
setRunsFor: (t: ScheduledTask | null) => void
setError: (m: string | null) => void
setInfo: (m: string | null) => void
},
): ActionItem[] {
const { arcadia, refresh, setEditor, setPendingDelete, setRunsFor, setError, setInfo } = ctx
const items: ActionItem[] = []
items.push({
id: "trigger",
label: "Run now",
icon: <Zap className="size-4" />,
dataAction: `task-${t.id}-trigger`,
onSelect: async () => {
try {
await triggerScheduledTask(arcadia, t.id)
setInfo(`${t.name} triggered. Check the run log for status.`)
await refresh()
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Trigger failed.")
}
},
})
items.push({
id: "edit",
label: "Edit",
dataAction: `task-${t.id}-edit`,
onSelect: () => setEditor({ mode: "edit", task: t }),
})
items.push({
id: "runs",
label: "View runs",
icon: <History className="size-4" />,
dataAction: `task-${t.id}-runs`,
onSelect: () => setRunsFor(t),
})
if (t.enabled) {
items.push({
id: "disable",
label: "Disable",
icon: <Pause className="size-4" />,
dataAction: `task-${t.id}-disable`,
onSelect: async () => {
try {
await disableScheduledTask(arcadia, t.id)
setInfo(`${t.name} disabled.`)
await refresh()
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Disable failed.")
}
},
})
} else {
items.push({
id: "enable",
label: "Enable",
icon: <Play className="size-4" />,
dataAction: `task-${t.id}-enable`,
onSelect: async () => {
try {
await enableScheduledTask(arcadia, t.id)
setInfo(`${t.name} enabled.`)
await refresh()
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Enable failed.")
}
},
})
}
items.push({
id: "delete",
label: "Delete",
icon: <Trash2 className="size-4" />,
destructive: true,
dataAction: `task-${t.id}-delete`,
onSelect: () => setPendingDelete(t),
})
return items
}
function TaskEditorDialog({
state,
onClose,
onSaved,
onError,
}: {
state: EditorState
onClose: () => void
onSaved: () => Promise<void>
onError: (msg: string | null) => void
}) {
const arcadia = useArcadiaClient()
const open = state !== null
const isEdit = state?.mode === "edit"
const initial = isEdit ? state.task : null
const [name, setName] = useState("")
const [description, setDescription] = useState("")
const [cron, setCron] = useState("")
const [timezone, setTimezone] = useState("UTC")
const [actionType, setActionType] = useState<ScheduledTaskAction>("event")
const [configText, setConfigText] = useState("{}")
const [tagsText, setTagsText] = useState("")
const [enabled, setEnabled] = useState(true)
const [maxRetries, setMaxRetries] = useState("3")
const [timeoutSeconds, setTimeoutSeconds] = useState("30")
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!open) return
if (initial) {
setName(initial.name)
setDescription(initial.description ?? "")
setCron(initial.cron_expression)
setTimezone(initial.timezone)
setActionType(initial.action_type)
setConfigText(
initial.action_config ? JSON.stringify(initial.action_config, null, 2) : "{}",
)
setTagsText(initial.tags.join(", "))
setEnabled(initial.enabled)
setMaxRetries(String(initial.max_retries))
setTimeoutSeconds(String(initial.timeout_seconds))
} else {
setName("")
setDescription("")
setCron("0 * * * *")
setTimezone("UTC")
setActionType("event")
setConfigText('{\n "event": "platform.heartbeat"\n}')
setTagsText("")
setEnabled(true)
setMaxRetries("3")
setTimeoutSeconds("30")
}
}, [open, initial])
const submit = async () => {
onError(null)
setSaving(true)
try {
let parsedConfig: Record<string, unknown>
try {
parsedConfig = configText.trim() === "" ? {} : JSON.parse(configText)
} catch {
throw new Error("Action config must be valid JSON.")
}
const tags = tagsText
.split(",")
.map((s) => s.trim())
.filter(Boolean)
const input: ScheduledTaskInput = {
name,
description: description || null,
cron_expression: cron,
timezone,
action_type: actionType,
action_config: parsedConfig,
tags,
enabled,
max_retries: Math.max(0, Number(maxRetries) || 0),
timeout_seconds: Math.max(1, Number(timeoutSeconds) || 30),
}
if (isEdit && initial) await updateScheduledTask(arcadia, initial.id, input)
else await createScheduledTask(arcadia, input)
await onSaved()
} catch (err) {
onError(
err instanceof ArcadiaError
? err.message
: err instanceof Error
? err.message
: "Save failed.",
)
} finally {
setSaving(false)
}
}
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{isEdit ? "Edit scheduled task" : "New scheduled task"}</DialogTitle>
<DialogDescription>
Cron uses standard 5-field syntax (minute hour dom month dow). Tasks run in the
specified timezone.
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2 flex flex-col gap-1.5">
<Label htmlFor="task-name">Name</Label>
<Input
id="task-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Daily cleanup"
data-action="task-form-name"
/>
</div>
<div className="col-span-2 flex flex-col gap-1.5">
<Label htmlFor="task-description">Description</Label>
<Input
id="task-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
data-action="task-form-description"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="task-cron">Cron expression</Label>
<Input
id="task-cron"
value={cron}
onChange={(e) => setCron(e.target.value)}
placeholder="0 2 * * *"
data-action="task-form-cron"
spellCheck={false}
className="font-mono"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="task-timezone">Timezone</Label>
<Input
id="task-timezone"
value={timezone}
onChange={(e) => setTimezone(e.target.value)}
placeholder="UTC"
data-action="task-form-timezone"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>Action type</Label>
<Select
value={actionType}
onValueChange={(v) => setActionType(v as ScheduledTaskAction)}
>
<SelectTrigger data-action="task-form-action-type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="event">Emit platform event</SelectItem>
<SelectItem value="webhook">Send outbound webhook</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="task-tags">Tags (comma-separated)</Label>
<Input
id="task-tags"
value={tagsText}
onChange={(e) => setTagsText(e.target.value)}
placeholder="cleanup, nightly"
data-action="task-form-tags"
/>
</div>
<div className="col-span-2 flex flex-col gap-1.5">
<Label htmlFor="task-config">
Action config (JSON){" "}
<span className="font-normal text-muted-foreground">
{actionType === "webhook"
? "{ url, method?, headers?, body? }"
: "{ event: 'name', payload?: {…} }"}
</span>
</Label>
<Textarea
id="task-config"
rows={8}
value={configText}
onChange={(e) => setConfigText(e.target.value)}
data-action="task-form-config"
spellCheck={false}
className="font-mono text-xs"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="task-retries">Max retries</Label>
<Input
id="task-retries"
type="number"
min={0}
value={maxRetries}
onChange={(e) => setMaxRetries(e.target.value)}
data-action="task-form-retries"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="task-timeout">Timeout (seconds)</Label>
<Input
id="task-timeout"
type="number"
min={1}
value={timeoutSeconds}
onChange={(e) => setTimeoutSeconds(e.target.value)}
data-action="task-form-timeout"
/>
</div>
<div className="col-span-2 flex items-center justify-between rounded-md border px-3 py-2">
<div>
<div className="text-sm font-medium">Enabled</div>
<div className="text-xs text-muted-foreground">
Disabled tasks skip their scheduled runs.
</div>
</div>
<Switch
checked={enabled}
onCheckedChange={setEnabled}
data-action="task-form-enabled"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={saving} data-action="task-form-cancel">
Cancel
</Button>
<Button
onClick={submit}
disabled={saving || !name || !cron}
data-action="task-form-save"
>
{saving ? <RefreshCw className="size-4 animate-spin" /> : <CheckCircle2 className="size-4" />}
{isEdit ? "Save" : "Create"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function RunsDialog({
task,
onClose,
onError,
}: {
task: ScheduledTask | null
onClose: () => void
onError: (msg: string | null) => void
}) {
const arcadia = useArcadiaClient()
const [runs, setRuns] = useState<TaskRun[]>([])
const [loading, setLoading] = useState(true)
const [expanded, setExpanded] = useState<string | null>(null)
useEffect(() => {
if (!task) return
let mounted = true
setLoading(true)
listTaskRuns(arcadia, task.id, { limit: 50 })
.then((r) => mounted && setRuns(r))
.catch((err) =>
onError(err instanceof ArcadiaError ? err.message : "Failed to load runs."),
)
.finally(() => mounted && setLoading(false))
return () => {
mounted = false
}
}, [arcadia, task, onError])
if (!task) return null
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-3xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Run history</DialogTitle>
<DialogDescription>
{task.name} last 50 runs, newest first.
</DialogDescription>
</DialogHeader>
{loading ? (
<p className="py-6 text-center text-sm text-muted-foreground">
<RefreshCw className="mr-1 inline size-3.5 animate-spin" /> Loading
</p>
) : runs.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">No runs yet.</p>
) : (
<ul className="flex flex-col divide-y rounded-md border">
{runs.map((r) => {
const open = expanded === r.id
return (
<li key={r.id} className="flex flex-col gap-1 px-3 py-2 text-sm">
<button
type="button"
onClick={() => setExpanded(open ? null : r.id)}
data-action={`task-run-${r.id}-toggle`}
className="flex items-start justify-between gap-3 text-left"
>
<div className="flex flex-col gap-0.5">
<span className="flex items-center gap-2">
<Badge variant={runVariant(r.status)}>{r.status}</Badge>
<span className="text-xs text-muted-foreground">
attempt {r.attempt}
</span>
{r.response_status ? (
<span className="text-xs text-muted-foreground">
HTTP {r.response_status}
</span>
) : null}
</span>
<span className="text-xs text-muted-foreground">
<Clock className="mr-1 inline size-3" />
{r.started_at
? `started ${new Date(r.started_at).toLocaleString()}`
: `queued ${new Date(r.inserted_at).toLocaleString()}`}
{r.finished_at
? ` · finished ${new Date(r.finished_at).toLocaleString()}`
: ""}
</span>
</div>
</button>
{open ? (
<div className="ml-1 mt-1 flex flex-col gap-2">
{r.error ? (
<div>
<div className="text-xs font-semibold text-destructive">Error</div>
<pre className="overflow-x-auto rounded-md border bg-muted/50 p-2 text-xs">
{r.error}
</pre>
</div>
) : null}
{r.response_body ? (
<div>
<div className="text-xs font-semibold">Response body</div>
<pre className="max-h-48 overflow-auto rounded-md border bg-muted/50 p-2 text-xs">
{r.response_body}
</pre>
</div>
) : null}
{!r.error && !r.response_body ? (
<p className="text-xs text-muted-foreground">No response captured.</p>
) : null}
</div>
) : null}
</li>
)
})}
</ul>
)}
<DialogFooter>
<Button variant="outline" onClick={onClose} data-action="task-runs-close">
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function runVariant(status: string): "default" | "secondary" | "destructive" | "outline" {
if (status === "succeeded") return "default"
if (status === "failed") return "destructive"
if (status === "running" || status === "pending") return "secondary"
return "outline"
}
function countBy<T>(arr: T[], key: (x: T) => string): Record<string, number> {
return arr.reduce<Record<string, number>>((acc, x) => {
const k = key(x)
acc[k] = (acc[k] ?? 0) + 1
return acc
}, {})
}

1090
app/routes/secrets.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,5 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { import {
Check,
X,
Loader2,
Cpu, Cpu,
Palette, Palette,
User as UserIcon, User as UserIcon,
@@ -12,6 +9,14 @@ import {
Trash2, Trash2,
} from "lucide-react" } from "lucide-react"
import { listModels } from "@crema/llm-ui" import { listModels } from "@crema/llm-ui"
import {
buildAdapter,
LLMProvidersSettingsCard,
resetSettings as resetProviderSettings,
useSettings as useProviderSettings,
type LLMProvidersSettings,
} from "@crema/llm-providers-ui"
import { useArcadiaClient } from "@crema/arcadia-client"
import { AppShell } from "~/components/layout/app-shell" import { AppShell } from "~/components/layout/app-shell"
import { Button } from "~/components/ui/button" import { Button } from "~/components/ui/button"
@@ -22,15 +27,6 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "~/components/ui/card" } from "~/components/ui/card"
import { Input } from "~/components/ui/input"
import { Textarea } from "~/components/ui/textarea"
import {
DEFAULT_SETTINGS,
DEFAULT_SYSTEM_PROMPT,
saveLLMSettings,
useLLMSettings,
type LLMSettings,
} from "~/lib/llm-settings"
import { import {
loadActiveAgentId, loadActiveAgentId,
newAgentId, newAgentId,
@@ -71,53 +67,94 @@ const sections: {
{ id: "about", label: "About", icon: Info, description: "Version & credits" }, { id: "about", label: "About", icon: Info, description: "Version & credits" },
] ]
type TestState =
| { kind: "idle" }
| { kind: "running" }
| { kind: "ok"; count: number }
| { kind: "fail"; reason: string }
export default function SettingsRoute() { export default function SettingsRoute() {
const settings = useLLMSettings() const arcadia = useArcadiaClient()
const [draft, setDraft] = useState<LLMSettings>(settings)
const [savedAt, setSavedAt] = useState<number | null>(null)
const [test, setTest] = useState<TestState>({ kind: "idle" })
useEffect(() => { const testConnection = async (
setDraft(settings) s: LLMProvidersSettings,
}, [settings]) ): Promise<{ ok: boolean; message: string }> => {
const runTest = async () => {
setTest({ kind: "running" })
const ac = new AbortController()
const timeout = setTimeout(() => ac.abort(), 4000)
try { try {
const rows = await listModels({ baseURL: draft.baseURL, signal: ac.signal }) const arcadiaBaseURL =
setTest({ kind: "ok", count: rows.length }) (import.meta.env.VITE_ARCADIA_URL as string | undefined) ?? "http://localhost:4000"
} catch (e) { const arcadiaTenantId =
setTest({ (import.meta.env.VITE_ARCADIA_TENANT as string | undefined) ?? "default"
kind: "fail", const arcadiaAuthToken =
reason: e instanceof Error ? e.message : String(e), typeof window !== "undefined"
? sessionStorage.getItem("arcadia_access_token") ?? undefined
: undefined
const adapter = await buildAdapter({
settings: s,
// Direct-mode resolver — fetches the API key from the vault.
resolveSecret: async (name) => {
const res = await arcadia.GET<{ data: { value: string } }>(
`/api/v1/secrets/${encodeURIComponent(name)}`,
)
return res.data.value
},
// Proxy-mode coordinates.
arcadiaBaseURL,
arcadiaAuthToken,
arcadiaTenantId,
}) })
} finally {
clearTimeout(timeout) // In proxy mode the adapter just being built is the strongest signal we
// can get without actually firing a chat request — the proxy endpoint
// doesn't exist on the backend yet, so any /models probe would 404.
if (s.mode === "proxy") {
return {
ok: true,
message:
"Adapter built. Note: the backend proxy (/api/v1/ai/llm/chat) isn't deployed yet — see docs/LLM_PROXY_CONTRACT.md.",
}
}
// Direct mode — for OpenAI-compatible endpoints, /models is a cheap probe.
if (s.providerId !== "anthropic") {
const baseURL =
s.baseURL ||
(s.providerId === "lmstudio"
? "http://localhost:1234/v1"
: s.providerId === "openai"
? "https://api.openai.com/v1"
: s.providerId === "deepseek"
? "https://api.deepseek.com/v1"
: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1")
// Resolve key for the probe (lmstudio doesn't need one).
let apiKey: string | undefined
if (s.providerId !== "lmstudio" && s.secretName) {
try {
const res = await arcadia.GET<{ data: { value: string } }>(
`/api/v1/secrets/${encodeURIComponent(s.secretName)}`,
)
apiKey = res.data.value
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
if (/404|not[_ ]found/i.test(msg)) {
return {
ok: false,
message: `No vault secret named "${s.secretName}". Create it under /secrets first (paste the API key as the Value), then enter the secret's name here.`,
}
}
throw err
}
}
const ac = new AbortController()
const t = setTimeout(() => ac.abort(), 5000)
try {
const rows = await listModels({ baseURL, apiKey, signal: ac.signal })
return { ok: true, message: `Connected. ${rows.length} model(s) reachable.` }
} finally {
clearTimeout(t)
}
}
// Anthropic doesn't expose a /models list; we just confirm adapter built.
return { ok: true, message: `Adapter ready (${adapter.label ?? adapter.id}).` }
} catch (e) {
return { ok: false, message: e instanceof Error ? e.message : String(e) }
} }
} }
const dirty =
draft.baseURL !== settings.baseURL ||
draft.contextTokens !== settings.contextTokens ||
draft.responseBudget !== settings.responseBudget
const save = () => {
saveLLMSettings(draft)
setSavedAt(Date.now())
}
const reset = () => {
setDraft(DEFAULT_SETTINGS)
}
const [section, setSection] = useState<SectionId>(() => { const [section, setSection] = useState<SectionId>(() => {
if (typeof window === "undefined") return "llm" if (typeof window === "undefined") return "llm"
const stored = localStorage.getItem(SECTION_KEY) const stored = localStorage.getItem(SECTION_KEY)
@@ -173,151 +210,36 @@ export default function SettingsRoute() {
<div className="min-w-0"> <div className="min-w-0">
{section === "llm" && ( {section === "llm" && (
<Card> <div className="flex flex-col gap-4">
<CardHeader> <Card>
<CardTitle>LLM</CardTitle> <CardHeader>
<CardDescription> <CardTitle>LLM</CardTitle>
Configure the local model endpoint and context budgets used <CardDescription>
by the Assistant. Pick a provider, model, and the arcadia-vault secret holding the API key. Settings
</CardDescription> auto-save as you type. The Assistant picks them up on the next message.
</CardHeader> </CardDescription>
<CardContent className="flex flex-col gap-5"> </CardHeader>
<Field <CardContent>
label="Base URL" <LLMProvidersSettingsCard
hint="OpenAI-compatible endpoint. LM Studio defaults to http://localhost:1234/v1." onTest={testConnection}
> hideTransportToggle={false}
<Input
data-action="settings-base-url"
value={draft.baseURL}
onChange={(e) =>
setDraft((d) => ({ ...d, baseURL: e.target.value }))
}
placeholder="http://localhost:1234/v1"
spellCheck={false}
autoComplete="off"
/> />
</Field> </CardContent>
</Card>
<Field <div className="flex items-center gap-2">
label="Context window (tokens)" <Button
hint="Match this to the context length you've loaded in LM Studio." variant="outline"
onClick={() => resetProviderSettings()}
data-action="settings-reset"
> >
<Input Reset to defaults
data-action="settings-context-tokens" </Button>
type="number" <span className="text-xs text-muted-foreground">
min={1024} Need to manage stored keys? See <a href="/secrets" className="underline">Secrets</a>.
step={512} </span>
value={draft.contextTokens} </div>
onChange={(e) => </div>
setDraft((d) => ({
...d,
contextTokens:
Number(e.target.value) || d.contextTokens,
}))
}
/>
</Field>
<Field
label="System prompt"
hint="Sent at the start of every conversation. Shapes the assistant's persona and scope. UI Control adds an action-driving preface on top of this when enabled."
>
<Textarea
data-action="settings-system-prompt"
value={draft.systemPrompt}
onChange={(e) =>
setDraft((d) => ({ ...d, systemPrompt: e.target.value }))
}
rows={5}
spellCheck={false}
className="min-h-24 font-mono text-xs"
/>
<button
type="button"
data-action="settings-system-prompt-reset"
onClick={() =>
setDraft((d) => ({
...d,
systemPrompt: DEFAULT_SYSTEM_PROMPT,
}))
}
className="self-start text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
>
Reset to default prompt
</button>
</Field>
<Field
label="Response cap (max tokens)"
hint="Upper bound on each model reply. Smaller = faster, less rambling."
>
<Input
data-action="settings-response-budget"
type="number"
min={64}
step={64}
value={draft.responseBudget}
onChange={(e) =>
setDraft((d) => ({
...d,
responseBudget:
Number(e.target.value) || d.responseBudget,
}))
}
/>
</Field>
<div className="flex flex-wrap items-center gap-2">
<Button
data-action="settings-save"
onClick={save}
disabled={!dirty}
>
Save
</Button>
<Button
data-action="settings-test"
variant="outline"
onClick={runTest}
disabled={test.kind === "running"}
>
{test.kind === "running" ? (
<Loader2 className="size-4 animate-spin" />
) : test.kind === "ok" ? (
<Check className="size-4 text-emerald-600" />
) : test.kind === "fail" ? (
<X className="size-4 text-destructive" />
) : null}
Test connection
</Button>
<Button
data-action="settings-reset"
variant="outline"
onClick={reset}
>
Reset to defaults
</Button>
{savedAt && !dirty && (
<span className="text-sm text-muted-foreground">
Saved.
</span>
)}
{test.kind === "ok" && (
<span className="text-sm text-emerald-700 dark:text-emerald-400">
{test.count} model{test.count === 1 ? "" : "s"} available.
</span>
)}
{test.kind === "fail" && (
<span
className="text-sm text-destructive"
title={test.reason}
>
Failed: {test.reason.slice(0, 60)}
</span>
)}
</div>
</CardContent>
</Card>
)} )}
{section === "agents" && <AgentsPanel />} {section === "agents" && <AgentsPanel />}

866
app/routes/storage.tsx Normal file
View File

@@ -0,0 +1,866 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { Link } from "react-router"
import {
CheckCircle2,
HardDrive,
Pause,
Play,
Plus,
RefreshCw,
ShieldCheck,
Star,
Trash2,
Wrench,
} from "lucide-react"
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
import {
ActionsCell,
BadgeCell,
DataTable,
DateCell,
Pagination,
useTable,
type ActionItem,
type BadgeTone,
type Column,
} from "@crema/table-ui"
import { SearchInput } from "@crema/search-ui"
import { AlertBanner, ConfirmDialog, EmptyState, LoadingOverlay } from "@crema/feedback-ui"
import { AppShell } from "~/components/layout/app-shell"
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 { Textarea } from "~/components/ui/textarea"
import {
activateStorageConfig,
createStorageConfig,
deactivateStorageConfig,
deleteStorageConfig,
isMaskedSecret,
isSecretField,
listStorageConfigs,
markStorageConfigDegraded,
markStorageConfigMaintenance,
OPTIONAL_FIELDS,
REQUIRED_FIELDS,
setDefaultStorageConfig,
updateStorageConfig,
validateStorageConfig,
type StorageBackend,
type StorageConfig,
type StorageConfigInput,
type StorageStatus,
} from "~/lib/arcadia/storage-configs"
import { pageTitle } from "~/lib/page-meta"
import { useSession } from "~/lib/session"
import { useRegisterAdminContext } from "~/lib/admin-context"
export const meta = () => pageTitle("Storage")
type PendingAction =
| { kind: "deactivate" | "degraded" | "maintenance" | "delete"; config: StorageConfig }
| null
type EditorState =
| { mode: "create" }
| { mode: "edit"; config: StorageConfig }
| null
export default function StorageRoute() {
const session = useSession()
const arcadia = useArcadiaClient()
const [configs, setConfigs] = useState<StorageConfig[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [info, setInfo] = useState<string | null>(null)
const [pending, setPending] = useState<PendingAction>(null)
const [editor, setEditor] = useState<EditorState>(null)
const [search, setSearch] = useState("")
const refresh = useCallback(async () => {
setError(null)
setLoading(true)
try {
const list = await listStorageConfigs(arcadia)
setConfigs(list)
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Failed to load storage configs.")
} finally {
setLoading(false)
}
}, [arcadia])
useEffect(() => {
if (session) refresh()
}, [session, refresh])
const runAction = useCallback(
async (action: PendingAction) => {
if (!action) return
try {
if (action.kind === "deactivate") await deactivateStorageConfig(arcadia, action.config.id)
else if (action.kind === "degraded")
await markStorageConfigDegraded(arcadia, action.config.id)
else if (action.kind === "maintenance")
await markStorageConfigMaintenance(arcadia, action.config.id)
else if (action.kind === "delete") await deleteStorageConfig(arcadia, action.config.id)
setPending(null)
await refresh()
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Action failed.")
setPending(null)
}
},
[arcadia, refresh],
)
const validate = useCallback(
async (config: StorageConfig) => {
setError(null)
setInfo(null)
try {
const result = await validateStorageConfig(arcadia, config.id)
if (result?.ok) {
setInfo(`${config.name}: validation passed.`)
} else {
setError(`${config.name}: ${result?.message ?? "validation failed."}`)
}
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Validation failed.")
}
},
[arcadia],
)
const columns = useMemo<Column<StorageConfig>[]>(
() => [
{
id: "name",
header: "Name",
accessor: "name",
sortable: true,
cell: (c) => (
<span className="flex items-center gap-2 font-medium">
{c.is_default ? <Star className="size-3.5 fill-amber-400 text-amber-400" /> : null}
{c.name}
</span>
),
},
{
id: "backend",
header: "Backend",
accessor: "backend_type",
sortable: true,
cell: (c) => (
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs uppercase">
{c.backend_type}
</code>
),
},
{
id: "status",
header: "Status",
accessor: "status",
sortable: true,
cell: (c) => <BadgeCell label={c.status} tone={statusTone(c.status)} />,
},
{
id: "size",
header: "Max size",
accessor: "max_file_size_bytes",
sortable: true,
cell: (c) => (
<span className="text-muted-foreground">{formatBytes(c.max_file_size_bytes)}</span>
),
},
{
id: "updated",
header: "Updated",
accessor: "updated_at",
sortable: true,
cell: (c) => <DateCell value={c.updated_at} format="short" />,
},
{
id: "actions",
header: "",
align: "right",
cell: (c) => (
<ActionsCell
items={rowActions(c, {
arcadia,
refresh,
setPending,
setEditor,
setError,
validate,
})}
triggerDataAction={`storage-${slugify(c.name)}-actions`}
/>
),
},
],
[arcadia, refresh, validate],
)
const summary = useMemo(
() => ({
total: configs.length,
byStatus: configs.reduce<Record<string, number>>((acc, c) => {
acc[c.status] = (acc[c.status] ?? 0) + 1
return acc
}, {}),
byBackend: configs.reduce<Record<string, number>>((acc, c) => {
acc[c.backend_type] = (acc[c.backend_type] ?? 0) + 1
return acc
}, {}),
configs: configs.map((c) => ({
id: c.id,
name: c.name,
backend_type: c.backend_type,
status: c.status,
is_default: c.is_default,
max_file_size_bytes: c.max_file_size_bytes,
updated_at: c.updated_at,
})),
}),
[configs],
)
useRegisterAdminContext("storage", summary)
const table = useTable<StorageConfig>({
data: configs,
columns,
getRowId: (c) => c.id,
initialPageSize: 25,
initialSearch: search,
})
useEffect(() => {
table.setSearch(search)
}, [search, table])
if (!session) {
return (
<AppShell title="Storage">
<div className="p-8">
<Card className="max-w-md">
<CardHeader>
<CardTitle>Sign in required</CardTitle>
<CardDescription>
Storage administration requires an admin session.
</CardDescription>
</CardHeader>
<CardContent>
<Button asChild>
<Link to="/login?next=/storage">Sign in</Link>
</Button>
</CardContent>
</Card>
</div>
</AppShell>
)
}
return (
<AppShell title="Storage">
<div className="flex flex-col gap-4 p-6">
<header className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Storage</h1>
<p className="text-sm text-muted-foreground">
Storage backends and credentials for the platform-admin tenant.
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={refresh}
disabled={loading}
data-action="storage-refresh"
>
<RefreshCw className={`size-4 ${loading ? "animate-spin" : ""}`} />
Refresh
</Button>
<Button
size="sm"
onClick={() => setEditor({ mode: "create" })}
data-action="storage-create"
>
<Plus className="size-4" />
New storage config
</Button>
</div>
</header>
{error ? (
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
{error}
</AlertBanner>
) : null}
{info ? (
<AlertBanner variant="success" dismissible onDismiss={() => setInfo(null)}>
{info}
</AlertBanner>
) : null}
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-4">
<SearchInput
value={search}
onValueChange={setSearch}
placeholder="Search by name, backend, or status"
data-action="storage-search"
className="max-w-sm flex-1"
/>
<div className="text-xs text-muted-foreground">
{table.total} of {configs.length}
</div>
</CardHeader>
<CardContent className="relative p-0">
<LoadingOverlay active={loading && configs.length === 0} label="Loading storage configs…" />
{table.total === 0 && !loading ? (
<EmptyState
title={search ? "No configs match that search." : "No storage configs yet."}
description={
search
? "Try a different name, backend, or status."
: "Create your first storage config to start uploading objects."
}
className="py-12"
/>
) : (
<>
<DataTable
columns={columns}
rows={table.pageRows}
getRowId={(c) => c.id}
sort={table.sort}
onSortToggle={table.toggleSort}
loading={loading && configs.length > 0}
stickyHeader
/>
<Pagination
page={table.page}
pageSize={table.pageSize}
total={table.total}
onPageChange={table.setPage}
onPageSizeChange={table.setPageSize}
/>
</>
)}
</CardContent>
</Card>
</div>
<ConfirmDialog
open={pending?.kind === "deactivate"}
onOpenChange={(o) => !o && setPending(null)}
title="Deactivate storage config?"
description={
pending
? `${pending.config.name} will stop accepting new uploads. Existing objects remain accessible.`
: ""
}
confirmLabel="Deactivate"
variant="default"
onConfirm={() => runAction(pending)}
/>
<ConfirmDialog
open={pending?.kind === "degraded"}
onOpenChange={(o) => !o && setPending(null)}
title="Mark as degraded?"
description={
pending
? `${pending.config.name} will be flagged as degraded. The platform may route new uploads elsewhere.`
: ""
}
confirmLabel="Mark degraded"
variant="default"
onConfirm={() => runAction(pending)}
/>
<ConfirmDialog
open={pending?.kind === "maintenance"}
onOpenChange={(o) => !o && setPending(null)}
title="Mark as in maintenance?"
description={
pending
? `${pending.config.name} will be put into maintenance mode.`
: ""
}
confirmLabel="Mark maintenance"
variant="default"
onConfirm={() => runAction(pending)}
/>
<ConfirmDialog
open={pending?.kind === "delete"}
onOpenChange={(o) => !o && setPending(null)}
title="Delete storage config?"
description={
pending
? `${pending.config.name} will be permanently removed. Objects already stored on this backend may become unreachable.`
: ""
}
confirmLabel="Delete"
variant="danger"
onConfirm={() => runAction(pending)}
/>
<StorageEditorDialog
state={editor}
onClose={() => setEditor(null)}
onSaved={async () => {
setEditor(null)
await refresh()
}}
onError={setError}
/>
</AppShell>
)
}
function statusTone(status: StorageStatus): BadgeTone {
if (status === "active") return "success"
if (status === "degraded") return "warning"
if (status === "maintenance") return "warning"
if (status === "inactive") return "default"
return "default"
}
function rowActions(
c: StorageConfig,
ctx: {
arcadia: ReturnType<typeof useArcadiaClient>
refresh: () => Promise<void>
setPending: (p: PendingAction) => void
setEditor: (s: EditorState) => void
setError: (msg: string | null) => void
validate: (c: StorageConfig) => Promise<void>
},
): ActionItem[] {
const { arcadia, refresh, setPending, setEditor, setError, validate } = ctx
const slug = slugify(c.name)
const items: ActionItem[] = []
items.push({
id: "validate",
label: "Validate",
icon: <ShieldCheck className="size-4" />,
dataAction: `storage-${slug}-validate`,
onSelect: () => validate(c),
})
items.push({
id: "edit",
label: "Edit",
dataAction: `storage-${slug}-edit`,
onSelect: () => setEditor({ mode: "edit", config: c }),
})
if (c.status === "active") {
items.push({
id: "deactivate",
label: "Deactivate",
icon: <Pause className="size-4" />,
dataAction: `storage-${slug}-deactivate`,
onSelect: () => setPending({ kind: "deactivate", config: c }),
})
} else {
items.push({
id: "activate",
label: "Activate",
icon: <Play className="size-4" />,
dataAction: `storage-${slug}-activate`,
onSelect: async () => {
try {
await activateStorageConfig(arcadia, c.id)
await refresh()
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Activate failed.")
}
},
})
}
if (!c.is_default) {
items.push({
id: "set-default",
label: "Set as default",
icon: <Star className="size-4" />,
dataAction: `storage-${slug}-set-default`,
onSelect: async () => {
try {
await setDefaultStorageConfig(arcadia, c.id)
await refresh()
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Set default failed.")
}
},
})
}
items.push({
id: "mark-degraded",
label: "Mark degraded",
icon: <Wrench className="size-4" />,
dataAction: `storage-${slug}-mark-degraded`,
onSelect: () => setPending({ kind: "degraded", config: c }),
})
items.push({
id: "mark-maintenance",
label: "Mark maintenance",
icon: <Wrench className="size-4" />,
dataAction: `storage-${slug}-mark-maintenance`,
onSelect: () => setPending({ kind: "maintenance", config: c }),
})
items.push({
id: "delete",
label: "Delete",
icon: <Trash2 className="size-4" />,
destructive: true,
dataAction: `storage-${slug}-delete`,
onSelect: () => setPending({ kind: "delete", config: c }),
})
return items
}
function StorageEditorDialog({
state,
onClose,
onSaved,
onError,
}: {
state: EditorState
onClose: () => void
onSaved: () => Promise<void>
onError: (msg: string | null) => void
}) {
const arcadia = useArcadiaClient()
const open = state !== null
const isEdit = state?.mode === "edit"
const initial = isEdit ? state.config : null
const [name, setName] = useState("")
const [backend, setBackend] = useState<StorageBackend>("s3")
const [isDefault, setIsDefault] = useState(false)
const [maxSize, setMaxSize] = useState<string>("")
const [allowedTypes, setAllowedTypes] = useState<string>("")
const [fields, setFields] = useState<Record<string, string>>({})
const [secretTouched, setSecretTouched] = useState<Record<string, boolean>>({})
const [saving, setSaving] = useState(false)
// Reset form whenever the dialog opens / target changes.
useEffect(() => {
if (!open) return
if (initial) {
setName(initial.name)
setBackend(initial.backend_type)
setIsDefault(initial.is_default)
setMaxSize(
initial.max_file_size_bytes == null ? "" : String(initial.max_file_size_bytes),
)
setAllowedTypes((initial.allowed_content_types ?? []).join(", "))
const initialFields: Record<string, string> = {}
const cfg = (initial.config ?? {}) as Record<string, unknown>
for (const k of Object.keys(cfg)) {
const v = cfg[k]
initialFields[k] = isMaskedSecret(v) ? "" : v == null ? "" : String(v)
}
setFields(initialFields)
setSecretTouched({})
} else {
setName("")
setBackend("s3")
setIsDefault(false)
setMaxSize("")
setAllowedTypes("")
setFields({})
setSecretTouched({})
}
}, [open, initial])
const required = REQUIRED_FIELDS[backend]
const optional = OPTIONAL_FIELDS[backend]
const setField = (key: string, value: string) => {
setFields((f) => ({ ...f, [key]: value }))
if (isSecretField(backend, key)) {
setSecretTouched((t) => ({ ...t, [key]: true }))
}
}
const submit = async () => {
onError(null)
setSaving(true)
try {
const config: Record<string, unknown> = {}
for (const k of [...required, ...optional]) {
const v = fields[k]
if (isSecretField(backend, k)) {
// Only send a secret if the user typed a fresh value.
if (secretTouched[k] && v !== "") config[k] = v
} else if (v !== undefined && v !== "") {
config[k] = v
}
}
const allowed = allowedTypes
.split(",")
.map((s) => s.trim())
.filter(Boolean)
const max = maxSize.trim() === "" ? null : Number(maxSize)
if (max != null && Number.isNaN(max)) {
throw new Error("Max file size must be a number (bytes).")
}
const input: StorageConfigInput = {
name,
backend_type: backend,
config,
is_default: isDefault,
max_file_size_bytes: max,
allowed_content_types: allowed.length ? allowed : undefined,
}
if (isEdit && initial) {
await updateStorageConfig(arcadia, initial.id, input)
} else {
await createStorageConfig(arcadia, input)
}
await onSaved()
} catch (err) {
onError(err instanceof ArcadiaError ? err.message : err instanceof Error ? err.message : "Save failed.")
} finally {
setSaving(false)
}
}
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{isEdit ? "Edit storage config" : "New storage config"}</DialogTitle>
<DialogDescription>
{isEdit
? "Secrets are write-only — leave masked fields blank to keep the existing value."
: "Connect a backend to start storing objects on this tenant."}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="storage-name">Name</Label>
<Input
id="storage-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Primary S3 storage"
data-action="storage-form-name"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>Backend</Label>
<Select
value={backend}
onValueChange={(v) => setBackend(v as StorageBackend)}
disabled={isEdit}
>
<SelectTrigger data-action="storage-form-backend">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="s3">S3 (or S3-compatible)</SelectItem>
<SelectItem value="gcs">Google Cloud Storage</SelectItem>
<SelectItem value="local">Local filesystem</SelectItem>
</SelectContent>
</Select>
</div>
{required.map((k) => (
<BackendField
key={k}
label={fieldLabel(k)}
field={k}
backend={backend}
value={fields[k] ?? ""}
originalIsMasked={
!!initial && isMaskedSecret((initial.config ?? {})[k as keyof typeof initial.config])
}
touched={!!secretTouched[k]}
onChange={(v) => setField(k, v)}
required
/>
))}
{optional.map((k) => (
<BackendField
key={k}
label={fieldLabel(k)}
field={k}
backend={backend}
value={fields[k] ?? ""}
originalIsMasked={false}
touched={false}
onChange={(v) => setField(k, v)}
/>
))}
<div className="flex flex-col gap-1.5">
<Label htmlFor="storage-max-size">Max file size (bytes)</Label>
<Input
id="storage-max-size"
value={maxSize}
onChange={(e) => setMaxSize(e.target.value)}
placeholder="e.g. 104857600"
data-action="storage-form-max-size"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="storage-allowed-types">Allowed content types (comma-separated)</Label>
<Textarea
id="storage-allowed-types"
value={allowedTypes}
onChange={(e) => setAllowedTypes(e.target.value)}
placeholder="image/jpeg, image/png, application/pdf"
data-action="storage-form-allowed-types"
rows={2}
/>
</div>
<div className="flex items-center justify-between rounded-md border px-3 py-2">
<div>
<div className="text-sm font-medium">Default backend</div>
<div className="text-xs text-muted-foreground">
New uploads go here unless another backend is specified.
</div>
</div>
<Switch
checked={isDefault}
onCheckedChange={setIsDefault}
data-action="storage-form-is-default"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={saving} data-action="storage-form-cancel">
Cancel
</Button>
<Button onClick={submit} disabled={saving || name.trim() === ""} data-action="storage-form-save">
{saving ? (
<RefreshCw className="size-4 animate-spin" />
) : (
<CheckCircle2 className="size-4" />
)}
{isEdit ? "Save changes" : "Create"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function BackendField({
label,
field,
backend,
value,
originalIsMasked,
touched,
onChange,
required,
}: {
label: string
field: string
backend: StorageBackend
value: string
originalIsMasked: boolean
touched: boolean
onChange: (v: string) => void
required?: boolean
}) {
const secret = isSecretField(backend, field)
const isJson = field === "service_account_json"
const placeholder = secret && originalIsMasked && !touched ? "•••••• (unchanged)" : ""
const inputId = `storage-config-field-${field}`
return (
<div className="flex flex-col gap-1.5">
<Label htmlFor={inputId}>
{label}
{required ? <span className="ml-1 text-destructive">*</span> : null}
</Label>
{isJson ? (
<Textarea
id={inputId}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder || "Paste service account JSON"}
rows={4}
data-action={`storage-form-config-${field}`}
/>
) : (
<Input
id={inputId}
type={secret ? "password" : "text"}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
data-action={`storage-form-config-${field}`}
/>
)}
</div>
)
}
function fieldLabel(key: string): string {
return key
.split("_")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ")
}
function slugify(s: string): string {
return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "config"
}
function formatBytes(n: number | null): string {
if (n == null) return "—"
const units = ["B", "KB", "MB", "GB", "TB"]
let i = 0
let v = n
while (v >= 1024 && i < units.length - 1) {
v /= 1024
i++
}
return `${v < 10 ? v.toFixed(1) : Math.round(v)} ${units[i]}`
}

1519
app/routes/users.tsx Normal file

File diff suppressed because it is too large Load Diff

921
app/routes/webhooks.tsx Normal file
View File

@@ -0,0 +1,921 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { Link } from "react-router"
import {
CheckCircle2,
Clock,
Copy,
History,
KeyRound,
Pause,
Play,
Plus,
RefreshCw,
RotateCw,
Send,
Trash2,
Webhook as WebhookIcon,
} from "lucide-react"
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
import {
ActionsCell,
BadgeCell,
DataTable,
DateCell,
Pagination,
useTable,
type ActionItem,
type BadgeTone,
type Column,
} from "@crema/table-ui"
import { SearchInput } from "@crema/search-ui"
import { AlertBanner, ConfirmDialog, EmptyState, LoadingOverlay } from "@crema/feedback-ui"
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 { Textarea } from "~/components/ui/textarea"
import {
COMMON_WEBHOOK_EVENTS,
createWebhook,
deleteWebhook,
listWebhookDeliveries,
listWebhooks,
pauseWebhook,
regenerateWebhookSecret,
resumeWebhook,
testWebhook,
updateWebhook,
type Webhook,
type WebhookDelivery,
type WebhookInput,
type WebhookRetryStrategy,
type WebhookStatus,
} from "~/lib/arcadia/webhooks"
import { pageTitle } from "~/lib/page-meta"
import { useSession } from "~/lib/session"
import { useRegisterAdminContext } from "~/lib/admin-context"
export const meta = () => pageTitle("Webhooks")
type EditorState =
| { mode: "create" }
| { mode: "edit"; webhook: Webhook }
| null
export default function WebhooksRoute() {
const session = useSession()
const arcadia = useArcadiaClient()
const [webhooks, setWebhooks] = useState<Webhook[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [info, setInfo] = useState<string | null>(null)
const [search, setSearch] = useState("")
const [editor, setEditor] = useState<EditorState>(null)
const [pendingDelete, setPendingDelete] = useState<Webhook | null>(null)
const [deliveriesFor, setDeliveriesFor] = useState<Webhook | null>(null)
const [revealedSecret, setRevealedSecret] = useState<{
webhookId: string
secret: string
isNew?: boolean
} | null>(null)
const refresh = useCallback(async () => {
setError(null)
setLoading(true)
try {
setWebhooks(await listWebhooks(arcadia))
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Failed to load webhooks.")
} finally {
setLoading(false)
}
}, [arcadia])
useEffect(() => {
if (session) refresh()
}, [session, refresh])
const columns = useMemo<Column<Webhook>[]>(
() => [
{
id: "url",
header: "URL",
accessor: "url",
sortable: true,
cell: (w) => (
<div className="flex flex-col">
<span className="font-mono text-xs">{w.url}</span>
{w.description ? (
<span className="text-xs text-muted-foreground">{w.description}</span>
) : null}
</div>
),
},
{
id: "status",
header: "Status",
accessor: "status",
sortable: true,
cell: (w) => <BadgeCell label={w.status} tone={statusTone(w.status)} />,
},
{
id: "events",
header: "Events",
cell: (w) =>
w.events.length === 0 ? (
<span className="text-muted-foreground">all</span>
) : (
<span className="text-xs">{w.events.length}</span>
),
},
{
id: "success",
header: "Success",
accessor: "success_count",
sortable: true,
cell: (w) => <span className="font-mono text-xs">{w.success_count}</span>,
},
{
id: "failure",
header: "Failure",
accessor: "failure_count",
sortable: true,
cell: (w) => (
<span
className={`font-mono text-xs ${
w.failure_count > 0 ? "text-destructive" : "text-muted-foreground"
}`}
>
{w.failure_count}
</span>
),
},
{
id: "last",
header: "Last triggered",
accessor: "last_triggered_at",
sortable: true,
cell: (w) =>
w.last_triggered_at ? (
<DateCell value={w.last_triggered_at} format="short" />
) : (
<span className="text-muted-foreground">never</span>
),
},
{
id: "actions",
header: "",
align: "right",
cell: (w) => (
<ActionsCell
items={rowActions(w, {
arcadia,
refresh,
setEditor,
setPendingDelete,
setDeliveriesFor,
setRevealedSecret,
setError,
setInfo,
})}
triggerDataAction={`webhook-${w.id}-actions`}
/>
),
},
],
[arcadia, refresh],
)
const summary = useMemo(
() => ({
total: webhooks.length,
byStatus: countBy(webhooks, (w) => w.status),
total_failures: webhooks.reduce((a, w) => a + w.failure_count, 0),
total_successes: webhooks.reduce((a, w) => a + w.success_count, 0),
webhooks: webhooks.map((w) => ({
url: w.url,
status: w.status,
events: w.events,
success_count: w.success_count,
failure_count: w.failure_count,
last_triggered_at: w.last_triggered_at,
})),
}),
[webhooks],
)
useRegisterAdminContext("webhooks", summary)
const table = useTable<Webhook>({
data: webhooks,
columns,
getRowId: (w) => w.id,
initialPageSize: 25,
initialSearch: search,
})
useEffect(() => {
table.setSearch(search)
}, [search, table])
if (!session) {
return (
<AppShell title="Webhooks">
<div className="p-8">
<Card className="max-w-md">
<CardHeader>
<CardTitle>Sign in required</CardTitle>
<CardDescription>Webhook administration requires an admin session.</CardDescription>
</CardHeader>
<CardContent>
<Button asChild>
<Link to="/login?next=/webhooks">Sign in</Link>
</Button>
</CardContent>
</Card>
</div>
</AppShell>
)
}
return (
<AppShell title="Webhooks">
<div className="flex flex-col gap-4 p-6">
<header className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Webhooks</h1>
<p className="text-sm text-muted-foreground">
Outbound HTTP callbacks for platform events. Each delivery is signed with the
endpoint's secret.
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={refresh}
disabled={loading}
data-action="webhooks-refresh"
>
<RefreshCw className={`size-4 ${loading ? "animate-spin" : ""}`} />
Refresh
</Button>
<Button
size="sm"
onClick={() => setEditor({ mode: "create" })}
data-action="webhooks-create"
>
<Plus className="size-4" />
New webhook
</Button>
</div>
</header>
{error ? (
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
{error}
</AlertBanner>
) : null}
{info ? (
<AlertBanner variant="success" dismissible onDismiss={() => setInfo(null)}>
{info}
</AlertBanner>
) : null}
<Card>
<CardHeader className="flex flex-row items-center gap-3">
<SearchInput
value={search}
onValueChange={setSearch}
placeholder="Search by URL, description, or status"
data-action="webhooks-search"
className="max-w-sm flex-1"
/>
<div className="ml-auto text-xs text-muted-foreground">
{table.total} of {webhooks.length}
</div>
</CardHeader>
<CardContent className="relative p-0">
<LoadingOverlay active={loading && webhooks.length === 0} label="Loading webhooks…" />
{table.total === 0 && !loading ? (
<EmptyState
icon={<WebhookIcon className="size-6" />}
title={search ? "No webhooks match." : "No webhooks yet."}
description={
search
? "Try a different search."
: "Add an endpoint to receive event notifications from arcadia."
}
className="py-12"
/>
) : (
<>
<DataTable
columns={columns}
rows={table.pageRows}
getRowId={(w) => w.id}
sort={table.sort}
onSortToggle={table.toggleSort}
loading={loading && webhooks.length > 0}
stickyHeader
/>
<Pagination
page={table.page}
pageSize={table.pageSize}
total={table.total}
onPageChange={table.setPage}
onPageSizeChange={table.setPageSize}
/>
</>
)}
</CardContent>
</Card>
</div>
<ConfirmDialog
open={pendingDelete !== null}
onOpenChange={(o) => !o && setPendingDelete(null)}
title="Delete webhook?"
description={
pendingDelete
? `${pendingDelete.url} will stop receiving events. Pending retries are abandoned.`
: ""
}
confirmLabel="Delete"
variant="danger"
onConfirm={async () => {
if (!pendingDelete) return
try {
await deleteWebhook(arcadia, pendingDelete.id)
setPendingDelete(null)
setInfo("Webhook deleted.")
await refresh()
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Delete failed.")
setPendingDelete(null)
}
}}
/>
<WebhookEditorDialog
state={editor}
onClose={() => setEditor(null)}
onSaved={async (created) => {
setEditor(null)
if (created?.secret) {
setRevealedSecret({ webhookId: created.id, secret: created.secret, isNew: true })
}
await refresh()
}}
onError={setError}
/>
<DeliveriesDialog
webhook={deliveriesFor}
onClose={() => setDeliveriesFor(null)}
onError={setError}
/>
<RevealSecretDialog reveal={revealedSecret} onClose={() => setRevealedSecret(null)} />
</AppShell>
)
}
function statusTone(s: WebhookStatus): BadgeTone {
if (s === "active") return "success"
if (s === "paused") return "warning"
return "default"
}
function rowActions(
w: Webhook,
ctx: {
arcadia: ReturnType<typeof useArcadiaClient>
refresh: () => Promise<void>
setEditor: (s: EditorState) => void
setPendingDelete: (w: Webhook | null) => void
setDeliveriesFor: (w: Webhook | null) => void
setRevealedSecret: (
r: { webhookId: string; secret: string; isNew?: boolean } | null,
) => void
setError: (m: string | null) => void
setInfo: (m: string | null) => void
},
): ActionItem[] {
const {
arcadia,
refresh,
setEditor,
setPendingDelete,
setDeliveriesFor,
setRevealedSecret,
setError,
setInfo,
} = ctx
const items: ActionItem[] = []
items.push({
id: "edit",
label: "Edit",
dataAction: `webhook-${w.id}-edit`,
onSelect: () => setEditor({ mode: "edit", webhook: w }),
})
items.push({
id: "deliveries",
label: "View deliveries",
icon: <History className="size-4" />,
dataAction: `webhook-${w.id}-deliveries`,
onSelect: () => setDeliveriesFor(w),
})
items.push({
id: "test",
label: "Send test event",
icon: <Send className="size-4" />,
dataAction: `webhook-${w.id}-test`,
onSelect: async () => {
try {
const r = await testWebhook(arcadia, w.id)
setInfo(r.ok === false ? r.message ?? "Test failed." : "Test event sent.")
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Test failed.")
}
},
})
if (w.status === "active") {
items.push({
id: "pause",
label: "Pause",
icon: <Pause className="size-4" />,
dataAction: `webhook-${w.id}-pause`,
onSelect: async () => {
try {
await pauseWebhook(arcadia, w.id)
setInfo("Webhook paused.")
await refresh()
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Pause failed.")
}
},
})
} else {
items.push({
id: "resume",
label: "Resume",
icon: <Play className="size-4" />,
dataAction: `webhook-${w.id}-resume`,
onSelect: async () => {
try {
await resumeWebhook(arcadia, w.id)
setInfo("Webhook resumed.")
await refresh()
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Resume failed.")
}
},
})
}
items.push({
id: "regen-secret",
label: "Regenerate secret",
icon: <RotateCw className="size-4" />,
dataAction: `webhook-${w.id}-regen-secret`,
onSelect: async () => {
try {
const updated = await regenerateWebhookSecret(arcadia, w.id)
if (updated.secret) {
setRevealedSecret({ webhookId: updated.id, secret: updated.secret })
}
await refresh()
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Regenerate failed.")
}
},
})
items.push({
id: "delete",
label: "Delete",
icon: <Trash2 className="size-4" />,
destructive: true,
dataAction: `webhook-${w.id}-delete`,
onSelect: () => setPendingDelete(w),
})
return items
}
function WebhookEditorDialog({
state,
onClose,
onSaved,
onError,
}: {
state: EditorState
onClose: () => void
onSaved: (created?: Webhook) => Promise<void>
onError: (msg: string | null) => void
}) {
const arcadia = useArcadiaClient()
const open = state !== null
const isEdit = state?.mode === "edit"
const initial = isEdit ? state.webhook : null
const [url, setUrl] = useState("")
const [description, setDescription] = useState("")
const [eventsText, setEventsText] = useState("")
const [headersText, setHeadersText] = useState("")
const [maxRetries, setMaxRetries] = useState("3")
const [retryStrategy, setRetryStrategy] = useState<WebhookRetryStrategy>("exponential")
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!open) return
if (initial) {
setUrl(initial.url)
setDescription(initial.description ?? "")
setEventsText(initial.events.join("\n"))
setHeadersText(
Object.entries(initial.headers ?? {})
.map(([k, v]) => `${k}: ${v}`)
.join("\n"),
)
setMaxRetries(String(initial.max_retries))
setRetryStrategy(initial.retry_strategy)
} else {
setUrl("")
setDescription("")
setEventsText("")
setHeadersText("")
setMaxRetries("3")
setRetryStrategy("exponential")
}
}, [open, initial])
const submit = async () => {
onError(null)
setSaving(true)
try {
const events = eventsText
.split(/\r?\n/)
.map((s) => s.trim())
.filter(Boolean)
const headers: Record<string, string> = {}
for (const line of headersText.split(/\r?\n/)) {
const idx = line.indexOf(":")
if (idx <= 0) continue
const k = line.slice(0, idx).trim()
const v = line.slice(idx + 1).trim()
if (k) headers[k] = v
}
const input: WebhookInput = {
url,
description: description || null,
events,
headers,
max_retries: Math.max(0, Number(maxRetries) || 0),
retry_strategy: retryStrategy,
}
if (isEdit && initial) {
const updated = await updateWebhook(arcadia, initial.id, input)
await onSaved(updated)
} else {
const created = await createWebhook(arcadia, input)
await onSaved(created)
}
} catch (err) {
onError(
err instanceof ArcadiaError
? err.message
: err instanceof Error
? err.message
: "Save failed.",
)
} finally {
setSaving(false)
}
}
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{isEdit ? "Edit webhook" : "New webhook"}</DialogTitle>
<DialogDescription>
{isEdit
? "Update the destination and event filter."
: "Arcadia POSTs JSON payloads to this URL when the listed events fire."}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="webhook-url">URL</Label>
<Input
id="webhook-url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://example.com/webhooks/arcadia"
data-action="webhook-form-url"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="webhook-description">Description</Label>
<Input
id="webhook-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
data-action="webhook-form-description"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="webhook-events">Events (one per line, blank = all events)</Label>
<Textarea
id="webhook-events"
rows={6}
value={eventsText}
onChange={(e) => setEventsText(e.target.value)}
placeholder={COMMON_WEBHOOK_EVENTS.slice(0, 5).join("\n")}
data-action="webhook-form-events"
/>
<div className="flex flex-wrap gap-1">
{COMMON_WEBHOOK_EVENTS.map((ev) => {
const has = eventsText
.split(/\r?\n/)
.some((l) => l.trim() === ev)
return (
<button
key={ev}
type="button"
onClick={() => {
setEventsText((prev) => {
const lines = prev.split(/\r?\n/).map((l) => l.trim()).filter(Boolean)
if (has) return lines.filter((l) => l !== ev).join("\n")
return [...lines, ev].join("\n")
})
}}
data-action={`webhook-form-event-${ev}`}
className={`rounded-full border px-2 py-0.5 text-xs font-medium transition-colors ${
has
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:bg-accent"
}`}
>
{ev}
</button>
)
})}
</div>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="webhook-headers">Custom headers (key: value, one per line)</Label>
<Textarea
id="webhook-headers"
rows={3}
value={headersText}
onChange={(e) => setHeadersText(e.target.value)}
placeholder="Authorization: Bearer xyz"
data-action="webhook-form-headers"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="webhook-retries">Max retries</Label>
<Input
id="webhook-retries"
type="number"
min={0}
value={maxRetries}
onChange={(e) => setMaxRetries(e.target.value)}
data-action="webhook-form-retries"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>Retry strategy</Label>
<Select
value={retryStrategy}
onValueChange={(v) => setRetryStrategy(v as WebhookRetryStrategy)}
>
<SelectTrigger data-action="webhook-form-retry-strategy">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="exponential">Exponential</SelectItem>
<SelectItem value="linear">Linear</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={saving} data-action="webhook-form-cancel">
Cancel
</Button>
<Button onClick={submit} disabled={saving || !url} data-action="webhook-form-save">
{saving ? <RefreshCw className="size-4 animate-spin" /> : <CheckCircle2 className="size-4" />}
{isEdit ? "Save" : "Create"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function DeliveriesDialog({
webhook,
onClose,
onError,
}: {
webhook: Webhook | null
onClose: () => void
onError: (msg: string | null) => void
}) {
const arcadia = useArcadiaClient()
const [deliveries, setDeliveries] = useState<WebhookDelivery[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
if (!webhook) return
let mounted = true
setLoading(true)
listWebhookDeliveries(arcadia, webhook.id, { limit: 50 })
.then((d) => mounted && setDeliveries(d))
.catch((err) =>
onError(err instanceof ArcadiaError ? err.message : "Failed to load deliveries."),
)
.finally(() => mounted && setLoading(false))
return () => {
mounted = false
}
}, [arcadia, webhook, onError])
if (!webhook) return null
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-3xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Recent deliveries</DialogTitle>
<DialogDescription>
<span className="font-mono text-xs">{webhook.url}</span>
</DialogDescription>
</DialogHeader>
{loading ? (
<p className="py-6 text-center text-sm text-muted-foreground">
<RefreshCw className="mr-1 inline size-3.5 animate-spin" /> Loading
</p>
) : deliveries.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">
No deliveries recorded yet.
</p>
) : (
<ul className="flex flex-col divide-y rounded-md border">
{deliveries.map((d) => (
<li key={d.id} className="flex items-start justify-between gap-3 px-3 py-2 text-sm">
<div className="flex flex-col gap-0.5">
<span className="flex items-center gap-2">
<Badge
variant={
d.status === "delivered"
? "default"
: d.status === "failed"
? "destructive"
: "secondary"
}
>
{d.status}
</Badge>
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">
{d.event_type}
</code>
{d.response_status ? (
<span className="text-xs text-muted-foreground">
HTTP {d.response_status}
</span>
) : null}
{d.response_time_ms != null ? (
<span className="text-xs text-muted-foreground">
{d.response_time_ms}ms
</span>
) : null}
</span>
<span className="text-xs text-muted-foreground">
<Clock className="mr-1 inline size-3" />
attempt {d.attempt} ·{" "}
{d.completed_at
? new Date(d.completed_at).toLocaleString()
: new Date(d.inserted_at).toLocaleString()}
</span>
{d.error_message ? (
<span className="text-xs text-destructive">{d.error_message}</span>
) : null}
{d.next_retry_at ? (
<span className="text-xs text-muted-foreground">
next retry {new Date(d.next_retry_at).toLocaleString()}
</span>
) : null}
</div>
</li>
))}
</ul>
)}
<DialogFooter>
<Button variant="outline" onClick={onClose} data-action="webhook-deliveries-close">
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function RevealSecretDialog({
reveal,
onClose,
}: {
reveal: { webhookId: string; secret: string; isNew?: boolean } | null
onClose: () => void
}) {
const [copied, setCopied] = useState(false)
useEffect(() => {
if (!reveal) setCopied(false)
}, [reveal])
if (!reveal) return null
const copy = async () => {
try {
await navigator.clipboard.writeText(reveal.secret)
setCopied(true)
} catch {}
}
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<KeyRound className="size-5 text-amber-500" />
{reveal.isNew ? "Webhook secret" : "New webhook secret"}
</DialogTitle>
<DialogDescription>
<strong>This is the only time the secret will be shown.</strong> Copy it now store it
with your verifying code so you can validate the X-Signature header on incoming
deliveries.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2 rounded-md border bg-muted/30 p-3">
<code className="select-all break-all font-mono text-xs">{reveal.secret}</code>
<Button size="sm" variant="outline" onClick={copy} data-action="webhook-secret-copy">
{copied ? <CheckCircle2 className="size-3.5" /> : <Copy className="size-3.5" />}
{copied ? "Copied" : "Copy"}
</Button>
</div>
<DialogFooter>
<Button onClick={onClose} data-action="webhook-secret-close">
Done
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function countBy<T>(arr: T[], key: (x: T) => string): Record<string, number> {
return arr.reduce<Record<string, number>>((acc, x) => {
const k = key(x)
acc[k] = (acc[k] ?? 0) + 1
return acc
}, {})
}

158
docs/LLM_PROXY_CONTRACT.md Normal file
View File

@@ -0,0 +1,158 @@
# LLM Proxy Contract
> **Status: not yet implemented on the backend.** This document is the contract that `lib-llm-providers-ui` expects from arcadia. Implement `POST /api/v1/ai/llm/chat` server-side to make `mode: "proxy"` work in the client.
## Why a proxy?
The Settings UI ships in two transport modes:
- **`direct`** — the browser fetches the API key from arcadia's vault (`GET /api/v1/secrets/:name`), then calls OpenAI/Anthropic/DeepSeek/Qwen directly. Works today, but the key briefly lives in browser memory and the prompt contents go straight to the upstream provider with no opportunity for arcadia to log, meter, or rewrite them.
- **`proxy`** — the browser sends the chat request to arcadia, which reads the secret server-side and calls the upstream provider. Keys never leave arcadia. This is what production should use.
This contract only covers the proxy mode.
## Endpoint
```
POST /api/v1/ai/llm/chat
Authorization: Bearer <arcadia session token>
X-Tenant-ID: <tenant id>
Content-Type: application/json
```
The path is `/api/v1/ai/llm/chat` so it lives under the existing `/api/v1/ai/*` scope (next to `embeddings`, `tools`, `llm/usage`).
## Request body
The shape is OpenAI's chat-completion request, **plus** two arcadia-specific fields:
```json
{
"provider": "openai",
"secret_name": "llm-openai-api-key",
"model": "gpt-4o-mini",
"messages": [
{ "role": "system", "content": "You are a helpful assistant." },
{ "role": "user", "content": "Hello!" }
],
"stream": true,
"max_tokens": 1024,
"temperature": 0.7,
"tools": [
{
"type": "function",
"function": {
"name": "search_docs",
"description": "...",
"parameters": { "type": "object", "properties": {} }
}
}
],
"tool_choice": "auto"
}
```
### Provider-specific fields
| Field | Type | Notes |
|---------------|-------------------------------------------------|-------|
| `provider` | `"openai" \| "anthropic" \| "deepseek" \| "qwen" \| "lmstudio"` | Selects the upstream backend. |
| `secret_name` | `string` (optional for `lmstudio`) | Name of the vault secret holding the upstream API key. The proxy resolves it via the same `Secrets.get/3` used for tenant-facing reads. |
The proxy must:
1. Authenticate the arcadia session.
2. Resolve `secret_name` for the current tenant (or fall back to platform-level). Refuse the call if the secret is disabled, expired, or IP-blocked. The existing `Arcadia.Secrets.get/3` already returns the right error codes.
3. Map the request to the upstream's native shape (Anthropic's `/v1/messages` differs from OpenAI's `/v1/chat/completions`).
4. Forward it with the resolved key as the upstream's expected auth header (`Authorization: Bearer <key>` for OpenAI/DeepSeek/Qwen, `x-api-key: <key>` + `anthropic-version: 2023-06-01` for Anthropic).
5. Stream the response back as **OpenAI-shape SSE** regardless of upstream. (See "Response — streaming" below.)
6. Record a usage row via the existing `POST /ai/llm/usage` after each completion.
## Response — non-streaming (`stream: false`)
OpenAI chat-completion shape, returned as a single JSON document:
```json
{
"id": "chatcmpl-...",
"object": "chat.completion",
"created": 1714512000,
"model": "gpt-4o-mini",
"choices": [
{
"index": 0,
"finish_reason": "stop",
"message": {
"role": "assistant",
"content": "Hi there!",
"tool_calls": null
}
}
],
"usage": {
"prompt_tokens": 12,
"completion_tokens": 4,
"total_tokens": 16
}
}
```
For Anthropic upstream, translate `usage.input_tokens` / `output_tokens``prompt_tokens` / `completion_tokens` and combine `content` blocks into a single string (or surface `tool_use` blocks via `tool_calls`).
## Response — streaming (`stream: true`)
Server-Sent Events, one event per delta, terminated with `data: [DONE]`. Each `data:` line is JSON of OpenAI's chat-completion *delta* shape:
```
data: {"id":"chatcmpl-...","object":"chat.completion.chunk","created":1714512000,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}
data: {"id":"chatcmpl-...","object":"chat.completion.chunk","created":1714512000,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"content":"Hi"},"finish_reason":null}]}
data: {"id":"chatcmpl-...","object":"chat.completion.chunk","created":1714512000,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"content":" there"},"finish_reason":null}]}
data: {"id":"chatcmpl-...","object":"chat.completion.chunk","created":1714512000,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}
data: [DONE]
```
For Anthropic upstream, translate `content_block_delta` events of type `text_delta` into delta `content` strings, and `message_stop` into the `finish_reason: "stop"` event. Tool calls translate `content_block_start` of type `tool_use` (with id + name) and the streaming JSON arguments into OpenAI-shape `delta.tool_calls` entries.
The client uses the OpenAI parser in `@crema/llm-ui` (`OpenAICompatibleAdapter.stream()`), so any deviation from this shape will manifest as missing tokens or hung streams.
## Errors
Use the existing `ArcadiaWeb.FallbackController` envelope:
```json
{ "error": { "code": "secret_disabled", "message": "Secret is disabled" } }
```
Specific codes the client distinguishes:
| HTTP | code | When |
|------|-------------------------|------|
| 401 | `unauthorized` | Missing / invalid arcadia session. |
| 403 | `secret_disabled` | Vault returned `:disabled`. |
| 410 | `secret_expired` | Vault returned `:expired`. |
| 410 | `secret_consumed` | Read-once secret already consumed. |
| 403 | `ip_not_allowed` | Caller IP blocked by the vault allowlist. |
| 404 | `unknown_provider` | `provider` field not in the supported set. |
| 502 | `upstream_unavailable` | Upstream returned 5xx or timed out. |
| 429 | `rate_limited` | Either arcadia or upstream returned 429. Pass through `Retry-After` if present. |
## Auth
The proxy must verify the arcadia session bearer the same way the rest of `/api/v1/*` does. The vault read uses the **caller's tenant context**, so platform-admin sessions can use platform-level secrets and tenant sessions can use their own — no special privilege required beyond what `/api/v1/secrets/:name` already enforces.
## Usage tracking
After each completion (success or failure), write a row via the existing `POST /api/v1/ai/llm/usage` (or call the equivalent context module directly inside the proxy). Required fields on that endpoint already include model, prompt_tokens, completion_tokens, latency_ms — the proxy can fill them from the upstream response.
## Test fixture
A minimal Mix test in `apps/arcadia_core/test/arcadia_web/controllers/api/ai_controller_test.exs` should cover:
- 200 with stream off, OpenAI upstream stubbed via Bypass.
- 200 with stream on, Anthropic upstream stubbed; assert SSE chunks carry OpenAI-shape JSON.
- 403 when the named secret is disabled.
- 404 when `provider: "unknown"`.
- Usage row written on the success cases.

View File

@@ -40,6 +40,8 @@
"@crema/auth-ui/*": ["../lib-auth-ui/src/*"], "@crema/auth-ui/*": ["../lib-auth-ui/src/*"],
"@crema/agent-ui": ["../lib-agent-ui/src/index.tsx"], "@crema/agent-ui": ["../lib-agent-ui/src/index.tsx"],
"@crema/agent-ui/*": ["../lib-agent-ui/src/*"], "@crema/agent-ui/*": ["../lib-agent-ui/src/*"],
"@crema/llm-providers-ui": ["../lib-llm-providers-ui/src/index.tsx"],
"@crema/llm-providers-ui/*": ["../lib-llm-providers-ui/src/*"],
"// CREMA:PATHS": [""], "// CREMA:PATHS": [""],
"react": ["./node_modules/@types/react"], "react": ["./node_modules/@types/react"],
"react/*": ["./node_modules/@types/react/*"], "react/*": ["./node_modules/@types/react/*"],

View File

@@ -65,6 +65,12 @@ const arcadiaClientSrc = fileURLToPath(
const arcadiaAuthUiSrc = fileURLToPath( const arcadiaAuthUiSrc = fileURLToPath(
new URL("../lib-arcadia-auth-ui/src", import.meta.url), new URL("../lib-arcadia-auth-ui/src", import.meta.url),
) )
const llmUiSrc = fileURLToPath(
new URL("../lib-llm-ui/src", import.meta.url),
)
const llmProvidersUiSrc = fileURLToPath(
new URL("../lib-llm-providers-ui/src", import.meta.url),
)
// Sibling lib packages (lib-content-ui, lib-content-editor-ui) import bare // Sibling lib packages (lib-content-ui, lib-content-editor-ui) import bare
// deps like clsx and @tiptap/* but have no node_modules of their own. Pin // deps like clsx and @tiptap/* but have no node_modules of their own. Pin
@@ -118,6 +124,8 @@ export default defineConfig({
"@crema/search-ui": `${searchUiSrc}/index.tsx`, "@crema/search-ui": `${searchUiSrc}/index.tsx`,
"@crema/arcadia-client": `${arcadiaClientSrc}/index.tsx`, "@crema/arcadia-client": `${arcadiaClientSrc}/index.tsx`,
"@crema/arcadia-auth-ui": `${arcadiaAuthUiSrc}/index.tsx`, "@crema/arcadia-auth-ui": `${arcadiaAuthUiSrc}/index.tsx`,
"@crema/llm-ui": `${llmUiSrc}/index.tsx`,
"@crema/llm-providers-ui": `${llmProvidersUiSrc}/index.tsx`,
...sharedDepAliases, ...sharedDepAliases,
}, },
dedupe: dedupeDeps, dedupe: dedupeDeps,