diff --git a/app/components/layout/app-shell.tsx b/app/components/layout/app-shell.tsx index 92e3591..ebbbb5f 100644 --- a/app/components/layout/app-shell.tsx +++ b/app/components/layout/app-shell.tsx @@ -20,6 +20,11 @@ import { HelpCircle, Menu, Play, + HardDrive, + Users as UsersIcon, + KeyRound, + Webhook as WebhookIcon, + CalendarClock, // CREMA:NAV-ICONS } from "lucide-react" @@ -88,6 +93,11 @@ type NavItem = { const navItems: NavItem[] = [ { to: "/", icon: LayoutDashboard, label: "Overview", end: true }, { 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: "/ai", icon: Bot, label: "AI" }, { to: "/settings", icon: Settings, label: "Settings" }, diff --git a/app/components/users/user-detail-sheet.tsx b/app/components/users/user-detail-sheet.tsx new file mode 100644 index 0000000..ccd326c --- /dev/null +++ b/app/components/users/user-detail-sheet.tsx @@ -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 +} + +export function UserDetailSheet({ user, roles, onClose, onChanged }: Props) { + const open = user !== null + return ( + !o && onClose()}> + + {user ? ( + + ) : null} + + + ) +} + +function UserDetailBody({ + user, + roles, + onChanged, + onClose, +}: { + user: User + roles: Role[] + onChanged: () => Promise + onClose: () => void +}) { + return ( + <> + + + + {user.full_name || user.email} + {user.email} + + + {user.email_verified ? "Verified" : "Unverified"} + + + + +
+ + + + Overview + + + Roles + + + API keys + + + + + + + + + + + + + +
+ +
+ +
+ + ) +} + +// --- Overview tab ------------------------------------------------------ + +function OverviewPanel({ user }: { user: User }) { + const arcadia = useArcadiaClient() + const [usage, setUsage] = useState(null) + const [quota, setQuota] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(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 ( +
+
+ + + + + + +
+ +

Storage

+ {error ? ( + setError(null)}> + {error} + + ) : null} + {loading ? ( +

+ Loading… +

+ ) : ( +
+ + + {quota ? ( + <> + + + + ) : ( + + )} +
+ )} +
+ ) +} + +function Stat({ + label, + value, + mono, +}: { + label: string + value: string + mono?: boolean +}) { + return ( +
+
{label}
+
{value}
+
+ ) +} + +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 +}) { + const arcadia = useArcadiaClient() + const [busy, setBusy] = useState(null) + const [error, setError] = useState(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 ( +
+ {error ? ( + setError(null)}> + {error} + + ) : null} + {roles.length === 0 ? ( +

No roles defined yet.

+ ) : ( +
    + {roles.map((r) => { + const has = assigned.has(r.id) + return ( +
  • +
    + + {has ? ( + + ) : ( + + )} + {r.name}{" "} + {r.slug} + + {r.description ? ( + {r.description} + ) : null} +
    + +
  • + ) + })} +
+ )} +
+ ) +} + +// --- API keys tab ------------------------------------------------------ + +function ApiKeysPanel({ + user, + onChanged, +}: { + user: User + onChanged: () => Promise +}) { + const arcadia = useArcadiaClient() + const [keys, setKeys] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [createOpen, setCreateOpen] = useState(false) + const [revealed, setRevealed] = useState(null) + const [pendingRevoke, setPendingRevoke] = useState(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 ( +
+ {error ? ( + setError(null)}> + {error} + + ) : null} + +
+

+ Keys are shown in full only once, on creation. Treat them like + passwords. +

+ +
+ + {loading ? ( +

+ Loading… +

+ ) : keys.length === 0 ? ( +

+ No API keys yet. +

+ ) : ( +
    + {keys.map((k) => ( +
  • +
    + + + {k.key_prefix}… + {!k.is_active ? ( + + revoked + + ) : null} + + + {k.description ?? "(no description)"} + + + 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()}` + : ""} + +
    + {k.is_active ? ( + + ) : null} +
  • + ))} +
+ )} + + setCreateOpen(false)} + onCreated={async (created) => { + setCreateOpen(false) + setRevealed(created) + await refresh() + await onChanged() + }} + /> + + setRevealed(null)} /> + + !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) + } + }} + /> +
+ ) +} + +function CreateApiKeyDialog({ + open, + userId, + onClose, + onCreated, +}: { + open: boolean + userId: string + onClose: () => void + onCreated: (k: ApiKeyCreated) => Promise +}) { + const arcadia = useArcadiaClient() + const [description, setDescription] = useState("") + const [expiresAt, setExpiresAt] = useState("") + const [busy, setBusy] = useState(false) + const [error, setError] = useState(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 ( + !o && onClose()}> + + + New API key + + The full key value will be shown once after creation. Copy it then; we don't store it + in cleartext. + + + + {error ? ( + setError(null)}> + {error} + + ) : null} + +
+
+ + setDescription(e.target.value)} + placeholder="e.g. CI deploy key" + data-action="api-key-form-description" + /> +
+
+ + setExpiresAt(e.target.value)} + data-action="api-key-form-expires" + /> +
+
+ + + + + +
+
+ ) +} + +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 ( + !o && onClose()}> + + + + + API key created + + + This is the only time the key will be shown. Copy and store it now. + + + +
+ {created.api_key} +
+ + Prefix: {created.key_prefix}… + {created.expires_at + ? ` · expires ${new Date(created.expires_at).toLocaleString()}` + : " · never expires"} + + +
+
+ +

{created.warning}

+ + + + +
+
+ ) +} diff --git a/app/lib/arcadia/api-keys.ts b/app/lib/arcadia/api-keys.ts new file mode 100644 index 0000000..05be977 --- /dev/null +++ b/app/lib/arcadia/api-keys.ts @@ -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 { + 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 { + 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 { + await arcadia.DELETE(`/api/v1/users/${userId}/api_keys/${keyId}`, { + body: reason ? { reason } : undefined, + }) +} diff --git a/app/lib/arcadia/audit-logs.ts b/app/lib/arcadia/audit-logs.ts new file mode 100644 index 0000000..51915a1 --- /dev/null +++ b/app/lib/arcadia/audit-logs.ts @@ -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 | null + metadata: Record | 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 + by_severity: Record + by_resource_type: Record + [key: string]: unknown +} + +export async function listAuditLogs( + arcadia: ArcadiaClient, + params?: AuditListParams, +): Promise { + const res = await arcadia.GET<{ data: AuditLog[] }>( + "/api/v1/observability/audit_logs", + { params: params as Record }, + ) + return res.data +} + +export async function getAuditLog(arcadia: ArcadiaClient, id: string): Promise { + 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 { + const res = await arcadia.GET<{ data: AuditStats }>( + "/api/v1/observability/audit_stats", + { params: params as Record }, + ) + return res.data +} diff --git a/app/lib/arcadia/invitations.ts b/app/lib/arcadia/invitations.ts new file mode 100644 index 0000000..8aa3823 --- /dev/null +++ b/app/lib/arcadia/invitations.ts @@ -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 { + const res = await arcadia.GET<{ data: Invitation[] }>("/api/v1/invitations") + return res.data +} + +export async function createInvitation( + arcadia: ArcadiaClient, + input: InvitationInput, +): Promise { + 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 { + await arcadia.DELETE(`/api/v1/invitations/${id}`) +} + +export async function resendInvitation(arcadia: ArcadiaClient, id: string): Promise { + await arcadia.POST(`/api/v1/invitations/${id}/resend`) +} diff --git a/app/lib/arcadia/roles.ts b/app/lib/arcadia/roles.ts new file mode 100644 index 0000000..a2e55be --- /dev/null +++ b/app/lib/arcadia/roles.ts @@ -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 + tenant_id: string + inserted_at: string + updated_at: string +} + +export interface RoleInput { + name: string + slug: string + description?: string | null + permissions?: string[] + metadata?: Record +} + +export async function listRoles(arcadia: ArcadiaClient): Promise { + const res = await arcadia.GET<{ data: Role[] }>("/api/v1/roles") + return res.data +} + +export async function getRole(arcadia: ArcadiaClient, id: string): Promise { + const res = await arcadia.GET<{ data: Role }>(`/api/v1/roles/${id}`) + return res.data +} + +export async function createRole(arcadia: ArcadiaClient, input: RoleInput): Promise { + 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, +): Promise { + 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 { + await arcadia.DELETE(`/api/v1/roles/${id}`) +} diff --git a/app/lib/arcadia/scheduled-tasks.ts b/app/lib/arcadia/scheduled-tasks.ts new file mode 100644 index 0000000..debd9d4 --- /dev/null +++ b/app/lib/arcadia/scheduled-tasks.ts @@ -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 | 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 + 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 { + const res = await arcadia.GET<{ data: ScheduledTask[] }>(BASE) + return res.data +} + +export async function getScheduledTask( + arcadia: ArcadiaClient, + id: string, +): Promise { + const res = await arcadia.GET<{ data: ScheduledTask }>(`${BASE}/${id}`) + return res.data +} + +export async function createScheduledTask( + arcadia: ArcadiaClient, + input: ScheduledTaskInput, +): Promise { + 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, +): Promise { + 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 { + await arcadia.DELETE(`${BASE}/${id}`) +} + +export async function enableScheduledTask( + arcadia: ArcadiaClient, + id: string, +): Promise { + const res = await arcadia.POST<{ data: ScheduledTask }>(`${BASE}/${id}/enable`) + return res.data +} + +export async function disableScheduledTask( + arcadia: ArcadiaClient, + id: string, +): Promise { + const res = await arcadia.POST<{ data: ScheduledTask }>(`${BASE}/${id}/disable`) + return res.data +} + +export async function triggerScheduledTask( + arcadia: ArcadiaClient, + id: string, +): Promise { + 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 { + const res = await arcadia.GET<{ data: TaskRun[] }>(`${BASE}/${id}/runs`, { + params: params as Record, + }) + return res.data +} diff --git a/app/lib/arcadia/secrets.ts b/app/lib/arcadia/secrets.ts new file mode 100644 index 0000000..469fdcb --- /dev/null +++ b/app/lib/arcadia/secrets.ts @@ -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, "value" | "name"> + +export interface RotateInput { + value: string + note?: string +} + +export async function listSecrets(arcadia: ArcadiaClient): Promise { + const res = await arcadia.GET<{ data: Secret[] }>("/api/v1/admin/secrets") + return res.data +} + +export async function getSecret(arcadia: ArcadiaClient, id: string): Promise { + const res = await arcadia.GET<{ data: Secret }>(`/api/v1/admin/secrets/${id}`) + return res.data +} + +export async function createSecret( + arcadia: ArcadiaClient, + input: SecretCreateInput, +): Promise { + 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 { + 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 { + await arcadia.DELETE(`/api/v1/admin/secrets/${id}`) +} + +export async function rotateSecret( + arcadia: ArcadiaClient, + id: string, + input: RotateInput, +): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + const res = await arcadia.GET<{ data: { value: string } }>("/api/v1/admin/secrets/generate", { + params: params as Record, + }) + 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" }, +] diff --git a/app/lib/arcadia/storage-configs.ts b/app/lib/arcadia/storage-configs.ts new file mode 100644 index 0000000..8066d7a --- /dev/null +++ b/app/lib/arcadia/storage-configs.ts @@ -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` / `arcadia.POST` / 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 + inserted_at: string + updated_at: string +} + +export interface StorageConfigInput { + name: string + backend_type: StorageBackend + config: Record + 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 + by_user: Record +} + +export interface StorageProvidersResponse { + data: Record +} + +export async function listStorageConfigs(arcadia: ArcadiaClient): Promise { + const res = await arcadia.GET<{ data: StorageConfig[] }>("/api/v1/storage_configs") + return res.data +} + +export async function getStorageConfig(arcadia: ArcadiaClient, id: string): Promise { + const res = await arcadia.GET<{ data: StorageConfig }>(`/api/v1/storage_configs/${id}`) + return res.data +} + +export async function createStorageConfig( + arcadia: ArcadiaClient, + input: StorageConfigInput, +): Promise { + 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, +): Promise { + 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 { + await arcadia.DELETE(`/api/v1/storage_configs/${id}`) +} + +export async function activateStorageConfig(arcadia: ArcadiaClient, id: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + return arcadia.POST(`/api/v1/storage_configs/${id}/validate`) +} + +export async function getStorageStats(arcadia: ArcadiaClient): Promise { + 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 = { + s3: ["secret_access_key"], + gcs: ["service_account_json"], + local: [], +} + +export const REQUIRED_FIELDS: Record = { + s3: ["bucket", "region", "access_key_id", "secret_access_key"], + gcs: ["bucket", "service_account_json"], + local: ["path"], +} + +export const OPTIONAL_FIELDS: Record = { + 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 === "***" +} diff --git a/app/lib/arcadia/user-stats.ts b/app/lib/arcadia/user-stats.ts new file mode 100644 index 0000000..d19f2a0 --- /dev/null +++ b/app/lib/arcadia/user-stats.ts @@ -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 + last_calculated_at: string | null + inserted_at: string + updated_at: string +} + +export async function getUserUsage( + arcadia: ArcadiaClient, + userId: string, +): Promise { + const res = await arcadia.GET<{ data: UserUsage }>( + `/api/v1/users/${userId}/usage`, + ) + return res.data +} + +export async function getUserQuota( + arcadia: ArcadiaClient, + userId: string, +): Promise { + 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 + } +} diff --git a/app/lib/arcadia/users.ts b/app/lib/arcadia/users.ts new file mode 100644 index 0000000..f206af0 --- /dev/null +++ b/app/lib/arcadia/users.ts @@ -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 { + 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 { + const res = await arcadia.GET<{ data: User }>(`/api/v1/users/${id}`) + return res.data +} + +export async function createUser(arcadia: ArcadiaClient, input: UserInput): Promise { + 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, +): Promise { + 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 { + await arcadia.DELETE(`/api/v1/users/${id}`) +} + +export async function assignRole( + arcadia: ArcadiaClient, + userId: string, + roleId: string, +): Promise { + 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 { + 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 { + return updateUser(arcadia, id, { status }) +} diff --git a/app/lib/arcadia/webhooks.ts b/app/lib/arcadia/webhooks.ts new file mode 100644 index 0000000..c0f6c3d --- /dev/null +++ b/app/lib/arcadia/webhooks.ts @@ -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 + 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 + 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 + 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 { + const res = await arcadia.GET<{ data: Webhook[] }>("/api/v1/webhooks") + return res.data +} + +export async function getWebhook(arcadia: ArcadiaClient, id: string): Promise { + const res = await arcadia.GET<{ data: Webhook }>(`/api/v1/webhooks/${id}`) + return res.data +} + +export async function createWebhook( + arcadia: ArcadiaClient, + input: WebhookInput, +): Promise { + 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, +): Promise { + 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 { + await arcadia.DELETE(`/api/v1/webhooks/${id}`) +} + +export async function pauseWebhook(arcadia: ArcadiaClient, id: string): Promise { + const res = await arcadia.POST<{ data: Webhook }>(`/api/v1/webhooks/${id}/pause`) + return res.data +} + +export async function resumeWebhook(arcadia: ArcadiaClient, id: string): Promise { + const res = await arcadia.POST<{ data: Webhook }>(`/api/v1/webhooks/${id}/resume`) + return res.data +} + +export async function regenerateWebhookSecret( + arcadia: ArcadiaClient, + id: string, +): Promise { + 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 { + const res = await arcadia.GET<{ data: WebhookDelivery[] }>( + `/api/v1/webhooks/${id}/deliveries`, + { params: params as Record }, + ) + return res.data +} + +export async function getWebhookStats( + arcadia: ArcadiaClient, + id: string, +): Promise { + 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", +] diff --git a/app/routes.ts b/app/routes.ts index 4c93f24..f24ca25 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -11,5 +11,10 @@ export default [ route("profile", "routes/profile.tsx"), route("login", "routes/login.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 ] satisfies RouteConfig diff --git a/app/routes/activity.tsx b/app/routes/activity.tsx index 9d43620..29ea6a1 100644 --- a/app/routes/activity.tsx +++ b/app/routes/activity.tsx @@ -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 { Button } from "~/components/ui/button" import { Card, CardContent, @@ -8,36 +25,405 @@ import { CardHeader, CardTitle, } 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 { 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() { + const session = useSession() + const arcadia = useArcadiaClient() + + const [logs, setLogs] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(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(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[]>( + () => [ + { + id: "time", + header: "Time", + accessor: "inserted_at", + sortable: true, + cell: (l) => , + }, + { + id: "user", + header: "User", + accessor: (l) => l.user?.email ?? "", + sortable: true, + cell: (l) => ( + + {l.user?.email ?? system} + + ), + }, + { + id: "action", + header: "Action", + accessor: "action", + sortable: true, + cell: (l) => ( + {l.action} + ), + }, + { + id: "resource", + header: "Resource", + accessor: "resource_type", + sortable: true, + cell: (l) => ( + + {l.resource_type} + {l.resource_id ? ( + + {l.resource_id.slice(0, 8)}… + + ) : null} + + ), + }, + { + id: "severity", + header: "Severity", + accessor: "severity", + sortable: true, + cell: (l) => , + }, + { + id: "ip", + header: "IP", + accessor: "ip_address", + cell: (l) => ( + + {l.ip_address ?? "—"} + + ), + }, + { + id: "actions", + header: "", + align: "right", + cell: (l) => ( + , + 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({ + data: logs, + columns, + getRowId: (l) => l.id, + initialPageSize: 50, + initialSearch: search, + }) + useEffect(() => { + table.setSearch(search) + }, [search, table]) + + if (!session) { + return ( + +
+ + + Sign in required + The audit log requires an admin session. + + + + + +
+
+ ) + } + return ( - - - - Activity - - Event stream, audit log, recent changes. - - - -
-
- -
-
-

No activity yet

-

- Once your app is doing things, this is where audit events, - webhook deliveries, and recent changes show up — pair with{" "} - @crema/log-ui. -

-
+ +
+
+
+

Audit log

+

+ Every authenticated action against the platform. Filter by date, severity, or + resource type. +

- - + +
+ + {error ? ( + setError(null)}> + {error} + + ) : null} + + + + +
+ + +
+
+ + setResourceFilter(e.target.value)} + placeholder="e.g. user" + className="w-40" + data-action="audit-resource-filter" + /> +
+
+ + setFrom(e.target.value)} + className="w-44" + data-action="audit-from-filter" + /> +
+
+ + setTo(e.target.value)} + className="w-44" + data-action="audit-to-filter" + /> +
+
+ + + + {table.total === 0 && !loading ? ( + } + title="No events match those filters." + description="Loosen the filter set or wait for new platform activity." + className="py-12" + /> + ) : ( + <> + l.id} + sort={table.sort} + onSortToggle={table.toggleSort} + loading={loading && logs.length > 0} + stickyHeader + /> + + + )} + +
+
+ + !o && setDetail(null)}> + + + Audit event + + {detail + ? `${detail.action} on ${detail.resource_type} at ${new Date( + detail.inserted_at, + ).toLocaleString()}` + : ""} + + + {detail ? : null} + +
) } + +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 ( +
+
+ {rows.map((r) => ( +
+
{r.k}
+
{r.v}
+
+ ))} +
+ {log.changes ? ( +
+

Changes

+
+            {JSON.stringify(log.changes, null, 2)}
+          
+
+ ) : null} + {log.metadata && Object.keys(log.metadata).length > 0 ? ( +
+

Metadata

+
+            {JSON.stringify(log.metadata, null, 2)}
+          
+
+ ) : null} +
+ ) +} + +function severityTone(s: AuditSeverity): BadgeTone { + if (s === "critical" || s === "error") return "danger" + if (s === "warning") return "warning" + return "default" +} + +function countBy(arr: T[], key: (x: T) => string): Record { + return arr.reduce>((acc, x) => { + const k = key(x) + acc[k] = (acc[k] ?? 0) + 1 + return acc + }, {}) +} diff --git a/app/routes/scheduled-tasks.tsx b/app/routes/scheduled-tasks.tsx new file mode 100644 index 0000000..de3af24 --- /dev/null +++ b/app/routes/scheduled-tasks.tsx @@ -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([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [info, setInfo] = useState(null) + const [search, setSearch] = useState("") + const [editor, setEditor] = useState(null) + const [pendingDelete, setPendingDelete] = useState(null) + const [runsFor, setRunsFor] = useState(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[]>( + () => [ + { + id: "name", + header: "Name", + accessor: "name", + sortable: true, + cell: (t) => ( +
+ {t.name} + {t.description ? ( + {t.description} + ) : null} +
+ ), + }, + { + id: "cron", + header: "Schedule", + accessor: "cron_expression", + sortable: true, + cell: (t) => ( +
+ + {t.cron_expression} + + {t.timezone} +
+ ), + }, + { + id: "action", + header: "Action", + accessor: "action_type", + sortable: true, + cell: (t) => ( + + {t.action_type} + + ), + }, + { + id: "status", + header: "Status", + accessor: "enabled", + sortable: true, + cell: (t) => ( + + ), + }, + { + id: "last", + header: "Last run", + accessor: "last_run_at", + sortable: true, + cell: (t) => + t.last_run_at ? ( + + ) : ( + never + ), + }, + { + id: "next", + header: "Next run", + accessor: "next_run_at", + sortable: true, + cell: (t) => + t.enabled && t.next_run_at ? ( + + ) : ( + + ), + }, + { + id: "actions", + header: "", + align: "right", + cell: (t) => ( + + ), + }, + ], + [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({ + data: tasks, + columns, + getRowId: (t) => t.id, + initialPageSize: 25, + initialSearch: search, + }) + useEffect(() => { + table.setSearch(search) + }, [search, table]) + + if (!session) { + return ( + +
+ + + Sign in required + + Scheduled task administration requires an admin session. + + + + + + +
+
+ ) + } + + return ( + +
+
+
+

Scheduled tasks

+

+ Cron-driven jobs run by arcadia. Trigger a task manually to test it without waiting + for the next scheduled run. +

+
+
+ + +
+
+ + {error ? ( + setError(null)}> + {error} + + ) : null} + {info ? ( + setInfo(null)}> + {info} + + ) : null} + + + + +
+ {table.total} of {tasks.length} +
+
+ + + + {table.total === 0 && !loading ? ( + } + 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" + /> + ) : ( + <> + t.id} + sort={table.sort} + onSortToggle={table.toggleSort} + loading={loading && tasks.length > 0} + stickyHeader + /> + + + )} + +
+
+ + !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) + } + }} + /> + + setEditor(null)} + onSaved={async () => { + setEditor(null) + await refresh() + }} + onError={setError} + /> + + setRunsFor(null)} onError={setError} /> +
+ ) +} + +function rowActions( + t: ScheduledTask, + ctx: { + arcadia: ReturnType + refresh: () => Promise + 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: , + 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: , + dataAction: `task-${t.id}-runs`, + onSelect: () => setRunsFor(t), + }) + + if (t.enabled) { + items.push({ + id: "disable", + label: "Disable", + icon: , + 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: , + 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: , + destructive: true, + dataAction: `task-${t.id}-delete`, + onSelect: () => setPendingDelete(t), + }) + + return items +} + +function TaskEditorDialog({ + state, + onClose, + onSaved, + onError, +}: { + state: EditorState + onClose: () => void + onSaved: () => Promise + 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("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 + 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 ( + !o && onClose()}> + + + {isEdit ? "Edit scheduled task" : "New scheduled task"} + + Cron uses standard 5-field syntax (minute hour dom month dow). Tasks run in the + specified timezone. + + + +
+
+ + setName(e.target.value)} + placeholder="Daily cleanup" + data-action="task-form-name" + /> +
+
+ + setDescription(e.target.value)} + data-action="task-form-description" + /> +
+
+ + setCron(e.target.value)} + placeholder="0 2 * * *" + data-action="task-form-cron" + spellCheck={false} + className="font-mono" + /> +
+
+ + setTimezone(e.target.value)} + placeholder="UTC" + data-action="task-form-timezone" + /> +
+
+ + +
+
+ + setTagsText(e.target.value)} + placeholder="cleanup, nightly" + data-action="task-form-tags" + /> +
+
+ +