Compare commits
2 Commits
45fa130951
...
7ba415d78e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ba415d78e | ||
|
|
a907e25a7c |
@@ -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 *));
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
679
app/components/users/user-detail-sheet.tsx
Normal file
679
app/components/users/user-detail-sheet.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
67
app/lib/arcadia/api-keys.ts
Normal file
67
app/lib/arcadia/api-keys.ts
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
76
app/lib/arcadia/audit-logs.ts
Normal file
76
app/lib/arcadia/audit-logs.ts
Normal 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
|
||||||
|
}
|
||||||
65
app/lib/arcadia/invitations.ts
Normal file
65
app/lib/arcadia/invitations.ts
Normal 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
55
app/lib/arcadia/roles.ts
Normal 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}`)
|
||||||
|
}
|
||||||
130
app/lib/arcadia/scheduled-tasks.ts
Normal file
130
app/lib/arcadia/scheduled-tasks.ts
Normal 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
171
app/lib/arcadia/secrets.ts
Normal 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" },
|
||||||
|
]
|
||||||
167
app/lib/arcadia/storage-configs.ts
Normal file
167
app/lib/arcadia/storage-configs.ts
Normal 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 === "***"
|
||||||
|
}
|
||||||
56
app/lib/arcadia/user-stats.ts
Normal file
56
app/lib/arcadia/user-stats.ts
Normal 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
111
app/lib/arcadia/users.ts
Normal 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
161
app/lib/arcadia/webhooks.ts
Normal 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",
|
||||||
|
]
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 (
|
return (
|
||||||
<AppShell title="Activity">
|
<AppShell title="Audit log">
|
||||||
<Card>
|
<div className="p-8">
|
||||||
|
<Card className="max-w-md">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Activity</CardTitle>
|
<CardTitle>Sign in required</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>The audit log requires an admin session.</CardDescription>
|
||||||
Event stream, audit log, recent changes.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<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">
|
<Button asChild>
|
||||||
<div className="flex size-12 items-center justify-center rounded-xl bg-background text-muted-foreground">
|
<Link to="/login?next=/activity">Sign in</Link>
|
||||||
<Activity className="size-6" />
|
</Button>
|
||||||
</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>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</div>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell title="Audit log">
|
||||||
|
<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">Audit log</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Every authenticated action against the platform. Filter by date, severity, or
|
||||||
|
resource type.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}, {})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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> => {
|
||||||
|
const res = await arcadia.GET<{ data: { value: string } }>(
|
||||||
|
`/api/v1/secrets/${encodeURIComponent(name)}`,
|
||||||
|
)
|
||||||
|
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,
|
PROBE_TIMEOUT_MS,
|
||||||
ac.signal,
|
ac.signal,
|
||||||
)
|
)
|
||||||
.then((rows) => {
|
|
||||||
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
|
||||||
|
// 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" })
|
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}
|
||||||
|
|||||||
879
app/routes/scheduled-tasks.tsx
Normal file
879
app/routes/scheduled-tasks.tsx
Normal 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
1090
app/routes/secrets.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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,51 +67,92 @@ 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,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 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 {
|
} finally {
|
||||||
clearTimeout(timeout)
|
clearTimeout(t)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Anthropic doesn't expose a /models list; we just confirm adapter built.
|
||||||
const dirty =
|
return { ok: true, message: `Adapter ready (${adapter.label ?? adapter.id}).` }
|
||||||
draft.baseURL !== settings.baseURL ||
|
} catch (e) {
|
||||||
draft.contextTokens !== settings.contextTokens ||
|
return { ok: false, message: e instanceof Error ? e.message : String(e) }
|
||||||
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>(() => {
|
||||||
@@ -173,151 +210,36 @@ export default function SettingsRoute() {
|
|||||||
|
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
{section === "llm" && (
|
{section === "llm" && (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>LLM</CardTitle>
|
<CardTitle>LLM</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Configure the local model endpoint and context budgets used
|
Pick a provider, model, and the arcadia-vault secret holding the API key. Settings
|
||||||
by the Assistant.
|
auto-save as you type. The Assistant picks them up on the next message.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-5">
|
<CardContent>
|
||||||
<Field
|
<LLMProvidersSettingsCard
|
||||||
label="Base URL"
|
onTest={testConnection}
|
||||||
hint="OpenAI-compatible endpoint. LM Studio defaults to http://localhost:1234/v1."
|
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)"
|
|
||||||
hint="Match this to the context length you've loaded in LM Studio."
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
data-action="settings-context-tokens"
|
|
||||||
type="number"
|
|
||||||
min={1024}
|
|
||||||
step={512}
|
|
||||||
value={draft.contextTokens}
|
|
||||||
onChange={(e) =>
|
|
||||||
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
|
<Button
|
||||||
data-action="settings-save"
|
|
||||||
onClick={save}
|
|
||||||
disabled={!dirty}
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
data-action="settings-test"
|
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={runTest}
|
onClick={() => resetProviderSettings()}
|
||||||
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"
|
data-action="settings-reset"
|
||||||
variant="outline"
|
|
||||||
onClick={reset}
|
|
||||||
>
|
>
|
||||||
Reset to defaults
|
Reset to defaults
|
||||||
</Button>
|
</Button>
|
||||||
{savedAt && !dirty && (
|
<span className="text-xs text-muted-foreground">
|
||||||
<span className="text-sm text-muted-foreground">
|
Need to manage stored keys? See <a href="/secrets" className="underline">Secrets</a>.
|
||||||
Saved.
|
|
||||||
</span>
|
</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>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{section === "agents" && <AgentsPanel />}
|
{section === "agents" && <AgentsPanel />}
|
||||||
|
|||||||
866
app/routes/storage.tsx
Normal file
866
app/routes/storage.tsx
Normal 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
1519
app/routes/users.tsx
Normal file
File diff suppressed because it is too large
Load Diff
921
app/routes/webhooks.tsx
Normal file
921
app/routes/webhooks.tsx
Normal 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
158
docs/LLM_PROXY_CONTRACT.md
Normal 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.
|
||||||
@@ -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/*"],
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user