Compare commits
43 Commits
45fa130951
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ab9b730f5 | |||
|
|
4b817b85ff | ||
|
|
06490865d3 | ||
|
|
a299900021 | ||
|
|
a74550d73f | ||
|
|
a286b9cdce | ||
|
|
c968ac0735 | ||
|
|
2ab183596c | ||
|
|
ffe3fc0473 | ||
|
|
f6a92118da | ||
|
|
c2730e3c77 | ||
|
|
725540617b | ||
|
|
5b0281574e | ||
|
|
d1469059d8 | ||
|
|
eb7bc62d14 | ||
|
|
20c592dfa7 | ||
|
|
444516e900 | ||
|
|
628691d2df | ||
|
|
f5189305c7 | ||
|
|
49a9b019fc | ||
|
|
9cbe921db7 | ||
|
|
cdb96499be | ||
|
|
50afbd7686 | ||
|
|
169acf3cdd | ||
|
|
c640721c8e | ||
|
|
c379ebc37a | ||
|
|
20494d1620 | ||
|
|
e4ed05b815 | ||
|
|
a770faf6eb | ||
|
|
2a68389121 | ||
|
|
7eb5093071 | ||
|
|
066a16bb8b | ||
|
|
4f699bb90e | ||
|
|
c0eb85d2fe | ||
|
|
b397bbcb9e | ||
|
|
5dfceeff94 | ||
|
|
bfe61c220a | ||
|
|
8e07f4b9c0 | ||
|
|
baf42c4cec | ||
|
|
29030c9e72 | ||
|
|
0fcb9e40f1 | ||
|
|
7ba415d78e | ||
|
|
a907e25a7c |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -1,5 +1,7 @@
|
||||
.DS_Store
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
/node_modules/
|
||||
|
||||
# React Router
|
||||
@@ -9,3 +11,7 @@
|
||||
.demo.log
|
||||
.demo.pid
|
||||
.boot.log
|
||||
|
||||
# Generated by `npm run build:docs` — regenerated on every full build
|
||||
# (prebuild) and on demand during dev. Don't commit the artifact.
|
||||
/public/docs-index.json
|
||||
|
||||
@@ -19,6 +19,7 @@ This file is a quick map, not a duplication of upstream docs.
|
||||
- **`useArcadiaClient()`** for typed/generic HTTP. `arcadia.typed.GET("/api/v1/...")` infers paths from the generated `paths` type; `arcadia.GET<T>(path)` is the generic escape hatch for spec-incomplete endpoints.
|
||||
- **Login** — `app/routes/login.tsx` renders `<LoginForm>` from `@crema/arcadia-auth-ui`. Successful login writes tokens via `persistFromArcadiaLogin()` in `app/lib/session.ts`, which preserves the existing `Session` shape used by `useUser` / `AppShell`.
|
||||
- **Realtime** — supported by the lib but not enabled at the provider here; pass `enableRealtime` + `userId` to opt in.
|
||||
- **Search admin sidecar** — the `/search` route (`app/routes/search.tsx`) calls arcadia-search's privileged `/admin/*` surface (default `127.0.0.1:7801`) via `app/lib/search-admin.ts`. Configured by `VITE_ARCADIA_SEARCH_ADMIN_URL` + `VITE_ARCADIA_SEARCH_ADMIN_TOKEN`; the token must match `ADMIN_TOKEN` on the search box. See `arcadia-search/README.md` § *Admin sidecar*.
|
||||
|
||||
## Scripts
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ To use it for real:
|
||||
|---|---|---|
|
||||
| `VITE_ARCADIA_URL` | `http://localhost:4000` | Base URL of arcadia-core. |
|
||||
| `VITE_ARCADIA_TENANT` | `default` | Tenant id sent as `X-Tenant-ID`. Override per-deployment. |
|
||||
| `VITE_ARCADIA_SEARCH_URL` | `http://127.0.0.1:7800` | Base URL of arcadia-search (Tantivy). |
|
||||
| `VITE_ARCADIA_SEARCH_TOKEN` | _(unset)_ | Service-principal JWT for the assistant's `search_kb`/`read_chunk` tools. Set this when arcadia-search runs in `AUTH_MODE=jwt` and doesn't share its signing secret with the arcadia issuing operator session tokens. When unset, the operator's own session JWT is used (works only with matched signing keys). |
|
||||
|
||||
## What's in here
|
||||
|
||||
|
||||
19
app/app.css
19
app/app.css
@@ -1,8 +1,12 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Instrument+Sans:ital,wght@0,400..700;1,400..700&family=Geist+Mono:wght@100..900&display=swap");
|
||||
@import url("https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Instrument+Sans:ital,wght@0,400..700;1,400..700&family=Geist+Mono:wght@100..900&family=JetBrains+Mono:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;0,6..72,600;1,6..72,400&display=swap");
|
||||
|
||||
/* Active theme — must be first so its @import url() font directives resolve
|
||||
* to the top of the output. Themes are self-contained: tokens + fonts. */
|
||||
@import "../../lib-theme-skyrise/theme.css"; /* CREMA:THEME */
|
||||
|
||||
/* Per-route alt theme — applied via [data-theme="console"] on AppShell. */
|
||||
@import "./themes/console.css";
|
||||
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
@@ -18,6 +22,19 @@
|
||||
@source "../../lib-feedback-ui/src";
|
||||
@source "../../lib-auth-ui/src";
|
||||
@source "../../lib-agent-ui/src";
|
||||
@source "../../lib-llm-providers-ui/src";
|
||||
@source "../../lib-file-ui/src";
|
||||
@source "../../lib-card-ui/src";
|
||||
@source "../../lib-dashboard-ui/src";
|
||||
@source "../../lib-chart-ui/src";
|
||||
@source "../../lib-map-ui/src";
|
||||
@source "../../lib-status-ui/src";
|
||||
@source "../../lib-data-ui/src";
|
||||
@source "../../lib-code-ui/src";
|
||||
@source "../../lib-diagram-ui/src";
|
||||
@source "../../lib-onboarding-ui/src";
|
||||
@source "../../lib-lexical-rag-ui/src";
|
||||
@source "../../lib-notification-ui/src";
|
||||
/* CREMA:SOURCES */
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
// Renders an assistant message: GFM markdown for prose, custom ```card```
|
||||
// blocks rendered as rich UI (status pills, tenant cards, KPIs), pills for
|
||||
// command-bus action blocks, and tool-result cards (role: "tool").
|
||||
// Renders an assistant message: GFM markdown for prose, plus typed fenced
|
||||
// code blocks rendered as rich UI from `@crema/*-ui` libs (charts, tables,
|
||||
// KPIs, code, diffs, status pills, callouts), pills for command-bus action
|
||||
// blocks, and tool-result cards (role: "tool").
|
||||
//
|
||||
// Typed blocks recognized (each is a fenced ```<kind>\n<json>\n``` block):
|
||||
// action — command-bus DSL (handled by extractActionBlocks; replaced
|
||||
// with a "Ran N actions" pill)
|
||||
// card — { kind: "pill" | "stat" | "callout", ... } (legacy)
|
||||
// chart-spark — { values: number[], stroke?, fill? }
|
||||
// chart-bar — { data: [{ label, value, color? }] }
|
||||
// chart-line — { series: [{ x, y }] }
|
||||
// chart-donut — { data: [{ label, value, color? }] }
|
||||
// table — { columns: [{ id, header, accessor? }], rows: [...] }
|
||||
// kpi — { items: [{ label, value, unit? }] }
|
||||
// code — { code, language?, title?, lineNumbers? }
|
||||
// diff — { oldCode, newCode, language?, title? }
|
||||
|
||||
import { type ReactNode, useMemo } from "react"
|
||||
import ReactMarkdown from "react-markdown"
|
||||
@@ -9,9 +23,33 @@ import { Sparkles, Wrench } from "lucide-react"
|
||||
|
||||
import { extractActionBlocks } from "@crema/action-bus"
|
||||
import { stripToolCallTags, type ToolCall } from "@crema/llm-ui"
|
||||
import {
|
||||
Sparkline,
|
||||
BarChart,
|
||||
LineChart,
|
||||
Donut,
|
||||
type ChartDatum,
|
||||
type SeriesPoint,
|
||||
} from "@crema/chart-ui"
|
||||
import { DataTable, type Column } from "@crema/table-ui"
|
||||
import { KPIRow } from "@crema/data-ui"
|
||||
import { CodeBlock, DiffViewer } from "@crema/code-ui"
|
||||
import { FlowChart, OrgChart } from "@crema/diagram-ui"
|
||||
import { StepTrail, type AgentStep } from "@crema/agent-ui"
|
||||
import {
|
||||
OnboardingChecklist,
|
||||
WelcomeCard,
|
||||
HintCard,
|
||||
type ChecklistTask,
|
||||
type OnboardingTone,
|
||||
} from "@crema/onboarding-ui"
|
||||
|
||||
const ACTION_BLOCK_RE = /```action\s*\n[\s\S]*?```/g
|
||||
const CARD_BLOCK_RE = /```card\s*\n([\s\S]*?)```/g
|
||||
|
||||
// Captures the kind tag and the body of every fenced block we render as UI.
|
||||
// Kept alongside the markdown ones — react-markdown ignores anything we strip.
|
||||
const TYPED_BLOCK_RE =
|
||||
/```(card|chart-spark|chart-bar|chart-line|chart-donut|table|kpi|code|diff|flowchart|orgchart|steps|checklist|welcome|hint)\s*\n([\s\S]*?)```/g
|
||||
|
||||
export type MessageBodyProps = {
|
||||
content: string
|
||||
@@ -19,33 +57,231 @@ export type MessageBodyProps = {
|
||||
toolCalls?: ToolCall[]
|
||||
}
|
||||
|
||||
type Segment =
|
||||
| { type: "prose"; text: string }
|
||||
| { type: "block"; kind: string; spec: unknown; raw: string }
|
||||
|
||||
function parseSegments(content: string): Segment[] {
|
||||
const segments: Segment[] = []
|
||||
TYPED_BLOCK_RE.lastIndex = 0
|
||||
let lastIndex = 0
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = TYPED_BLOCK_RE.exec(content)) !== null) {
|
||||
const [raw, kind, body] = match
|
||||
if (match.index > lastIndex) {
|
||||
segments.push({ type: "prose", text: content.slice(lastIndex, match.index) })
|
||||
}
|
||||
let spec: unknown = null
|
||||
try {
|
||||
spec = JSON.parse(body.trim())
|
||||
} catch {
|
||||
// malformed → emit the raw fence as prose so the user sees the model output
|
||||
segments.push({ type: "prose", text: raw })
|
||||
lastIndex = match.index + raw.length
|
||||
continue
|
||||
}
|
||||
segments.push({ type: "block", kind, spec, raw })
|
||||
lastIndex = match.index + raw.length
|
||||
}
|
||||
if (lastIndex < content.length) {
|
||||
segments.push({ type: "prose", text: content.slice(lastIndex) })
|
||||
}
|
||||
return segments
|
||||
}
|
||||
|
||||
function renderBlock(kind: string, spec: any, key: number): ReactNode {
|
||||
switch (kind) {
|
||||
case "card":
|
||||
return <CardBlock key={key} spec={spec} />
|
||||
case "chart-spark":
|
||||
return (
|
||||
<div key={key} className="my-2 inline-block text-primary">
|
||||
<Sparkline
|
||||
values={spec.values ?? []}
|
||||
width={spec.width ?? 240}
|
||||
height={spec.height ?? 48}
|
||||
stroke={spec.stroke ?? "currentColor"}
|
||||
fill={spec.fill}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
case "chart-bar":
|
||||
return (
|
||||
<ChartFrame key={key} title={spec.title}>
|
||||
<div className="text-primary">
|
||||
<BarChart data={(spec.data ?? []) as ChartDatum[]} width={spec.width ?? 360} height={spec.height ?? 180} />
|
||||
</div>
|
||||
<ChartLegend data={spec.data} />
|
||||
</ChartFrame>
|
||||
)
|
||||
case "chart-line":
|
||||
return (
|
||||
<ChartFrame key={key} title={spec.title}>
|
||||
<div className="text-primary">
|
||||
<LineChart series={(spec.series ?? []) as SeriesPoint[]} width={spec.width ?? 360} height={spec.height ?? 180} />
|
||||
</div>
|
||||
</ChartFrame>
|
||||
)
|
||||
case "chart-donut":
|
||||
return (
|
||||
<ChartFrame key={key} title={spec.title}>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-primary">
|
||||
<Donut data={(spec.data ?? []) as ChartDatum[]} size={spec.size ?? 140} thickness={spec.thickness ?? 20} />
|
||||
</div>
|
||||
<ChartLegend data={spec.data} />
|
||||
</div>
|
||||
</ChartFrame>
|
||||
)
|
||||
case "table":
|
||||
return <TableBlock key={key} spec={spec} />
|
||||
case "kpi":
|
||||
return (
|
||||
<div key={key} className="my-3">
|
||||
<KPIRow items={spec.items ?? []} />
|
||||
</div>
|
||||
)
|
||||
case "code":
|
||||
return (
|
||||
<div key={key} className="my-3">
|
||||
<CodeBlock
|
||||
code={spec.code ?? ""}
|
||||
language={spec.language}
|
||||
title={spec.title}
|
||||
showLineNumbers={spec.lineNumbers ?? false}
|
||||
highlightLines={spec.highlightLines}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
case "diff":
|
||||
return (
|
||||
<div key={key} className="my-3">
|
||||
<DiffViewer
|
||||
oldCode={spec.oldCode ?? ""}
|
||||
newCode={spec.newCode ?? ""}
|
||||
language={spec.language}
|
||||
title={spec.title}
|
||||
mode={spec.mode ?? "unified"}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
case "flowchart":
|
||||
return (
|
||||
<div key={key} className="my-3 overflow-x-auto rounded-lg border bg-card/50 p-3">
|
||||
<FlowChart nodes={spec.nodes ?? []} edges={spec.edges ?? []} />
|
||||
</div>
|
||||
)
|
||||
case "orgchart":
|
||||
return (
|
||||
<div key={key} className="my-3 overflow-x-auto rounded-lg border bg-card/50 p-3">
|
||||
<OrgChart data={spec.data} horizontal={spec.horizontal} />
|
||||
</div>
|
||||
)
|
||||
case "steps":
|
||||
return (
|
||||
<div key={key} className="my-3 rounded-lg border bg-card/50 p-3">
|
||||
<StepTrail steps={(spec.steps ?? []) as AgentStep[]} />
|
||||
</div>
|
||||
)
|
||||
case "checklist":
|
||||
return (
|
||||
<div key={key} className="my-3">
|
||||
<OnboardingChecklist
|
||||
title={spec.title}
|
||||
description={spec.description}
|
||||
tasks={(spec.tasks ?? []) as ChecklistTask[]}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
case "welcome":
|
||||
return (
|
||||
<div key={key} className="my-3">
|
||||
<WelcomeCard
|
||||
title={spec.title}
|
||||
description={spec.description}
|
||||
badge={spec.badge}
|
||||
primaryAction={spec.primaryAction}
|
||||
secondaryAction={spec.secondaryAction}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
case "hint":
|
||||
return (
|
||||
<div key={key} className="my-3">
|
||||
<HintCard
|
||||
title={spec.title}
|
||||
tone={(spec.tone ?? "info") as OnboardingTone}
|
||||
action={spec.action}
|
||||
>
|
||||
{spec.body ?? ""}
|
||||
</HintCard>
|
||||
</div>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<pre key={key} className="my-2 rounded-md border border-border/60 bg-muted/40 p-2 text-[11px] font-mono text-muted-foreground">
|
||||
{JSON.stringify(spec, null, 2)}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function ChartFrame({ title, children }: { title?: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="my-3 rounded-lg border bg-card/50 p-3">
|
||||
{title && <div className="mb-2 text-xs font-medium text-muted-foreground">{title}</div>}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ChartLegend({ data }: { data?: ChartDatum[] }) {
|
||||
if (!data || data.length === 0) return null
|
||||
return (
|
||||
<ul className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
{data.map((d) => (
|
||||
<li key={d.label} className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block size-2 rounded-sm"
|
||||
style={{ background: d.color ?? "currentColor" }}
|
||||
/>
|
||||
<span>{d.label}</span>
|
||||
<span className="tabular-nums text-foreground/70">{d.value}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
function TableBlock({ spec }: { spec: any }) {
|
||||
const rows: Record<string, unknown>[] = Array.isArray(spec.rows) ? spec.rows : []
|
||||
const columns: Column<Record<string, unknown>>[] = (spec.columns ?? []).map((c: any) => ({
|
||||
id: c.id,
|
||||
header: c.header ?? c.id,
|
||||
accessor: c.accessor ?? c.id,
|
||||
sortable: c.sortable ?? true,
|
||||
align: c.align,
|
||||
}))
|
||||
const idKey = spec.idKey ?? columns[0]?.id ?? "id"
|
||||
return (
|
||||
<div className="my-3 overflow-x-auto rounded-lg border bg-card/50">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
getRowId={(r) => String(r[idKey] ?? Math.random())}
|
||||
density="compact"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type CardSpec =
|
||||
| { kind: "pill"; status: string; label?: string }
|
||||
| { kind: "stat"; label: string; value: string | number; tone?: string }
|
||||
| { kind: "callout"; title?: string; tone?: "info" | "warning" | "danger" | "success"; body?: string }
|
||||
| { kind: string; [k: string]: unknown }
|
||||
|
||||
function parseCardBlocks(content: string): { blocks: CardSpec[]; stripped: string } {
|
||||
const blocks: CardSpec[] = []
|
||||
CARD_BLOCK_RE.lastIndex = 0
|
||||
const stripped = content.replace(CARD_BLOCK_RE, (_, body: string) => {
|
||||
try {
|
||||
const parsed = JSON.parse(body.trim()) as CardSpec
|
||||
if (parsed && typeof parsed === "object" && typeof parsed.kind === "string") {
|
||||
blocks.push(parsed)
|
||||
return "" // strip from prose
|
||||
}
|
||||
} catch {
|
||||
// malformed — leave the original block in the prose so the user can see
|
||||
// what the model tried to emit.
|
||||
return _
|
||||
}
|
||||
return _
|
||||
})
|
||||
return { blocks, stripped }
|
||||
}
|
||||
|
||||
function renderCard(spec: CardSpec): ReactNode {
|
||||
function CardBlock({ spec }: { spec: CardSpec }) {
|
||||
switch (spec.kind) {
|
||||
case "pill": {
|
||||
const s = spec as { kind: "pill"; status: string; label?: string }
|
||||
@@ -58,9 +294,7 @@ function renderCard(spec: CardSpec): ReactNode {
|
||||
? "border-rose-500/40 bg-rose-500/15 text-rose-700 dark:text-rose-300"
|
||||
: "border-border bg-muted text-muted-foreground"
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium ${tone}`}
|
||||
>
|
||||
<span className={`my-1 inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium ${tone}`}>
|
||||
{s.label ?? s.status}
|
||||
</span>
|
||||
)
|
||||
@@ -68,19 +302,14 @@ function renderCard(spec: CardSpec): ReactNode {
|
||||
case "stat": {
|
||||
const s = spec as { kind: "stat"; label: string; value: string | number }
|
||||
return (
|
||||
<span className="inline-flex items-baseline gap-1.5 rounded-md border bg-card px-2 py-1 text-sm">
|
||||
<span className="my-1 inline-flex items-baseline gap-1.5 rounded-md border bg-card px-2 py-1 text-sm">
|
||||
<span className="text-xs text-muted-foreground">{s.label}</span>
|
||||
<span className="font-semibold tabular-nums">{s.value}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
case "callout": {
|
||||
const s = spec as {
|
||||
kind: "callout"
|
||||
title?: string
|
||||
tone?: "info" | "warning" | "danger" | "success"
|
||||
body?: string
|
||||
}
|
||||
const s = spec as { kind: "callout"; title?: string; tone?: "info" | "warning" | "danger" | "success"; body?: string }
|
||||
const tone = s.tone ?? "info"
|
||||
const palette: Record<string, string> = {
|
||||
info: "border-sky-500/40 bg-sky-500/10",
|
||||
@@ -89,7 +318,7 @@ function renderCard(spec: CardSpec): ReactNode {
|
||||
success: "border-emerald-500/40 bg-emerald-500/10",
|
||||
}
|
||||
return (
|
||||
<div className={`rounded-md border px-3 py-2 text-sm ${palette[tone]}`}>
|
||||
<div className={`my-2 rounded-md border px-3 py-2 text-sm ${palette[tone]}`}>
|
||||
{s.title && <div className="mb-1 font-medium">{s.title}</div>}
|
||||
{s.body && <div className="text-muted-foreground">{s.body}</div>}
|
||||
</div>
|
||||
@@ -97,24 +326,67 @@ function renderCard(spec: CardSpec): ReactNode {
|
||||
}
|
||||
default:
|
||||
return (
|
||||
<pre className="rounded-md border border-border/60 bg-muted/40 p-2 text-[11px] font-mono text-muted-foreground">
|
||||
<pre className="my-2 rounded-md border border-border/60 bg-muted/40 p-2 text-[11px] font-mono text-muted-foreground">
|
||||
{JSON.stringify(spec, null, 2)}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const PROSE_COMPONENTS = {
|
||||
p: ({ children }: any) => <p className="my-1.5 leading-relaxed">{children}</p>,
|
||||
code: ({ children, className }: any) => {
|
||||
const isBlock = className?.startsWith("language-")
|
||||
if (isBlock) {
|
||||
return (
|
||||
<pre className="my-2 overflow-x-auto rounded-md bg-muted p-3 text-xs">
|
||||
<code className="font-mono">{children}</code>
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
return <code className="rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]">{children}</code>
|
||||
},
|
||||
ul: ({ children }: any) => <ul className="my-1.5 list-disc pl-5">{children}</ul>,
|
||||
ol: ({ children }: any) => <ol className="my-1.5 list-decimal pl-5">{children}</ol>,
|
||||
li: ({ children }: any) => <li className="my-0.5">{children}</li>,
|
||||
a: ({ children, href }: any) => (
|
||||
<a href={href} className="text-primary underline underline-offset-2" target="_blank" rel="noreferrer">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
table: ({ children }: any) => (
|
||||
<div className="my-2 overflow-x-auto rounded-md border">
|
||||
<table className="w-full text-sm">{children}</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ children }: any) => <thead className="bg-muted/50 text-xs text-muted-foreground">{children}</thead>,
|
||||
th: ({ children }: any) => <th className="px-3 py-2 text-left font-medium">{children}</th>,
|
||||
td: ({ children }: any) => <td className="border-t px-3 py-2">{children}</td>,
|
||||
input: ({ checked, type, ...rest }: any) =>
|
||||
type === "checkbox" ? (
|
||||
<input type="checkbox" checked={!!checked} readOnly {...rest} className="mr-1.5 align-middle" />
|
||||
) : (
|
||||
<input type={type} {...rest} />
|
||||
),
|
||||
}
|
||||
|
||||
function ProseChunk({ text }: { text: string }) {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed) return null
|
||||
return (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={PROSE_COMPONENTS}>
|
||||
{trimmed}
|
||||
</ReactMarkdown>
|
||||
)
|
||||
}
|
||||
|
||||
export function MessageBody({ content, isToolResult, toolCalls }: MessageBodyProps) {
|
||||
const { prose, actionCount, cardBlocks } = useMemo(() => {
|
||||
const { segments, actionCount } = useMemo(() => {
|
||||
const blocks = extractActionBlocks(content)
|
||||
const cleaned = stripToolCallTags(content)
|
||||
.replace(ACTION_BLOCK_RE, "")
|
||||
.trim()
|
||||
const { blocks: cardBlocks, stripped } = parseCardBlocks(cleaned)
|
||||
const cleaned = stripToolCallTags(content).replace(ACTION_BLOCK_RE, "")
|
||||
return {
|
||||
prose: stripped.trim(),
|
||||
segments: parseSegments(cleaned),
|
||||
actionCount: blocks.length,
|
||||
cardBlocks,
|
||||
}
|
||||
}, [content])
|
||||
|
||||
@@ -134,76 +406,8 @@ export function MessageBody({ content, isToolResult, toolCalls }: MessageBodyPro
|
||||
|
||||
return (
|
||||
<div className="prose prose-sm max-w-none dark:prose-invert">
|
||||
{prose && (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
p: ({ children }) => <p className="my-1.5 leading-relaxed">{children}</p>,
|
||||
code: ({ children, className }) => {
|
||||
const isBlock = className?.startsWith("language-")
|
||||
if (isBlock) {
|
||||
return (
|
||||
<pre className="my-2 overflow-x-auto rounded-md bg-muted p-3 text-xs">
|
||||
<code className="font-mono">{children}</code>
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]">
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
},
|
||||
ul: ({ children }) => <ul className="my-1.5 list-disc pl-5">{children}</ul>,
|
||||
ol: ({ children }) => <ol className="my-1.5 list-decimal pl-5">{children}</ol>,
|
||||
li: ({ children }) => <li className="my-0.5">{children}</li>,
|
||||
a: ({ children, href }) => (
|
||||
<a
|
||||
href={href}
|
||||
className="text-primary underline underline-offset-2"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
table: ({ children }) => (
|
||||
<div className="my-2 overflow-x-auto rounded-md border">
|
||||
<table className="w-full text-sm">{children}</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ children }) => (
|
||||
<thead className="bg-muted/50 text-xs text-muted-foreground">{children}</thead>
|
||||
),
|
||||
th: ({ children }) => (
|
||||
<th className="px-3 py-2 text-left font-medium">{children}</th>
|
||||
),
|
||||
td: ({ children }) => <td className="border-t px-3 py-2">{children}</td>,
|
||||
input: ({ checked, type, ...rest }) =>
|
||||
type === "checkbox" ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!checked}
|
||||
readOnly
|
||||
{...rest}
|
||||
className="mr-1.5 align-middle"
|
||||
/>
|
||||
) : (
|
||||
<input type={type} {...rest} />
|
||||
),
|
||||
}}
|
||||
>
|
||||
{prose}
|
||||
</ReactMarkdown>
|
||||
)}
|
||||
{cardBlocks.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
{cardBlocks.map((spec, i) => (
|
||||
<span key={i} className={spec.kind === "callout" ? "block w-full" : ""}>
|
||||
{renderCard(spec)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{segments.map((seg, i) =>
|
||||
seg.type === "prose" ? <ProseChunk key={i} text={seg.text} /> : renderBlock(seg.kind, seg.spec, i),
|
||||
)}
|
||||
{actionCount > 0 && (
|
||||
<span
|
||||
|
||||
33
app/components/auth/auth-shell.tsx
Normal file
33
app/components/auth/auth-shell.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { type ReactNode } from "react"
|
||||
|
||||
import { useBrand } from "~/lib/identity"
|
||||
|
||||
export function AuthShell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className="dark relative isolate flex min-h-svh items-center justify-center p-4"
|
||||
style={{ background: "var(--background)" }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AuthBrand() {
|
||||
const brand = useBrand()
|
||||
const BrandIcon = brand.icon
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="flex size-8 items-center justify-center rounded-lg"
|
||||
style={{
|
||||
background: "var(--primary)",
|
||||
color: "var(--primary-foreground)",
|
||||
}}
|
||||
>
|
||||
<BrandIcon className="size-4" />
|
||||
</span>
|
||||
<span className="text-sm font-semibold">{brand.name}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
|
||||
const SIDEBAR_KEY = "crema.shell.sidebar"
|
||||
import { NavLink, useNavigate } from "react-router"
|
||||
const NAV_GROUPS_KEY = "crema.shell.nav-groups"
|
||||
import { NavLink, useLocation, useNavigate } from "react-router"
|
||||
import {
|
||||
Bell,
|
||||
LayoutDashboard,
|
||||
@@ -20,6 +21,26 @@ import {
|
||||
HelpCircle,
|
||||
Menu,
|
||||
Play,
|
||||
HardDrive,
|
||||
Users as UsersIcon,
|
||||
KeyRound,
|
||||
Webhook as WebhookIcon,
|
||||
CalendarClock,
|
||||
Gauge,
|
||||
UserCheck,
|
||||
Network,
|
||||
Building,
|
||||
ShieldCheck,
|
||||
Megaphone,
|
||||
AlertOctagon,
|
||||
SearchCode,
|
||||
ChevronDown,
|
||||
Database,
|
||||
Plug,
|
||||
MessageSquare,
|
||||
Eye,
|
||||
LayoutGrid,
|
||||
CreditCard,
|
||||
// CREMA:NAV-ICONS
|
||||
} from "lucide-react"
|
||||
|
||||
@@ -48,6 +69,7 @@ import {
|
||||
} from "~/components/ui/popover"
|
||||
import { profileInitials, useProfile } from "~/lib/profile"
|
||||
import { signOut, useSession } from "~/lib/session"
|
||||
import { capabilityForPath, useCapabilities } from "~/lib/capabilities"
|
||||
import {
|
||||
addNotification,
|
||||
dismiss,
|
||||
@@ -77,6 +99,7 @@ import {
|
||||
SheetTrigger,
|
||||
} from "~/components/ui/sheet"
|
||||
import { ScriptsDialog, useScriptsHotkey } from "~/components/scripts-dialog"
|
||||
import { RouteGuard } from "~/components/route-guard"
|
||||
|
||||
type NavItem = {
|
||||
to: string
|
||||
@@ -85,17 +108,113 @@ type NavItem = {
|
||||
end?: boolean
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
type NavGroup = {
|
||||
key: string
|
||||
label: string
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
items: NavItem[]
|
||||
}
|
||||
|
||||
// Pinned items render flat at the top of the rail, above any groups.
|
||||
const pinnedTop: NavItem[] = [
|
||||
{ to: "/", icon: LayoutDashboard, label: "Overview", end: true },
|
||||
{ to: "/tenants", icon: Building2, label: "Tenants" },
|
||||
{ to: "/activity", icon: Activity, label: "Audit log" },
|
||||
{ to: "/ai", icon: Bot, label: "AI" },
|
||||
]
|
||||
|
||||
// Pinned items render flat at the bottom of the rail, below all groups.
|
||||
const pinnedBottom: NavItem[] = [
|
||||
{ to: "/settings", icon: Settings, label: "Settings" },
|
||||
]
|
||||
|
||||
const navGroups: NavGroup[] = [
|
||||
{
|
||||
key: "tenancy",
|
||||
label: "Tenancy",
|
||||
icon: Building2,
|
||||
items: [
|
||||
{ to: "/tenants", icon: Building2, label: "Tenants" },
|
||||
{ to: "/memberships", icon: UserCheck, label: "Memberships" },
|
||||
{ to: "/organizations", icon: Building, label: "Organizations" },
|
||||
{ to: "/users", icon: UsersIcon, label: "Users" },
|
||||
{ to: "/sso", icon: ShieldCheck, label: "SSO" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "billing",
|
||||
label: "Billing",
|
||||
icon: CreditCard,
|
||||
items: [
|
||||
{ to: "/apps", icon: LayoutGrid, label: "Apps" },
|
||||
{ to: "/plan", icon: CreditCard, label: "Plan" },
|
||||
{ to: "/entitlements", icon: Gauge, label: "Entitlements" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "data",
|
||||
label: "Data",
|
||||
icon: Database,
|
||||
items: [
|
||||
{ to: "/storage", icon: HardDrive, label: "Storage" },
|
||||
{ to: "/buckets", icon: Boxes, label: "Buckets" },
|
||||
{ to: "/secrets", icon: KeyRound, label: "Secrets" },
|
||||
{ to: "/integrations", icon: Plug, label: "Integrations" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "integrations",
|
||||
label: "Integrations",
|
||||
icon: Plug,
|
||||
items: [
|
||||
{ to: "/webhooks", icon: WebhookIcon, label: "Webhooks" },
|
||||
{ to: "/scheduled-tasks", icon: CalendarClock, label: "Scheduled" },
|
||||
{ to: "/networking", icon: Network, label: "Networking" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "comms",
|
||||
label: "Communications",
|
||||
icon: MessageSquare,
|
||||
items: [
|
||||
{ to: "/announcements", icon: Megaphone, label: "Announcements" },
|
||||
{ to: "/status-page", icon: AlertOctagon, label: "Status page" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "observability",
|
||||
label: "Observability",
|
||||
icon: Eye,
|
||||
items: [
|
||||
{ to: "/monitoring", icon: Gauge, label: "Monitoring" },
|
||||
{ to: "/activity", icon: Activity, label: "Audit log" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "ai",
|
||||
label: "AI & Search",
|
||||
icon: Sparkles,
|
||||
items: [
|
||||
{ to: "/ai", icon: Bot, label: "AI" },
|
||||
{ to: "/search", icon: SearchCode, label: "Search" },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// Items appended by `crema add <lib>` land here. Rendered ungrouped at
|
||||
// the bottom of the groups, above the pinned footer.
|
||||
const extraNavItems: NavItem[] = [
|
||||
// CREMA:NAV-ITEMS
|
||||
]
|
||||
|
||||
function readNavGroupState(): Record<string, boolean> {
|
||||
if (typeof window === "undefined") return {}
|
||||
try {
|
||||
const raw = localStorage.getItem(NAV_GROUPS_KEY)
|
||||
return raw ? (JSON.parse(raw) as Record<string, boolean>) : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
type AppShellProps = {
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
brand?: Brand
|
||||
user?: User
|
||||
@@ -108,7 +227,6 @@ type AppShellProps = {
|
||||
}
|
||||
|
||||
export function AppShell({
|
||||
title,
|
||||
children,
|
||||
brand: brandOverride,
|
||||
user: userOverride,
|
||||
@@ -118,16 +236,14 @@ export function AppShell({
|
||||
const defaultUser = useUser()
|
||||
const profile = useProfile()
|
||||
const session = useSession()
|
||||
const caps = useCapabilities()
|
||||
const navigate = useNavigate()
|
||||
const brand = brandOverride ?? defaultBrand
|
||||
// Prefer the live session for identity, fall back to the editable profile,
|
||||
// fall back to the stub user.
|
||||
// Prefer the live session for identity, fall back to the stub user.
|
||||
const user = userOverride ?? {
|
||||
name: session?.name || profile.name || defaultUser.name,
|
||||
email: session?.email || profile.email || defaultUser.email,
|
||||
initials: profileInitials(
|
||||
session?.name || profile.name || defaultUser.name,
|
||||
),
|
||||
name: session?.name || defaultUser.name,
|
||||
email: session?.email || defaultUser.email,
|
||||
initials: profileInitials(session?.name || defaultUser.name),
|
||||
}
|
||||
|
||||
// Protected shell: bounce to /login when there's no session.
|
||||
@@ -140,7 +256,9 @@ export function AppShell({
|
||||
navigate(`/login?next=${next}`, { replace: true })
|
||||
}
|
||||
}, [session, navigate])
|
||||
if (!session) return null
|
||||
// All hooks must run unconditionally — keep them above the session
|
||||
// short-circuit so a sign-out doesn't reduce the hook count and trip
|
||||
// React's "rendered fewer hooks than expected" check.
|
||||
const [expanded, setExpanded] = useState<boolean>(() => {
|
||||
if (typeof window === "undefined") return false
|
||||
return localStorage.getItem(SIDEBAR_KEY) === "1"
|
||||
@@ -150,10 +268,80 @@ export function AppShell({
|
||||
}, [expanded])
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
const [scriptsOpen, setScriptsOpen] = useState(false)
|
||||
const BrandIcon = brand.icon
|
||||
|
||||
useScriptsHotkey(() => setScriptsOpen(true))
|
||||
|
||||
const location = useLocation()
|
||||
|
||||
// Filter the nav by what the active session can actually reach. A
|
||||
// capability map exists for every protected route — items without one
|
||||
// (or whose capability isn't held) are dropped here, so the sidebar
|
||||
// doesn't advertise routes the user will only hit a 403 from.
|
||||
const allowed = (item: NavItem): boolean => {
|
||||
const cap = capabilityForPath(item.to)
|
||||
if (!cap) return true // unknown routes default to visible
|
||||
return caps.has(cap)
|
||||
}
|
||||
const visiblePinnedTop = useMemo(
|
||||
() => pinnedTop.filter(allowed),
|
||||
[caps],
|
||||
)
|
||||
const visiblePinnedBottom = useMemo(
|
||||
() => pinnedBottom.filter(allowed),
|
||||
[caps],
|
||||
)
|
||||
const visibleNavGroups: NavGroup[] = useMemo(
|
||||
() =>
|
||||
navGroups
|
||||
.map((g) => ({ ...g, items: g.items.filter(allowed) }))
|
||||
.filter((g) => g.items.length > 0),
|
||||
[caps],
|
||||
)
|
||||
const visibleExtraItems = useMemo(
|
||||
() => extraNavItems.filter(allowed),
|
||||
[caps],
|
||||
)
|
||||
const visibleAllNavItems: NavItem[] = useMemo(
|
||||
() => [
|
||||
...visiblePinnedTop,
|
||||
...visibleNavGroups.flatMap((g) => g.items),
|
||||
...visibleExtraItems,
|
||||
...visiblePinnedBottom,
|
||||
],
|
||||
[visiblePinnedTop, visibleNavGroups, visibleExtraItems, visiblePinnedBottom],
|
||||
)
|
||||
|
||||
const activeGroupKey = useMemo(
|
||||
() =>
|
||||
visibleNavGroups.find((g) =>
|
||||
g.items.some((it) => location.pathname.startsWith(it.to)),
|
||||
)?.key ?? null,
|
||||
[location.pathname, visibleNavGroups],
|
||||
)
|
||||
|
||||
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>(() =>
|
||||
readNavGroupState(),
|
||||
)
|
||||
|
||||
// Auto-open the group that owns the current route on first mount or
|
||||
// navigation, but never auto-close — the user's explicit toggles win.
|
||||
useEffect(() => {
|
||||
if (!activeGroupKey) return
|
||||
setOpenGroups((prev) =>
|
||||
prev[activeGroupKey] ? prev : { ...prev, [activeGroupKey]: true },
|
||||
)
|
||||
}, [activeGroupKey])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
localStorage.setItem(NAV_GROUPS_KEY, JSON.stringify(openGroups))
|
||||
}, [openGroups])
|
||||
|
||||
const toggleGroup = (key: string) =>
|
||||
setOpenGroups((prev) => ({ ...prev, [key]: !prev[key] }))
|
||||
|
||||
if (!session) return null
|
||||
const BrandIcon = brand.icon
|
||||
|
||||
return (
|
||||
<div
|
||||
data-theme={theme}
|
||||
@@ -195,31 +383,67 @@ export function AppShell({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<nav className="flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto p-2">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
<nav className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-2">
|
||||
{expanded ? (
|
||||
<>
|
||||
{visiblePinnedTop.map((item) => (
|
||||
<NavRow key={item.label} item={item} expanded />
|
||||
))}
|
||||
|
||||
{visibleNavGroups.map((group) => {
|
||||
const isOpen = !!openGroups[group.key]
|
||||
const GroupIcon = group.icon
|
||||
return (
|
||||
<NavLink
|
||||
key={item.label}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
title={expanded ? undefined : item.label}
|
||||
data-action={`nav-${item.label.toLowerCase()}`}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2 font-medium transition-colors duration-fast ease-standard",
|
||||
expanded ? "justify-start" : "justify-center",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
||||
].join(" ")
|
||||
}
|
||||
<div key={group.key} className="mt-3 flex flex-col gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
data-action={`nav-group-${group.key}`}
|
||||
onClick={() => toggleGroup(group.key)}
|
||||
aria-expanded={isOpen}
|
||||
className="flex items-center gap-2 rounded-md px-3 py-1.5 text-left text-caption font-semibold uppercase tracking-wider text-muted-foreground/70 transition-colors duration-fast ease-standard hover:text-foreground"
|
||||
>
|
||||
<Icon className="size-5 shrink-0" />
|
||||
{expanded && <span className="truncate">{item.label}</span>}
|
||||
</NavLink>
|
||||
<GroupIcon className="size-3.5 shrink-0" />
|
||||
<span className="flex-1 truncate">{group.label}</span>
|
||||
<ChevronDown
|
||||
className={[
|
||||
"size-3.5 shrink-0 transition-transform duration-fast ease-standard",
|
||||
isOpen ? "" : "-rotate-90",
|
||||
].join(" ")}
|
||||
/>
|
||||
</button>
|
||||
{isOpen ? (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{group.items.map((item) => (
|
||||
<NavRow key={item.label} item={item} expanded inGroup />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{visibleExtraItems.length > 0 ? (
|
||||
<div className="mt-1.5 flex flex-col gap-0.5">
|
||||
{visibleExtraItems.map((item) => (
|
||||
<NavRow key={item.label} item={item} expanded />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-auto flex flex-col gap-0.5 pt-2">
|
||||
{visiblePinnedBottom.map((item) => (
|
||||
<NavRow key={item.label} item={item} expanded />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
// Icon-only rail: flat list, no group headers.
|
||||
<>
|
||||
{visibleAllNavItems.map((item) => (
|
||||
<NavRow key={item.label} item={item} expanded={false} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div className="shrink-0 border-t p-2">
|
||||
@@ -253,8 +477,11 @@ export function AppShell({
|
||||
>
|
||||
<Menu className="size-5" />
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="w-72 p-0">
|
||||
<SheetHeader className="border-b">
|
||||
<SheetContent
|
||||
side="left"
|
||||
className="flex h-svh w-72 flex-col p-0"
|
||||
>
|
||||
<SheetHeader className="shrink-0 border-b">
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
<div className="flex size-7 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
||||
<BrandIcon className="size-4" />
|
||||
@@ -262,30 +489,81 @@ export function AppShell({
|
||||
{brand.name}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<nav className="flex flex-col gap-1 p-2">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<NavLink
|
||||
<nav className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-2">
|
||||
{visiblePinnedTop.map((item) => (
|
||||
<NavRow
|
||||
key={item.label}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
data-action={`nav-mobile-${item.label.toLowerCase()}`}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2 font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
||||
].join(" ")
|
||||
}
|
||||
item={item}
|
||||
expanded
|
||||
mobile
|
||||
onNavigate={() => setMobileOpen(false)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{visibleNavGroups.map((group) => {
|
||||
const isOpen = !!openGroups[group.key]
|
||||
const GroupIcon = group.icon
|
||||
return (
|
||||
<div key={group.key} className="mt-3 flex flex-col gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
data-action={`nav-mobile-group-${group.key}`}
|
||||
onClick={() => toggleGroup(group.key)}
|
||||
aria-expanded={isOpen}
|
||||
className="flex items-center gap-2 rounded-md px-3 py-1.5 text-left text-caption font-semibold uppercase tracking-wider text-muted-foreground/70 transition-colors duration-fast ease-standard hover:text-foreground"
|
||||
>
|
||||
<Icon className="size-5 shrink-0" />
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
<GroupIcon className="size-3.5 shrink-0" />
|
||||
<span className="flex-1 truncate">{group.label}</span>
|
||||
<ChevronDown
|
||||
className={[
|
||||
"size-3.5 shrink-0 transition-transform duration-fast ease-standard",
|
||||
isOpen ? "" : "-rotate-90",
|
||||
].join(" ")}
|
||||
/>
|
||||
</button>
|
||||
{isOpen ? (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{group.items.map((item) => (
|
||||
<NavRow
|
||||
key={item.label}
|
||||
item={item}
|
||||
expanded
|
||||
mobile
|
||||
inGroup
|
||||
onNavigate={() => setMobileOpen(false)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{visibleExtraItems.length > 0 ? (
|
||||
<div className="mt-1.5 flex flex-col gap-0.5">
|
||||
{visibleExtraItems.map((item) => (
|
||||
<NavRow
|
||||
key={item.label}
|
||||
item={item}
|
||||
expanded
|
||||
mobile
|
||||
onNavigate={() => setMobileOpen(false)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-auto flex flex-col gap-0.5 pt-2">
|
||||
{visiblePinnedBottom.map((item) => (
|
||||
<NavRow
|
||||
key={item.label}
|
||||
item={item}
|
||||
expanded
|
||||
mobile
|
||||
onNavigate={() => setMobileOpen(false)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
@@ -318,7 +596,11 @@ export function AppShell({
|
||||
>
|
||||
<Avatar className="size-7 cursor-pointer">
|
||||
{profile.avatarUrl ? (
|
||||
<AvatarImage src={profile.avatarUrl} alt={user.name} />
|
||||
<AvatarImage
|
||||
key={profile.avatarUrl}
|
||||
src={profile.avatarUrl}
|
||||
alt={user.name}
|
||||
/>
|
||||
) : null}
|
||||
<AvatarFallback>{user.initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
@@ -366,9 +648,16 @@ export function AppShell({
|
||||
<div
|
||||
id="main-content"
|
||||
tabIndex={-1}
|
||||
className="flex flex-1 flex-col gap-6 p-6 focus:outline-none"
|
||||
className="flex flex-1 flex-col focus:outline-none"
|
||||
>
|
||||
{children}
|
||||
{/* Centered content column. Caps line lengths and frames pages
|
||||
on wide displays so the canvas reads as composed instead of
|
||||
one floating card in a sea of black. The floating actions
|
||||
pill is fixed to the viewport edge and lives outside this
|
||||
column, so it stays clear regardless of cap width. */}
|
||||
<div className="mx-auto flex w-full max-w-[1180px] flex-1 flex-col gap-6 p-6 [&>*:first-child]:lg:pr-72">
|
||||
<RouteGuard>{children}</RouteGuard>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -378,6 +667,59 @@ export function AppShell({
|
||||
)
|
||||
}
|
||||
|
||||
function NavRow({
|
||||
item,
|
||||
expanded,
|
||||
mobile = false,
|
||||
inGroup = false,
|
||||
onNavigate,
|
||||
}: {
|
||||
item: NavItem
|
||||
expanded: boolean
|
||||
mobile?: boolean
|
||||
/** True when rendered inside a collapsible group — hides the per-item
|
||||
* icon and indents the label so it aligns under the group header. */
|
||||
inGroup?: boolean
|
||||
onNavigate?: () => void
|
||||
}) {
|
||||
const Icon = item.icon
|
||||
const prefix = mobile ? "nav-mobile-" : "nav-"
|
||||
// Icons are hidden inside groups in the expanded rail. The collapsed
|
||||
// icon-only rail (expanded=false) always shows icons regardless.
|
||||
const showIcon = !inGroup || !expanded
|
||||
return (
|
||||
<NavLink
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
title={expanded ? undefined : item.label}
|
||||
onClick={onNavigate}
|
||||
data-action={`${prefix}${item.label.toLowerCase()}`}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
"relative flex items-center gap-3 rounded-lg py-2 text-sm font-medium transition-colors duration-fast ease-standard",
|
||||
// 2px left accent rail on active. Absolute-positioned so the rail
|
||||
// anchors to the rail's edge regardless of per-item left padding,
|
||||
// and a fixed 14px height keeps it from filling tall rows.
|
||||
"before:absolute before:left-0 before:top-1/2 before:h-3.5 before:w-[2px] before:-translate-y-1/2 before:rounded-r-full before:bg-primary before:opacity-0 before:transition-opacity before:duration-fast",
|
||||
expanded
|
||||
? inGroup
|
||||
? // Indent the label by chevron(12) + gap(8) = 20px so it
|
||||
// visually aligns under the group header label.
|
||||
"justify-start pl-[1.625rem] pr-3"
|
||||
: "justify-start px-3"
|
||||
: "justify-center px-3",
|
||||
isActive
|
||||
? "bg-primary/[0.08] text-primary before:opacity-100"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
||||
].join(" ")
|
||||
}
|
||||
>
|
||||
{showIcon ? <Icon className="size-5 shrink-0" /> : null}
|
||||
{expanded ? <span className="truncate">{item.label}</span> : null}
|
||||
</NavLink>
|
||||
)
|
||||
}
|
||||
|
||||
function NotificationDispatcher() {
|
||||
// Hidden bridge so the action bus can create real notifications:
|
||||
// fill notify-title "Hello"
|
||||
|
||||
36
app/components/layout/page-header.tsx
Normal file
36
app/components/layout/page-header.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { type ReactNode } from "react"
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: ReactNode
|
||||
description?: ReactNode
|
||||
/** Inline indicators after the title (badges, status pills). */
|
||||
badges?: ReactNode
|
||||
/** Toolbar rendered below the title row — primary actions go here. */
|
||||
actions?: ReactNode
|
||||
}
|
||||
|
||||
// Right-side space for the appbar's floating actions pill is reserved by
|
||||
// the AppShell's first-child padding rule, not here — keep this layout
|
||||
// concerned only with title/description/actions composition.
|
||||
|
||||
export function PageHeader({
|
||||
title,
|
||||
description,
|
||||
badges,
|
||||
actions,
|
||||
}: PageHeaderProps) {
|
||||
return (
|
||||
<header className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||
{badges}
|
||||
</div>
|
||||
{description ? (
|
||||
<p className="max-w-3xl text-sm text-muted-foreground">{description}</p>
|
||||
) : null}
|
||||
{actions ? (
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2">{actions}</div>
|
||||
) : null}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
50
app/components/route-guard.tsx
Normal file
50
app/components/route-guard.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
// Per-route capability guard. Wrap the page body — if the active
|
||||
// session doesn't hold the route's capability, render a 403 instead of
|
||||
// the page. Server-side authz is still the real gate; this is UX so a
|
||||
// deep link doesn't 500 inside a route loader that assumes access.
|
||||
|
||||
import { useLocation } from "react-router"
|
||||
import { ShieldAlert } from "lucide-react"
|
||||
|
||||
import {
|
||||
capabilityForPath,
|
||||
useCapabilities,
|
||||
type Capability,
|
||||
} from "~/lib/capabilities"
|
||||
import { Card, CardContent } from "~/components/ui/card"
|
||||
|
||||
type RouteGuardProps = {
|
||||
children: React.ReactNode
|
||||
/** Override the capability derived from the current path. Useful for
|
||||
* nested routes where you want to check a specific cap. */
|
||||
capability?: Capability
|
||||
}
|
||||
|
||||
export function RouteGuard({ children, capability }: RouteGuardProps) {
|
||||
const caps = useCapabilities()
|
||||
const location = useLocation()
|
||||
const required = capability ?? capabilityForPath(location.pathname)
|
||||
// No mapping = route is intentionally unguarded (e.g. login flows
|
||||
// never reach AppShell anyway).
|
||||
if (!required) return <>{children}</>
|
||||
if (caps.has(required)) return <>{children}</>
|
||||
return <Forbidden capability={required} />
|
||||
}
|
||||
|
||||
function Forbidden({ capability }: { capability: Capability }) {
|
||||
return (
|
||||
<div className="flex min-h-[60vh] items-center justify-center">
|
||||
<Card className="max-w-md">
|
||||
<CardContent className="flex flex-col items-center gap-3 py-10 text-center">
|
||||
<ShieldAlert className="size-10 text-muted-foreground" />
|
||||
<h2 className="text-lg font-semibold">You can't access this page</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This view requires the <code className="font-mono text-xs">{capability}</code>{" "}
|
||||
capability on your active tenant. If you think you should have it,
|
||||
switch tenants from the avatar menu or ask an admin.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -118,7 +118,7 @@ export function ScriptsDialog({
|
||||
data-action="scripts-dsl"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder={"navigate /resources\nclick nav-resources"}
|
||||
placeholder={"navigate /tenants\nclick nav-tenants"}
|
||||
spellCheck={false}
|
||||
rows={6}
|
||||
className="w-full rounded-md border bg-background p-2 font-mono text-xs"
|
||||
|
||||
905
app/components/settings/llm-configurations-panel.tsx
Normal file
905
app/components/settings/llm-configurations-panel.tsx
Normal file
@@ -0,0 +1,905 @@
|
||||
// LLM configurations panel.
|
||||
//
|
||||
// One unified surface for everything LLM-config-related: server-persisted
|
||||
// configurations, the per-operator "active" choice (which one the assistant
|
||||
// uses on the next message), and 30-day spend per row. The "active" toggle
|
||||
// writes to the same localStorage key @crema/llm-providers-ui reads via
|
||||
// loadSettings/saveSettings, so the existing assistant code picks it up
|
||||
// without any plumbing changes.
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { Pencil, Plus, Sparkles, Star, Trash2, Upload } from "lucide-react"
|
||||
import { useArcadiaClient } from "@crema/arcadia-client"
|
||||
import {
|
||||
loadSettings as loadActiveSettings,
|
||||
saveSettings as saveActiveSettings,
|
||||
type LLMProvidersSettings,
|
||||
} from "@crema/llm-providers-ui"
|
||||
|
||||
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 {
|
||||
createConfiguration,
|
||||
deleteConfiguration,
|
||||
findSpend,
|
||||
formatCost,
|
||||
getCatalog,
|
||||
getUsageByModel,
|
||||
getUsageSummary,
|
||||
listConfigurations,
|
||||
REASONING_EFFORTS,
|
||||
saveActiveReasoning,
|
||||
updateConfiguration,
|
||||
type CatalogEntry,
|
||||
type LlmConfiguration,
|
||||
type LlmConfigurationInput,
|
||||
type LlmProvider,
|
||||
type LlmUsageSummary,
|
||||
type ReasoningEffort,
|
||||
type UsageByModelRow,
|
||||
} from "~/lib/arcadia/llm-configs"
|
||||
import { listSecrets, type Secret } from "~/lib/arcadia/secrets"
|
||||
|
||||
const PROVIDERS: LlmProvider[] = ["openai", "anthropic", "deepseek", "qwen", "lmstudio"]
|
||||
const LOCAL_SETTINGS_KEY = "crema.llm-providers.settings"
|
||||
|
||||
// Curated picks for the "Seed catalog" empty-state action — operators get
|
||||
// a sensible starting set instead of a blank panel and 19 manual creates.
|
||||
const SEED_PICKS: Array<{ name: string; provider: LlmProvider; model: string }> = [
|
||||
{ name: "GPT-4o mini (cheap default)", provider: "openai", model: "gpt-4o-mini" },
|
||||
{ name: "GPT-4o", provider: "openai", model: "gpt-4o" },
|
||||
{ name: "Claude Sonnet 4.6", provider: "anthropic", model: "claude-sonnet-4-6" },
|
||||
{ name: "Claude Haiku 4.5", provider: "anthropic", model: "claude-haiku-4-5" },
|
||||
{ name: "DeepSeek V4 Flash", provider: "deepseek", model: "deepseek-v4-flash" },
|
||||
{ name: "LM Studio (local)", provider: "lmstudio", model: "local-model" },
|
||||
]
|
||||
|
||||
export function LlmConfigurationsPanel() {
|
||||
const arcadia = useArcadiaClient()
|
||||
const [configs, setConfigs] = useState<LlmConfiguration[]>([])
|
||||
const [catalog, setCatalog] = useState<CatalogEntry[]>([])
|
||||
const [usage, setUsage] = useState<LlmUsageSummary | null>(null)
|
||||
const [usageByModel, setUsageByModel] = useState<UsageByModelRow[]>([])
|
||||
const [secrets, setSecrets] = useState<Secret[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [active, setActive] = useState<LLMProvidersSettings | null>(null)
|
||||
const [editing, setEditing] = useState<LlmConfiguration | "new" | null>(null)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setError(null)
|
||||
try {
|
||||
const [list, cat, sum, byModel, secs] = await Promise.all([
|
||||
listConfigurations(arcadia),
|
||||
getCatalog(arcadia).catch(() => [] as CatalogEntry[]),
|
||||
getUsageSummary(arcadia, { days: 30 }).catch(() => null),
|
||||
getUsageByModel(arcadia, { days: 30 }).catch(() => [] as UsageByModelRow[]),
|
||||
listSecrets(arcadia).catch(() => [] as Secret[]),
|
||||
])
|
||||
setConfigs(list)
|
||||
setCatalog(cat)
|
||||
setUsage(sum)
|
||||
setUsageByModel(byModel)
|
||||
setSecrets(secs)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to load configurations.")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [arcadia])
|
||||
|
||||
useEffect(() => {
|
||||
void refresh()
|
||||
if (typeof window !== "undefined") setActive(loadActiveSettings())
|
||||
}, [refresh])
|
||||
|
||||
const isActive = useCallback(
|
||||
(c: LlmConfiguration) =>
|
||||
!!active &&
|
||||
active.providerId === c.provider &&
|
||||
active.model === c.model &&
|
||||
(active.secretName || "") === (c.secret_name || ""),
|
||||
[active],
|
||||
)
|
||||
|
||||
const onMakeActive = (c: LlmConfiguration) => {
|
||||
const current = loadActiveSettings()
|
||||
saveActiveSettings({
|
||||
...current,
|
||||
providerId: c.provider as LLMProvidersSettings["providerId"],
|
||||
model: c.model,
|
||||
baseURL: c.base_url || undefined,
|
||||
secretName: c.secret_name || undefined,
|
||||
})
|
||||
// Inherit this config's reasoning default. The /ai composer chip
|
||||
// listens for this and updates live; if the operator already
|
||||
// override it via the chip, the next save propagates here.
|
||||
saveActiveReasoning(c.reasoning_effort ?? "off")
|
||||
setActive(loadActiveSettings())
|
||||
}
|
||||
|
||||
const onToggleEnabled = async (c: LlmConfiguration) => {
|
||||
setError(null)
|
||||
try {
|
||||
await updateConfiguration(arcadia, c.id, { enabled: !c.enabled })
|
||||
await refresh()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Update failed.")
|
||||
}
|
||||
}
|
||||
|
||||
const onDelete = async (c: LlmConfiguration) => {
|
||||
setError(null)
|
||||
if (!window.confirm(`Delete "${c.name}"? Historical usage rows are preserved.`)) return
|
||||
try {
|
||||
await deleteConfiguration(arcadia, c.id)
|
||||
await refresh()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Delete failed.")
|
||||
}
|
||||
}
|
||||
|
||||
const onSave = async (input: LlmConfigurationInput, existing: LlmConfiguration | null) => {
|
||||
setError(null)
|
||||
try {
|
||||
if (existing) {
|
||||
await updateConfiguration(arcadia, existing.id, input)
|
||||
} else {
|
||||
await createConfiguration(arcadia, input)
|
||||
}
|
||||
setEditing(null)
|
||||
await refresh()
|
||||
} catch (e) {
|
||||
throw e instanceof Error ? e : new Error(String(e))
|
||||
}
|
||||
}
|
||||
|
||||
const onSeed = async () => {
|
||||
setError(null)
|
||||
try {
|
||||
// Seed sequentially to surface conflicts cleanly.
|
||||
for (const pick of SEED_PICKS) {
|
||||
try {
|
||||
await createConfiguration(arcadia, pick)
|
||||
} catch {
|
||||
// skip dupes — they're benign on a re-seed
|
||||
}
|
||||
}
|
||||
await refresh()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Seed failed.")
|
||||
}
|
||||
}
|
||||
|
||||
const onImportFromLocal = async () => {
|
||||
setError(null)
|
||||
const raw = typeof window !== "undefined" ? localStorage.getItem(LOCAL_SETTINGS_KEY) : null
|
||||
if (!raw) {
|
||||
setError("No local settings found to import.")
|
||||
return
|
||||
}
|
||||
try {
|
||||
const local = JSON.parse(raw) as LLMProvidersSettings
|
||||
if (!local.providerId || !local.model) {
|
||||
setError("Local settings are incomplete.")
|
||||
return
|
||||
}
|
||||
await createConfiguration(arcadia, {
|
||||
name: `Imported (${local.providerId})`,
|
||||
provider: local.providerId as LlmProvider,
|
||||
model: local.model,
|
||||
base_url: local.baseURL || null,
|
||||
secret_name: local.secretName || null,
|
||||
})
|
||||
await refresh()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Import failed.")
|
||||
}
|
||||
}
|
||||
|
||||
const hasLocalSettings =
|
||||
typeof window !== "undefined" && !!localStorage.getItem(LOCAL_SETTINGS_KEY)
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col items-stretch justify-between gap-4 sm:flex-row sm:items-start">
|
||||
<div className="flex-1">
|
||||
<CardTitle>LLM configurations</CardTitle>
|
||||
<CardDescription>
|
||||
Server-persisted provider/model/secret/cost settings. Toggle the star to
|
||||
pick which one the Assistant uses on the next message.
|
||||
</CardDescription>
|
||||
</div>
|
||||
{usage ? (
|
||||
<div className="flex shrink-0 flex-col items-start rounded-md border bg-muted/40 px-3 py-2 text-left sm:items-end sm:text-right">
|
||||
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
Spend (30d)
|
||||
</span>
|
||||
<span className="font-mono text-base font-semibold tabular-nums">
|
||||
{formatCost(usage.total_cost_cents ?? 0)}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{(usage.total_requests ?? 0).toLocaleString()} req ·{" "}
|
||||
{(usage.total_tokens ?? 0).toLocaleString()} tok
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
{hasLocalSettings && configs.length === 0 ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onImportFromLocal}
|
||||
data-action="llm-config-import-local"
|
||||
>
|
||||
<Upload className="size-4" />
|
||||
Import local
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setEditing("new")}
|
||||
data-action="llm-config-add"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
{error ? (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<p className="py-4 text-sm text-muted-foreground">Loading…</p>
|
||||
) : configs.length === 0 ? (
|
||||
<EmptyState onSeed={onSeed} onImport={hasLocalSettings ? onImportFromLocal : null} />
|
||||
) : (
|
||||
<ul className="divide-y border-y">
|
||||
{configs.map((c) => (
|
||||
<ConfigRow
|
||||
key={c.id}
|
||||
config={c}
|
||||
spend={findSpend(usageByModel, c)}
|
||||
isActive={isActive(c)}
|
||||
onMakeActive={() => onMakeActive(c)}
|
||||
onToggleEnabled={() => onToggleEnabled(c)}
|
||||
onEdit={() => setEditing(c)}
|
||||
onDelete={() => onDelete(c)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{editing ? (
|
||||
<ConfigDialog
|
||||
existing={editing === "new" ? null : editing}
|
||||
catalog={catalog}
|
||||
secrets={secrets}
|
||||
onClose={() => setEditing(null)}
|
||||
onSave={onSave}
|
||||
/>
|
||||
) : null}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Empty state ---------------------------------------------------------
|
||||
|
||||
function EmptyState({
|
||||
onSeed,
|
||||
onImport,
|
||||
}: {
|
||||
onSeed: () => void
|
||||
onImport: (() => void) | null
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 rounded-md border border-dashed bg-muted/20 px-6 py-10 text-center">
|
||||
<Sparkles className="size-6 text-muted-foreground" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium">No configurations yet</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Seed a starter set from the curated catalog (GPT-4o, Claude, DeepSeek, LM Studio)
|
||||
and tweak from there.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={onSeed} data-action="llm-config-seed">
|
||||
<Sparkles className="size-4" />
|
||||
Seed from catalog
|
||||
</Button>
|
||||
{onImport ? (
|
||||
<Button variant="outline" size="sm" onClick={onImport}>
|
||||
<Upload className="size-4" />
|
||||
Import local
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Single row ----------------------------------------------------------
|
||||
|
||||
function ConfigRow({
|
||||
config: c,
|
||||
spend,
|
||||
isActive,
|
||||
onMakeActive,
|
||||
onToggleEnabled,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
config: LlmConfiguration
|
||||
spend: UsageByModelRow | undefined
|
||||
isActive: boolean
|
||||
onMakeActive: () => void
|
||||
onToggleEnabled: () => void
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
return (
|
||||
<li className="flex flex-col items-stretch justify-between gap-3 px-1 py-2.5 sm:flex-row sm:items-center">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMakeActive}
|
||||
className="shrink-0 rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
aria-label={isActive ? "Active configuration" : "Make active"}
|
||||
data-action={`llm-config-activate-${c.id}`}
|
||||
title={isActive ? "Currently active for this browser" : "Make active for this browser"}
|
||||
>
|
||||
<Star
|
||||
className={`size-4 ${isActive ? "fill-amber-400 text-amber-400" : ""}`}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="flex items-center gap-2 text-sm font-medium">
|
||||
<span className="truncate">{c.name}</span>
|
||||
{c.tenant_id == null ? (
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
platform
|
||||
</span>
|
||||
) : null}
|
||||
{!c.enabled ? (
|
||||
<span className="text-[11px] text-muted-foreground">disabled</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{c.provider} · <code className="font-mono">{c.model}</code>
|
||||
{c.secret_name ? (
|
||||
<>
|
||||
{" "}
|
||||
· secret <code className="font-mono">{c.secret_name}</code>
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{formatRate(c.input_cost_per_million)}/1M in ·{" "}
|
||||
{formatRate(c.output_cost_per_million)}/1M out
|
||||
{c.reasoning_effort && c.reasoning_effort !== "off" ? (
|
||||
<>
|
||||
{" "}
|
||||
· <span className="uppercase tracking-wider">think</span>{" "}
|
||||
<span className="text-[var(--console-amber,oklch(0.78_0.15_60))]">
|
||||
{c.reasoning_effort}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center justify-end gap-3 pl-7 sm:pl-0">
|
||||
{spend && spend.cost_cents > 0 ? (
|
||||
<div className="flex flex-col items-end text-right">
|
||||
<span className="font-mono text-sm tabular-nums">
|
||||
{formatCost(spend.cost_cents)}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
30d · {spend.requests.toLocaleString()} req
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Switch
|
||||
checked={c.enabled}
|
||||
onCheckedChange={onToggleEnabled}
|
||||
size="sm"
|
||||
aria-label={c.enabled ? "Disable" : "Enable"}
|
||||
data-action={`llm-config-enabled-${c.id}`}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onEdit}
|
||||
data-action={`llm-config-edit-${c.id}`}
|
||||
aria-label="Edit"
|
||||
title={c.tenant_id == null ? "Edit platform default (visible to all tenants)" : "Edit"}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onDelete}
|
||||
data-action={`llm-config-delete-${c.id}`}
|
||||
aria-label="Delete"
|
||||
title={c.tenant_id == null ? "Delete platform default" : "Delete"}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Add/Edit modal ------------------------------------------------------
|
||||
|
||||
function ConfigDialog({
|
||||
existing,
|
||||
catalog,
|
||||
secrets,
|
||||
onClose,
|
||||
onSave,
|
||||
}: {
|
||||
existing: LlmConfiguration | null
|
||||
catalog: CatalogEntry[]
|
||||
secrets: Secret[]
|
||||
onClose: () => void
|
||||
onSave: (
|
||||
input: LlmConfigurationInput,
|
||||
existing: LlmConfiguration | null,
|
||||
) => Promise<void>
|
||||
}) {
|
||||
const [draft, setDraft] = useState<LlmConfigurationInput>(
|
||||
existing
|
||||
? {
|
||||
name: existing.name,
|
||||
provider: existing.provider,
|
||||
model: existing.model,
|
||||
base_url: existing.base_url,
|
||||
secret_name: existing.secret_name,
|
||||
input_cost_per_million: existing.input_cost_per_million,
|
||||
output_cost_per_million: existing.output_cost_per_million,
|
||||
enabled: existing.enabled,
|
||||
reasoning_effort: existing.reasoning_effort,
|
||||
}
|
||||
: emptyDraft(),
|
||||
)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
|
||||
const modelsForProvider = useMemo(
|
||||
() => catalog.filter((c) => c.provider === draft.provider && c.model !== "*"),
|
||||
[catalog, draft.provider],
|
||||
)
|
||||
|
||||
const onSubmit = async () => {
|
||||
setSaving(true)
|
||||
setErr(null)
|
||||
try {
|
||||
await onSave(draft, existing)
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : "Save failed.")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const valid = draft.name.trim() !== "" && draft.model.trim() !== ""
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{existing ? "Edit configuration" : "New configuration"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Costs auto-fill from the curated catalog when you pick a known model.
|
||||
Override below if you have a negotiated rate.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-3 py-2 sm:grid-cols-2">
|
||||
<Field label="Name" className="sm:col-span-2">
|
||||
<Input
|
||||
value={draft.name}
|
||||
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
|
||||
placeholder="Production GPT-4o-mini"
|
||||
autoFocus={!existing}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Provider">
|
||||
<Select
|
||||
value={draft.provider}
|
||||
onValueChange={(v) =>
|
||||
setDraft({ ...draft, provider: v as LlmProvider, model: "" })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PROVIDERS.map((p) => (
|
||||
<SelectItem key={p} value={p}>
|
||||
{p}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label="Model">
|
||||
<ModelPicker
|
||||
value={draft.model}
|
||||
models={modelsForProvider}
|
||||
onChange={(model) => {
|
||||
// Auto-fill costs when picking a catalog model.
|
||||
const entry = modelsForProvider.find((m) => m.model === model)
|
||||
setDraft({
|
||||
...draft,
|
||||
model,
|
||||
...(entry && {
|
||||
input_cost_per_million: entry.input_cost_per_million,
|
||||
output_cost_per_million: entry.output_cost_per_million,
|
||||
}),
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Vault secret (optional)" className="sm:col-span-2">
|
||||
<SecretPicker
|
||||
value={draft.secret_name ?? null}
|
||||
secrets={secrets}
|
||||
onChange={(name) => setDraft({ ...draft, secret_name: name })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Base URL (optional)" className="sm:col-span-2">
|
||||
<Input
|
||||
value={draft.base_url ?? ""}
|
||||
onChange={(e) => setDraft({ ...draft, base_url: e.target.value || null })}
|
||||
placeholder="leave blank for provider default"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Input cost (USD per 1M tokens)">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min={0}
|
||||
value={draft.input_cost_per_million ?? ""}
|
||||
onChange={(e) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
input_cost_per_million: e.target.value === "" ? null : Number(e.target.value),
|
||||
})
|
||||
}
|
||||
placeholder="0.15"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Output cost (USD per 1M tokens)">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min={0}
|
||||
value={draft.output_cost_per_million ?? ""}
|
||||
onChange={(e) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
output_cost_per_million: e.target.value === "" ? null : Number(e.target.value),
|
||||
})
|
||||
}
|
||||
placeholder="0.60"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Reasoning effort (thinking models)" className="sm:col-span-2">
|
||||
<Select
|
||||
value={draft.reasoning_effort ?? "off"}
|
||||
onValueChange={(v) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
reasoning_effort: (v === "off" ? null : v) as ReasoningEffort | null,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{REASONING_EFFORTS.map((e) => (
|
||||
<SelectItem key={e} value={e}>
|
||||
<span className="flex items-center justify-between gap-3">
|
||||
<span className="capitalize">{e}</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{reasoningHint(e)}
|
||||
</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{err ? (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{err}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onSubmit}
|
||||
disabled={!valid || saving}
|
||||
data-action="llm-config-save"
|
||||
>
|
||||
{saving ? "Saving…" : existing ? "Save changes" : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Model picker (Select + Custom… escape) -------------------------------
|
||||
|
||||
function ModelPicker({
|
||||
value,
|
||||
models,
|
||||
onChange,
|
||||
}: {
|
||||
value: string
|
||||
models: CatalogEntry[]
|
||||
onChange: (model: string) => void
|
||||
}) {
|
||||
const known = useMemo(() => new Set(models.map((m) => m.model)), [models])
|
||||
const isCustom = value !== "" && !known.has(value)
|
||||
const [customMode, setCustomMode] = useState(isCustom)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCustom) setCustomMode(false)
|
||||
}, [models, isCustom])
|
||||
|
||||
if (customMode) {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="custom-model-id"
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setCustomMode(false)
|
||||
onChange("")
|
||||
}}
|
||||
>
|
||||
Catalog
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={known.has(value) ? value : ""}
|
||||
onValueChange={(v) => {
|
||||
if (v === "__custom__") {
|
||||
setCustomMode(true)
|
||||
onChange("")
|
||||
} else {
|
||||
onChange(v)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={models.length ? "Pick a model…" : "Type a model id"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{models.map((m) => (
|
||||
<SelectItem key={m.model} value={m.model}>
|
||||
<span className="flex items-center justify-between gap-3">
|
||||
<span>{m.model}</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
${m.input_cost_per_million.toFixed(2)} / ${m.output_cost_per_million.toFixed(2)} per 1M
|
||||
</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="__custom__">
|
||||
<span className="text-muted-foreground">Custom…</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Secret picker: Select populated from /api/v1/admin/secrets, filtered to
|
||||
* api_key category (LLM keys live there). Includes a "(none)" option for
|
||||
* keyless providers (lmstudio) and "Type a name…" for secrets that haven't
|
||||
* been created yet — the latter switches to free-text and the user can
|
||||
* type any name; the proxy will fail loudly at request time if it's wrong.
|
||||
*/
|
||||
function SecretPicker({
|
||||
value,
|
||||
secrets,
|
||||
onChange,
|
||||
}: {
|
||||
value: string | null
|
||||
secrets: Secret[]
|
||||
onChange: (name: string | null) => void
|
||||
}) {
|
||||
const apiKeys = useMemo(
|
||||
() =>
|
||||
secrets
|
||||
.filter((s) => s.category === "api_key" && s.enabled)
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
[secrets],
|
||||
)
|
||||
|
||||
const known = useMemo(() => new Set(apiKeys.map((s) => s.name)), [apiKeys])
|
||||
const isCustom = value != null && value !== "" && !known.has(value)
|
||||
const [customMode, setCustomMode] = useState(isCustom)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCustom) setCustomMode(false)
|
||||
}, [secrets, isCustom])
|
||||
|
||||
if (customMode) {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onChange(e.target.value || null)}
|
||||
placeholder="secret-name-not-yet-in-vault"
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setCustomMode(false)
|
||||
onChange(null)
|
||||
}}
|
||||
>
|
||||
Pick
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Encode null as the empty string for the Select — Radix/base-ui can't
|
||||
// bind an actual null/undefined value cleanly.
|
||||
const NONE = "__none__"
|
||||
const CUSTOM = "__custom__"
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={value == null ? NONE : known.has(value) ? value : ""}
|
||||
onValueChange={(v) => {
|
||||
if (v === NONE) onChange(null)
|
||||
else if (v === CUSTOM) {
|
||||
setCustomMode(true)
|
||||
onChange("")
|
||||
} else onChange(v)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={apiKeys.length ? "Pick a secret…" : "No api_key secrets yet"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>
|
||||
<span className="text-muted-foreground">(none — keyless / local)</span>
|
||||
</SelectItem>
|
||||
{apiKeys.map((s) => (
|
||||
<SelectItem key={s.id} value={s.name}>
|
||||
<span className="flex flex-col items-start">
|
||||
<span className="font-mono text-xs">{s.name}</span>
|
||||
{s.description ? (
|
||||
<span className="text-[10px] text-muted-foreground">{s.description}</span>
|
||||
) : null}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value={CUSTOM}>
|
||||
<span className="text-muted-foreground">Type a name…</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
children,
|
||||
className = "",
|
||||
}: {
|
||||
label: string
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={`flex flex-col gap-1 ${className}`}>
|
||||
<Label className="text-xs">{label}</Label>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function emptyDraft(): LlmConfigurationInput {
|
||||
return {
|
||||
name: "",
|
||||
provider: "openai",
|
||||
model: "",
|
||||
base_url: null,
|
||||
secret_name: null,
|
||||
}
|
||||
}
|
||||
|
||||
function formatRate(rate: number | null): string {
|
||||
if (rate == null) return "—"
|
||||
if (rate === 0) return "free"
|
||||
return `$${rate.toFixed(2)}`
|
||||
}
|
||||
|
||||
function reasoningHint(e: ReasoningEffort): string {
|
||||
switch (e) {
|
||||
case "off":
|
||||
return "no thinking"
|
||||
case "low":
|
||||
return "~2k thinking tokens"
|
||||
case "medium":
|
||||
return "~8k thinking tokens"
|
||||
case "high":
|
||||
return "~24k thinking tokens"
|
||||
case "max":
|
||||
return "~64k — slowest, most thorough"
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as React from "react"
|
||||
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
@@ -40,19 +41,61 @@ const buttonVariants = cva(
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Button component.
|
||||
*
|
||||
* Supports the Radix-style `asChild` ergonomic for cases like
|
||||
* `<Button asChild><Link to="/foo">…</Link></Button>` so the consumer doesn't
|
||||
* have to reach for base-ui's `render` prop directly. Internally translates
|
||||
* `asChild` → base-ui `render` so the underlying `<a>` (or whatever the child
|
||||
* is) actually rendered, instead of nesting a `<button>` around it.
|
||||
*/
|
||||
type ButtonProps = ButtonPrimitive.Props &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
/**
|
||||
* When true, render the single child element instead of a `<button>`.
|
||||
* Compatible with Radix-style usage. Internally bridged to base-ui's
|
||||
* `render` prop.
|
||||
*/
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild,
|
||||
children,
|
||||
render,
|
||||
...props
|
||||
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||
}: ButtonProps) {
|
||||
const mergedClassName = cn(buttonVariants({ variant, size, className }))
|
||||
|
||||
// asChild: take the single child element, hand it to base-ui as the render
|
||||
// target. base-ui merges its props (including className) into the element.
|
||||
if (asChild) {
|
||||
const child = React.Children.only(children) as React.ReactElement
|
||||
return (
|
||||
<ButtonPrimitive
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
className={mergedClassName}
|
||||
render={child}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ButtonPrimitive
|
||||
data-slot="button"
|
||||
className={mergedClassName}
|
||||
render={render}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ButtonPrimitive>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
export type { ButtonProps }
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
// Shared state surface that any admin page can publish to so the assistant
|
||||
// can read live data without scraping the DOM.
|
||||
//
|
||||
// Pages call `useRegisterAdminContext("tenants", { tenants: [...] })` while
|
||||
// mounted; the assistant calls `getAdminContextSnapshot()` each turn to
|
||||
// inject a structured snapshot into the system prompt.
|
||||
|
||||
import { useEffect } from "react"
|
||||
|
||||
type Surface = Record<string, unknown>
|
||||
|
||||
export type AdminContextSnapshot = {
|
||||
route: string
|
||||
surfaces: Record<string, Surface>
|
||||
}
|
||||
|
||||
const surfaces = new Map<string, Surface>()
|
||||
|
||||
export function publishAdminSurface(name: string, data: Surface): void {
|
||||
surfaces.set(name, data)
|
||||
if (typeof window !== "undefined") {
|
||||
;(window as unknown as { __adminContext?: unknown }).__adminContext = getAdminContextSnapshot()
|
||||
}
|
||||
}
|
||||
|
||||
export function clearAdminSurface(name: string): void {
|
||||
surfaces.delete(name)
|
||||
if (typeof window !== "undefined") {
|
||||
;(window as unknown as { __adminContext?: unknown }).__adminContext = getAdminContextSnapshot()
|
||||
}
|
||||
}
|
||||
|
||||
export function getAdminContextSnapshot(): AdminContextSnapshot {
|
||||
const route = typeof window !== "undefined" ? window.location.pathname : ""
|
||||
return {
|
||||
route,
|
||||
surfaces: Object.fromEntries(surfaces.entries()),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a snapshot as a markdown block for the LLM system prompt.
|
||||
* Keeps it compact: route, then one section per surface with JSON.
|
||||
*/
|
||||
export function formatAdminContextForPrompt(snapshot = getAdminContextSnapshot()): string {
|
||||
const sections: string[] = [`Admin context (read-only — for answering factual questions):`]
|
||||
sections.push(`Route: ${snapshot.route || "?"}`)
|
||||
const names = Object.keys(snapshot.surfaces)
|
||||
if (names.length === 0) {
|
||||
sections.push(`Surfaces: (none registered)`)
|
||||
} else {
|
||||
for (const name of names) {
|
||||
const json = safeJson(snapshot.surfaces[name])
|
||||
sections.push(`Surface "${name}":\n${json}`)
|
||||
}
|
||||
}
|
||||
return sections.join("\n\n")
|
||||
}
|
||||
|
||||
function safeJson(value: unknown): string {
|
||||
try {
|
||||
const text = JSON.stringify(value, null, 2)
|
||||
if (text.length > 4000) return text.slice(0, 4000) + "\n…(truncated)"
|
||||
return text
|
||||
} catch {
|
||||
return "(unserializable)"
|
||||
}
|
||||
}
|
||||
|
||||
/** Hook: publish a surface while the component is mounted. */
|
||||
export function useRegisterAdminContext(name: string, data: Surface): void {
|
||||
useEffect(() => {
|
||||
publishAdminSurface(name, data)
|
||||
return () => clearAdminSurface(name)
|
||||
}, [name, data])
|
||||
}
|
||||
@@ -6,40 +6,169 @@
|
||||
// raw HTTP — only the menu below.
|
||||
|
||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
||||
import type { Tool, ToolCall as LLMToolCall } from "@crema/llm-ui"
|
||||
import {
|
||||
createToolRuntime,
|
||||
type ToolDef,
|
||||
} from "@crema/aifirst-ui/tools"
|
||||
|
||||
import {
|
||||
activateTenant,
|
||||
deactivateTenant,
|
||||
getTenant,
|
||||
listTenants,
|
||||
suspendTenant,
|
||||
type Tenant,
|
||||
} from "~/lib/arcadia/tenants"
|
||||
import {
|
||||
assignRole,
|
||||
createUser,
|
||||
deleteUser,
|
||||
removeRole,
|
||||
setUserStatus,
|
||||
updateUser,
|
||||
type UserStatus,
|
||||
} from "~/lib/arcadia/users"
|
||||
import { listMemberships } from "~/lib/arcadia/memberships"
|
||||
import { listRoles } from "~/lib/arcadia/roles"
|
||||
import { revokeUserApiKey } from "~/lib/arcadia/api-keys"
|
||||
import { createRAGClient } from "@crema/lexical-rag-ui"
|
||||
import { BLOCK_INDEX, getBlockSchema } from "~/lib/block-schemas"
|
||||
import { searchAdmin, SearchAdminError } from "~/lib/search-admin"
|
||||
|
||||
export type ToolCall = {
|
||||
name: string
|
||||
args: Record<string, unknown>
|
||||
// Lazy singleton — first tool call fetches /docs-index.json, subsequent
|
||||
// calls reuse the parsed MiniSearch instance.
|
||||
const docsClient = createRAGClient("/docs-index.json")
|
||||
|
||||
// Server-side Tantivy backend (arcadia-search).
|
||||
//
|
||||
// URL: comes from window.__ARCADIA_SEARCH_URL (override hook) or
|
||||
// VITE_ARCADIA_SEARCH_URL build-time env, defaulting to localhost.
|
||||
//
|
||||
// Token (resolution order):
|
||||
// 1. window.__ARCADIA_SEARCH_TOKEN — runtime override hook for tests/devtools.
|
||||
// 2. VITE_ARCADIA_SEARCH_TOKEN — build-time service-principal token.
|
||||
// Required when arcadia-search runs in AUTH_MODE=jwt and arcadia-admin
|
||||
// talks to a remote arcadia whose JWT signing secret arcadia-search
|
||||
// doesn't share. Issue this once from arcadia-admin's service-principal
|
||||
// tooling and wire it through `.env.local`.
|
||||
// 3. operator session JWT — works only when arcadia-search shares the
|
||||
// JWT signing secret with the arcadia issuing the operator's session
|
||||
// (i.e. local arcadia-app + local arcadia-search with matching keys).
|
||||
// 4. "dev" literal — only accepted by AUTH_MODE=dev backends.
|
||||
function readEnv(key: string): string | undefined {
|
||||
if (typeof import.meta === "undefined") return undefined
|
||||
return (import.meta as unknown as { env?: Record<string, string | undefined> })
|
||||
.env?.[key]
|
||||
}
|
||||
|
||||
export type ToolResult = {
|
||||
name: string
|
||||
args: Record<string, unknown>
|
||||
ok: boolean
|
||||
data?: unknown
|
||||
error?: string
|
||||
const KB_BASE_URL: string =
|
||||
(typeof window !== "undefined" &&
|
||||
(window as unknown as { __ARCADIA_SEARCH_URL?: string }).__ARCADIA_SEARCH_URL) ||
|
||||
readEnv("VITE_ARCADIA_SEARCH_URL") ||
|
||||
"http://127.0.0.1:7800"
|
||||
|
||||
const KB_SERVICE_TOKEN: string | undefined = readEnv("VITE_ARCADIA_SEARCH_TOKEN")
|
||||
|
||||
type TokenSource = "override" | "service" | "session" | "dev"
|
||||
|
||||
function kbAuthToken(): { token: string; source: TokenSource } {
|
||||
if (typeof window !== "undefined") {
|
||||
const override = (window as unknown as { __ARCADIA_SEARCH_TOKEN?: string })
|
||||
.__ARCADIA_SEARCH_TOKEN
|
||||
if (override) return { token: override, source: "override" }
|
||||
}
|
||||
if (KB_SERVICE_TOKEN) return { token: KB_SERVICE_TOKEN, source: "service" }
|
||||
if (typeof window === "undefined") return { token: "dev", source: "dev" }
|
||||
try {
|
||||
const stored = window.sessionStorage.getItem("arcadia_access_token")
|
||||
if (stored) return { token: stored, source: "session" }
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
return { token: "dev", source: "dev" }
|
||||
}
|
||||
|
||||
type ToolDef = {
|
||||
name: string
|
||||
description: string
|
||||
parameters: Record<string, unknown> // JSON Schema for OpenAI tool calling
|
||||
isWrite: boolean
|
||||
run: (args: Record<string, unknown>, ctx: ToolCtx) => Promise<unknown>
|
||||
// True when the operator's session JWT was minted by an arcadia other than
|
||||
// the one hosting search — i.e. signing keys almost certainly don't match
|
||||
// and a session-token fallback will 401 silently. We treat any non-localhost
|
||||
// arcadia URL as "remote" for this heuristic.
|
||||
function isRemoteArcadia(): boolean {
|
||||
const url = readEnv("VITE_ARCADIA_URL") ?? ""
|
||||
if (!url) return false
|
||||
return !/^https?:\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0)\b/i.test(url)
|
||||
}
|
||||
|
||||
function kbAuthHint(source: TokenSource): string {
|
||||
if (source === "service" || source === "override") {
|
||||
return "VITE_ARCADIA_SEARCH_TOKEN was rejected — verify it's signed with the secret arcadia-search expects (JWT_HMAC_SECRET) and hasn't expired."
|
||||
}
|
||||
if (source === "session" && isRemoteArcadia()) {
|
||||
return "Set VITE_ARCADIA_SEARCH_TOKEN in arcadia-admin/.env.local to a service-principal JWT signed with arcadia-search's JWT_HMAC_SECRET. The operator session JWT (from a remote arcadia) won't validate against a locally-keyed arcadia-search."
|
||||
}
|
||||
if (source === "session") {
|
||||
return "Operator session JWT was rejected — arcadia-search's JWT_HMAC_SECRET likely doesn't match the arcadia that issued the session. Either align secrets or set VITE_ARCADIA_SEARCH_TOKEN."
|
||||
}
|
||||
return "arcadia-search rejected the dev fallback — it's running in AUTH_MODE=jwt. Set VITE_ARCADIA_SEARCH_TOKEN or restart arcadia-search with AUTH_MODE=dev for local testing."
|
||||
}
|
||||
|
||||
async function kbFetch(input: string, init?: RequestInit): Promise<Response> {
|
||||
const { token, source } = kbAuthToken()
|
||||
const res = await fetch(input, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init?.headers ?? {}),
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
throw new Error(
|
||||
`arcadia-search rejected the request (${res.status}). ${kbAuthHint(source)}`,
|
||||
)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
type KBHit = {
|
||||
chunk_id: string
|
||||
title: string
|
||||
source_path: string
|
||||
heading_path: string
|
||||
tags: string[]
|
||||
snippet: string
|
||||
score: number
|
||||
mtime: string
|
||||
}
|
||||
|
||||
async function kbSearch(
|
||||
query: string,
|
||||
corpus: string,
|
||||
limit: number,
|
||||
tags?: string[],
|
||||
): Promise<{ count: number; hits: KBHit[] }> {
|
||||
const res = await kbFetch(`${KB_BASE_URL}/search`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ query, corpus, limit, tags }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw new Error(`arcadia-search ${res.status}: ${await res.text()}`)
|
||||
}
|
||||
return (await res.json()) as { count: number; hits: KBHit[] }
|
||||
}
|
||||
|
||||
async function kbRead(chunkId: string, corpus: string): Promise<unknown> {
|
||||
const url = `${KB_BASE_URL}/chunks/${encodeURIComponent(chunkId)}?corpus=${encodeURIComponent(corpus)}`
|
||||
const res = await kbFetch(url)
|
||||
if (res.status === 404) return null
|
||||
if (!res.ok) {
|
||||
throw new Error(`arcadia-search ${res.status}: ${await res.text()}`)
|
||||
}
|
||||
return await res.json()
|
||||
}
|
||||
|
||||
type ToolCtx = { arcadia: ArcadiaClient }
|
||||
|
||||
const TOOLS: ToolDef[] = [
|
||||
const TOOLS: ToolDef<ToolCtx>[] = [
|
||||
{
|
||||
name: "list_tenants",
|
||||
description:
|
||||
@@ -221,6 +350,544 @@ const TOOLS: ToolDef[] = [
|
||||
return summarize(updated)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "deactivate_tenant",
|
||||
description:
|
||||
"Permanently deactivate a tenant by slug. Stronger than suspend — also revokes API keys and disables billing. Reversible only via activate_tenant. Use when a tenant is closing the account, not for short-term holds. Requires user confirmation before executing.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
slug: { type: "string", description: "The tenant's slug." },
|
||||
},
|
||||
required: ["slug"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
isWrite: true,
|
||||
run: async (args, { arcadia }) => {
|
||||
const slug = typeof args.slug === "string" ? args.slug : null
|
||||
if (!slug) throw new Error("deactivate_tenant requires { slug }")
|
||||
const tenants = await listTenants(arcadia)
|
||||
const target = tenants.find((t) => t.slug === slug)
|
||||
if (!target) throw new Error(`No tenant with slug "${slug}"`)
|
||||
const updated = await deactivateTenant(arcadia, target.id)
|
||||
return summarize(updated)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "set_user_status",
|
||||
description:
|
||||
"Change a user's status to active, inactive, or suspended. Suspended users cannot sign in; inactive users are hidden from default lists but retain their data. Pass the user's id (UUID). Requires user confirmation before executing.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
user_id: { type: "string", description: "User UUID." },
|
||||
status: {
|
||||
type: "string",
|
||||
enum: ["active", "inactive", "suspended"],
|
||||
description: "Target status.",
|
||||
},
|
||||
},
|
||||
required: ["user_id", "status"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
isWrite: true,
|
||||
run: async (args, { arcadia }) => {
|
||||
const userId = typeof args.user_id === "string" ? args.user_id : null
|
||||
const status = typeof args.status === "string" ? (args.status as UserStatus) : null
|
||||
if (!userId || !status)
|
||||
throw new Error("set_user_status requires { user_id, status }")
|
||||
const updated = await setUserStatus(arcadia, userId, status)
|
||||
return {
|
||||
id: updated.id,
|
||||
email: updated.email,
|
||||
status: updated.status,
|
||||
full_name: updated.full_name,
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "delete_user",
|
||||
description:
|
||||
"Permanently delete a user by id. Cascades to their memberships and API keys. NOT reversible — prefer set_user_status with 'inactive' or 'suspended' unless the user explicitly asks for permanent deletion. Requires user confirmation before executing.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
user_id: { type: "string", description: "User UUID." },
|
||||
},
|
||||
required: ["user_id"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
isWrite: true,
|
||||
run: async (args, { arcadia }) => {
|
||||
const userId = typeof args.user_id === "string" ? args.user_id : null
|
||||
if (!userId) throw new Error("delete_user requires { user_id }")
|
||||
await deleteUser(arcadia, userId)
|
||||
return { id: userId, deleted: true }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_memberships",
|
||||
description:
|
||||
"List user-to-tenant memberships. Returns user/tenant pairs with role assignments and primary-membership flag. Filter by tenant_slug to answer 'who's in tenant X', or by user_id to answer 'which tenants does user Y belong to'.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
tenant_slug: {
|
||||
type: "string",
|
||||
description: "Optional: filter to a single tenant by slug.",
|
||||
},
|
||||
user_id: {
|
||||
type: "string",
|
||||
description: "Optional: filter to a single user by UUID.",
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
isWrite: false,
|
||||
run: async (args, { arcadia }) => {
|
||||
const slug = typeof args.tenant_slug === "string" ? args.tenant_slug : null
|
||||
const userId = typeof args.user_id === "string" ? args.user_id : null
|
||||
const all = await listMemberships(arcadia)
|
||||
const filtered = all.filter((m) => {
|
||||
if (slug && m.tenant?.slug !== slug) return false
|
||||
if (userId && m.user?.id !== userId) return false
|
||||
return true
|
||||
})
|
||||
return filtered.map((m) => ({
|
||||
id: m.id,
|
||||
tenant: m.tenant ? { slug: m.tenant.slug, name: m.tenant.name } : null,
|
||||
user: m.user
|
||||
? {
|
||||
id: m.user.id,
|
||||
email: m.user.email,
|
||||
name:
|
||||
[m.user.first_name, m.user.last_name].filter(Boolean).join(" ") ||
|
||||
null,
|
||||
}
|
||||
: null,
|
||||
status: m.status,
|
||||
is_primary: m.is_primary,
|
||||
roles: m.roles.map((r) => r.slug),
|
||||
joined_at: m.joined_at,
|
||||
}))
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_roles",
|
||||
description:
|
||||
"List every role defined in the current tenant. Returns slug, name, description, permission set, and is_system flag. Use to answer 'what roles are available' or before assigning a role.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
additionalProperties: false,
|
||||
},
|
||||
isWrite: false,
|
||||
run: async (_args, { arcadia }) => {
|
||||
const roles = await listRoles(arcadia)
|
||||
return roles.map((r) => ({
|
||||
id: r.id,
|
||||
slug: r.slug,
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
permissions: r.permissions,
|
||||
is_system: r.is_system,
|
||||
}))
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "create_user",
|
||||
description:
|
||||
"Create a new user in the current tenant. Pass email (required) plus optional first_name, last_name, status, password, and role_ids. If password is omitted the user must set one via the password-reset flow. Requires user confirmation before executing.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
email: { type: "string", description: "User email address." },
|
||||
first_name: { type: "string" },
|
||||
last_name: { type: "string" },
|
||||
status: {
|
||||
type: "string",
|
||||
enum: ["active", "inactive", "suspended"],
|
||||
},
|
||||
password: {
|
||||
type: "string",
|
||||
description:
|
||||
"Optional initial password. Omit to require the user to use the password-reset flow.",
|
||||
},
|
||||
role_ids: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Optional UUIDs of roles to assign on creation.",
|
||||
},
|
||||
},
|
||||
required: ["email"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
isWrite: true,
|
||||
run: async (args, { arcadia }) => {
|
||||
const email = typeof args.email === "string" ? args.email : null
|
||||
if (!email) throw new Error("create_user requires { email }")
|
||||
const created = await createUser(arcadia, {
|
||||
email,
|
||||
first_name: typeof args.first_name === "string" ? args.first_name : undefined,
|
||||
last_name: typeof args.last_name === "string" ? args.last_name : undefined,
|
||||
status:
|
||||
typeof args.status === "string"
|
||||
? (args.status as UserStatus)
|
||||
: undefined,
|
||||
password: typeof args.password === "string" ? args.password : undefined,
|
||||
role_ids: Array.isArray(args.role_ids)
|
||||
? (args.role_ids.filter((r) => typeof r === "string") as string[])
|
||||
: undefined,
|
||||
})
|
||||
return {
|
||||
id: created.id,
|
||||
email: created.email,
|
||||
full_name: created.full_name,
|
||||
status: created.status,
|
||||
roles: created.roles.map((r) => r.slug),
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "update_user",
|
||||
description:
|
||||
"Update a user's name or email by id. For status changes use set_user_status; for role assignment use assign_role/remove_role. Requires user confirmation before executing.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
user_id: { type: "string", description: "User UUID." },
|
||||
email: { type: "string" },
|
||||
first_name: { type: "string" },
|
||||
last_name: { type: "string" },
|
||||
},
|
||||
required: ["user_id"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
isWrite: true,
|
||||
run: async (args, { arcadia }) => {
|
||||
const userId = typeof args.user_id === "string" ? args.user_id : null
|
||||
if (!userId) throw new Error("update_user requires { user_id }")
|
||||
const patch: Record<string, string> = {}
|
||||
if (typeof args.email === "string") patch.email = args.email
|
||||
if (typeof args.first_name === "string") patch.first_name = args.first_name
|
||||
if (typeof args.last_name === "string") patch.last_name = args.last_name
|
||||
if (Object.keys(patch).length === 0)
|
||||
throw new Error("update_user needs at least one field to change")
|
||||
const updated = await updateUser(arcadia, userId, patch)
|
||||
return {
|
||||
id: updated.id,
|
||||
email: updated.email,
|
||||
full_name: updated.full_name,
|
||||
status: updated.status,
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "assign_role",
|
||||
description:
|
||||
"Grant a role to a user by user_id and role_id. Idempotent — re-granting an existing role is a no-op. Use list_roles first to find the role's id. Requires user confirmation before executing.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
user_id: { type: "string", description: "User UUID." },
|
||||
role_id: { type: "string", description: "Role UUID." },
|
||||
},
|
||||
required: ["user_id", "role_id"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
isWrite: true,
|
||||
run: async (args, { arcadia }) => {
|
||||
const userId = typeof args.user_id === "string" ? args.user_id : null
|
||||
const roleId = typeof args.role_id === "string" ? args.role_id : null
|
||||
if (!userId || !roleId)
|
||||
throw new Error("assign_role requires { user_id, role_id }")
|
||||
const updated = await assignRole(arcadia, userId, roleId)
|
||||
return {
|
||||
id: updated.id,
|
||||
email: updated.email,
|
||||
roles: updated.roles.map((r) => r.slug),
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "remove_role",
|
||||
description:
|
||||
"Revoke a role from a user by user_id and role_id. Idempotent — removing a role the user doesn't have is a no-op. Requires user confirmation before executing.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
user_id: { type: "string", description: "User UUID." },
|
||||
role_id: { type: "string", description: "Role UUID." },
|
||||
},
|
||||
required: ["user_id", "role_id"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
isWrite: true,
|
||||
run: async (args, { arcadia }) => {
|
||||
const userId = typeof args.user_id === "string" ? args.user_id : null
|
||||
const roleId = typeof args.role_id === "string" ? args.role_id : null
|
||||
if (!userId || !roleId)
|
||||
throw new Error("remove_role requires { user_id, role_id }")
|
||||
const updated = await removeRole(arcadia, userId, roleId)
|
||||
return {
|
||||
id: updated.id,
|
||||
email: updated.email,
|
||||
roles: updated.roles.map((r) => r.slug),
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "revoke_api_key",
|
||||
description:
|
||||
"Revoke a user's API key by id. The key stops working immediately and cannot be un-revoked — the user must mint a new one. Use for compromised keys or offboarding. Requires user confirmation before executing.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
user_id: { type: "string", description: "Owner user UUID." },
|
||||
key_id: { type: "string", description: "API key UUID." },
|
||||
reason: {
|
||||
type: "string",
|
||||
description: "Optional audit-log reason for the revocation.",
|
||||
},
|
||||
},
|
||||
required: ["user_id", "key_id"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
isWrite: true,
|
||||
run: async (args, { arcadia }) => {
|
||||
const userId = typeof args.user_id === "string" ? args.user_id : null
|
||||
const keyId = typeof args.key_id === "string" ? args.key_id : null
|
||||
const reason = typeof args.reason === "string" ? args.reason : undefined
|
||||
if (!userId || !keyId)
|
||||
throw new Error("revoke_api_key requires { user_id, key_id }")
|
||||
await revokeUserApiKey(arcadia, userId, keyId, reason)
|
||||
return { user_id: userId, key_id: keyId, revoked: true }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "search_docs",
|
||||
description:
|
||||
"Search the arcadia-app documentation (architecture, API surface, deploy/setup guides) for passages relevant to a question. Use this for conceptual or procedural questions where the live API tools won't help — e.g. 'how does multi-tenant isolation work', 'how do I deploy to production', 'what is the modular monolith pattern'. Returns up to `limit` ranked passages with title, sourcePath, and excerpt. Cite results by sourcePath in your reply.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: {
|
||||
type: "string",
|
||||
description:
|
||||
"Lexical search query. Use specific terms from the docs (endpoint names, schema fields, concept names) — paraphrase poorly.",
|
||||
},
|
||||
limit: {
|
||||
type: "integer",
|
||||
description: "Max passages to return. Default 5, cap 10.",
|
||||
minimum: 1,
|
||||
maximum: 10,
|
||||
},
|
||||
},
|
||||
required: ["query"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
isWrite: false,
|
||||
run: async (args) => {
|
||||
const query = typeof args.query === "string" ? args.query.trim() : ""
|
||||
if (!query) throw new Error("search_docs requires a non-empty { query }")
|
||||
const limit = Math.min(
|
||||
10,
|
||||
Math.max(1, typeof args.limit === "number" ? args.limit : 5),
|
||||
)
|
||||
const hits = await docsClient.search(query, { limit })
|
||||
// Tool-shape parity with the previous searchDocs() return: collapse
|
||||
// tags[] back to category for now so the agent's prior expectations
|
||||
// and any cached examples still parse cleanly.
|
||||
return {
|
||||
query,
|
||||
count: hits.length,
|
||||
hits: hits.map((h) => ({
|
||||
id: h.id,
|
||||
title: h.title,
|
||||
sourcePath: h.sourcePath,
|
||||
category: h.tags[0] ?? "",
|
||||
excerpt: h.excerpt,
|
||||
score: h.score,
|
||||
})),
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "search_kb",
|
||||
description:
|
||||
"Lexical (BM25) search over the arcadia-search Tantivy backend. Returns chunks with snippets + chunk_ids that can be passed to `read_chunk` to expand. Prefer this over `search_docs` (browser) when you need richer hits or when the content wouldn't be in the bundled docs.\n\nKnown corpora on the platform-admin tenant:\n- `docs` — arcadia-app architecture/ops docs (same as the browser RAG, server-hosted for parity).\n- `operator-tools` — arcadia-search + arcadia-admin documentation (admin sidecar, deploy script, search admin UI, MULTI_TENANT, RAG, AI_FIRST, LIBS, LLM_PROXY_CONTRACT).\n- `files` — markdown/text files uploaded by tenant users via arcadia-app.\n\nIf you're not sure what's available, call `list_search_corpora` first. Operators can add new corpora via the `/search` route.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Lexical search query." },
|
||||
corpus: {
|
||||
type: "string",
|
||||
description:
|
||||
"Which indexed corpus to search. See list_search_corpora for the live set; common values: `docs`, `operator-tools`, `files`.",
|
||||
},
|
||||
limit: {
|
||||
type: "integer",
|
||||
description: "Max hits. Default 5, cap 20.",
|
||||
minimum: 1,
|
||||
maximum: 20,
|
||||
},
|
||||
tags: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description:
|
||||
"Optional tag filter — return only hits whose chunk has at least one matching tag.",
|
||||
},
|
||||
},
|
||||
required: ["query", "corpus"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
isWrite: false,
|
||||
run: async (args) => {
|
||||
const query = typeof args.query === "string" ? args.query.trim() : ""
|
||||
const corpus = typeof args.corpus === "string" ? args.corpus.trim() : ""
|
||||
if (!query) throw new Error("search_kb requires a non-empty { query }")
|
||||
if (!corpus) throw new Error("search_kb requires a { corpus } name")
|
||||
const limit = Math.min(20, Math.max(1, typeof args.limit === "number" ? args.limit : 5))
|
||||
const tags = Array.isArray(args.tags) ? (args.tags as string[]) : undefined
|
||||
return await kbSearch(query, corpus, limit, tags)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "read_chunk",
|
||||
description:
|
||||
"Fetch the full body of one chunk by id from the arcadia-search backend, after `search_kb` returned it as a snippet. Use this to expand a hit when the snippet looked promising but you need more context to answer.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
chunk_id: { type: "string", description: "The chunk_id from a prior search_kb hit." },
|
||||
corpus: { type: "string", description: "Same corpus the chunk came from." },
|
||||
},
|
||||
required: ["chunk_id", "corpus"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
isWrite: false,
|
||||
run: async (args) => {
|
||||
const chunkId = typeof args.chunk_id === "string" ? args.chunk_id : ""
|
||||
const corpus = typeof args.corpus === "string" ? args.corpus : ""
|
||||
if (!chunkId || !corpus) {
|
||||
throw new Error("read_chunk requires { chunk_id, corpus }")
|
||||
}
|
||||
const result = await kbRead(chunkId, corpus)
|
||||
if (result === null) {
|
||||
return { error: "chunk not found", chunk_id: chunkId, corpus }
|
||||
}
|
||||
return result
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_search_corpora",
|
||||
description:
|
||||
"Enumerate the corpora currently configured on the arcadia-search admin sidecar. Returns each tenant's corpora with build status (indexed?, num_docs). Call this when you don't know what corpora exist before invoking `search_kb`, or when the user asks what knowledge is available. Requires the search admin token to be configured.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
additionalProperties: false,
|
||||
},
|
||||
isWrite: false,
|
||||
run: async () => {
|
||||
try {
|
||||
const tenantsRes = await searchAdmin.listTenants()
|
||||
const tenants = await Promise.all(
|
||||
tenantsRes.tenants.map(async (t) => {
|
||||
try {
|
||||
const c = await searchAdmin.listCorpora(t.id)
|
||||
return {
|
||||
tenant: t.id,
|
||||
corpora: c.corpora.map((cc) => ({
|
||||
corpus: cc.corpus,
|
||||
indexed: cc.indexed,
|
||||
num_docs: cc.num_docs,
|
||||
})),
|
||||
}
|
||||
} catch {
|
||||
return { tenant: t.id, corpora: [] }
|
||||
}
|
||||
}),
|
||||
)
|
||||
return { tenants }
|
||||
} catch (err) {
|
||||
if (err instanceof SearchAdminError) {
|
||||
return {
|
||||
error: `search-admin ${err.status}: ${err.message}`,
|
||||
hint: "VITE_ARCADIA_SEARCH_ADMIN_TOKEN may be unset, or the sidecar (default :7801) may be down.",
|
||||
}
|
||||
}
|
||||
throw err
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "rebuild_search_corpus",
|
||||
description:
|
||||
"Trigger a synchronous rebuild of one corpus on arcadia-search. Use when the operator says the index is stale, after they've uploaded new files, or when search_kb returned suspiciously few/old hits. Returns chunk_count and built_at on success. The operator confirms before the rebuild runs (rebuilds can take seconds–minutes depending on corpus size).",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
tenant: {
|
||||
type: "string",
|
||||
description: "Search tenant id (e.g. `platform-admin`). See list_search_corpora for available tenants.",
|
||||
},
|
||||
corpus: {
|
||||
type: "string",
|
||||
description: "Corpus name within that tenant (e.g. `docs`, `operator-tools`, `files`).",
|
||||
},
|
||||
},
|
||||
required: ["tenant", "corpus"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
isWrite: true,
|
||||
run: async (args) => {
|
||||
const tenant = typeof args.tenant === "string" ? args.tenant.trim() : ""
|
||||
const corpus = typeof args.corpus === "string" ? args.corpus.trim() : ""
|
||||
if (!tenant || !corpus) {
|
||||
throw new Error("rebuild_search_corpus requires { tenant, corpus }")
|
||||
}
|
||||
try {
|
||||
return await searchAdmin.rebuild(tenant, corpus)
|
||||
} catch (err) {
|
||||
if (err instanceof SearchAdminError) {
|
||||
return { error: `search-admin ${err.status}: ${err.message}` }
|
||||
}
|
||||
throw err
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_block_schema",
|
||||
description: `Fetch the full JSON schema + example for a rich-output block kind so you can emit it correctly in your reply. Call this the first time in a thread that you intend to render a particular kind. Available kinds: ${Object.entries(
|
||||
BLOCK_INDEX,
|
||||
)
|
||||
.map(([k, v]) => `${k} (${v})`)
|
||||
.join(", ")}.`,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
kind: {
|
||||
type: "string",
|
||||
description: "The block kind to fetch the schema for.",
|
||||
enum: Object.keys(BLOCK_INDEX),
|
||||
},
|
||||
},
|
||||
required: ["kind"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
isWrite: false,
|
||||
run: async (args) => {
|
||||
const kind = typeof args.kind === "string" ? args.kind : ""
|
||||
const schema = getBlockSchema(kind)
|
||||
if (!schema) {
|
||||
return {
|
||||
error: `Unknown block kind "${kind}". Available: ${Object.keys(BLOCK_INDEX).join(", ")}.`,
|
||||
}
|
||||
}
|
||||
return { kind, schema }
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
interface AuditEntry {
|
||||
@@ -242,58 +909,6 @@ interface UserEntry {
|
||||
roles?: { slug?: string; name?: string }[]
|
||||
}
|
||||
|
||||
/** OpenAI-format tool list to pass into ChatRequest.tools. */
|
||||
export function getOpenAITools(): Tool[] {
|
||||
return TOOLS.map((t) => ({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
parameters: t.parameters,
|
||||
}))
|
||||
}
|
||||
|
||||
/** Split an LLM tool-call list into reads (run automatically) and writes
|
||||
* (held for user confirmation). Unknown tools fall into reads so the runner
|
||||
* can surface a structured "unknown tool" error to the model. */
|
||||
export function classifyCalls(calls: LLMToolCall[]): {
|
||||
reads: LLMToolCall[]
|
||||
writes: LLMToolCall[]
|
||||
} {
|
||||
const reads: LLMToolCall[] = []
|
||||
const writes: LLMToolCall[] = []
|
||||
for (const c of calls) {
|
||||
const def = TOOL_BY_NAME.get(c.name)
|
||||
if (def?.isWrite) writes.push(c)
|
||||
else reads.push(c)
|
||||
}
|
||||
return { reads, writes }
|
||||
}
|
||||
|
||||
/** Synthesise tool-result messages saying the user denied a write call. */
|
||||
export function buildDenialMessages(
|
||||
calls: LLMToolCall[],
|
||||
): { role: "tool"; content: string; toolCallId: string; name: string }[] {
|
||||
return calls.map((c) => ({
|
||||
role: "tool",
|
||||
content: JSON.stringify({
|
||||
error: "User denied this write. Do not retry without re-asking the user.",
|
||||
}),
|
||||
toolCallId: c.id,
|
||||
name: c.name,
|
||||
}))
|
||||
}
|
||||
|
||||
/** Pretty-print args for the confirm UI. */
|
||||
export function formatToolCallArgs(c: LLMToolCall): string {
|
||||
try {
|
||||
const parsed = c.arguments ? JSON.parse(c.arguments) : {}
|
||||
const keys = Object.keys(parsed)
|
||||
if (keys.length === 0) return ""
|
||||
return keys.map((k) => `${k}=${JSON.stringify(parsed[k])}`).join(", ")
|
||||
} catch {
|
||||
return c.arguments
|
||||
}
|
||||
}
|
||||
|
||||
function summarize(t: Tenant) {
|
||||
return {
|
||||
id: t.id,
|
||||
@@ -305,62 +920,15 @@ function summarize(t: Tenant) {
|
||||
}
|
||||
}
|
||||
|
||||
const TOOL_BY_NAME = new Map(TOOLS.map((t) => [t.name, t]))
|
||||
const runtime = createToolRuntime(TOOLS)
|
||||
|
||||
function safeJson(value: unknown): string {
|
||||
try {
|
||||
const text = JSON.stringify(value, null, 2)
|
||||
if (text.length > 6000) return text.slice(0, 6000) + "\n…(truncated)"
|
||||
return text
|
||||
} catch {
|
||||
return "(unserializable)"
|
||||
}
|
||||
}
|
||||
export const getOpenAITools = runtime.getOpenAITools
|
||||
export const classifyCalls = runtime.classifyCalls
|
||||
export const runLLMToolCalls = runtime.runLLMToolCalls
|
||||
|
||||
/** Run a list of provider-native tool calls and return `tool` role messages
|
||||
* ready to push back into useChat history. */
|
||||
export async function runLLMToolCalls(
|
||||
calls: LLMToolCall[],
|
||||
ctx: ToolCtx,
|
||||
opts: { allowWrites?: boolean } = {},
|
||||
): Promise<{
|
||||
results: ToolResult[]
|
||||
toolMessages: { role: "tool"; content: string; toolCallId: string; name: string }[]
|
||||
}> {
|
||||
const results: ToolResult[] = []
|
||||
const toolMessages: { role: "tool"; content: string; toolCallId: string; name: string }[] = []
|
||||
for (const call of calls) {
|
||||
const def = TOOL_BY_NAME.get(call.name)
|
||||
let parsed: Record<string, unknown> = {}
|
||||
try {
|
||||
parsed = call.arguments ? (JSON.parse(call.arguments) as Record<string, unknown>) : {}
|
||||
} catch {
|
||||
const err = `Could not parse arguments JSON: ${call.arguments}`
|
||||
results.push({ name: call.name, args: {}, ok: false, error: err })
|
||||
toolMessages.push({ role: "tool", content: JSON.stringify({ error: err }), toolCallId: call.id, name: call.name })
|
||||
continue
|
||||
}
|
||||
if (!def) {
|
||||
const err = `Unknown tool: ${call.name}`
|
||||
results.push({ name: call.name, args: parsed, ok: false, error: err })
|
||||
toolMessages.push({ role: "tool", content: JSON.stringify({ error: err }), toolCallId: call.id, name: call.name })
|
||||
continue
|
||||
}
|
||||
if (def.isWrite && !opts.allowWrites) {
|
||||
const err = "Write tools require user confirmation."
|
||||
results.push({ name: call.name, args: parsed, ok: false, error: err })
|
||||
toolMessages.push({ role: "tool", content: JSON.stringify({ error: err }), toolCallId: call.id, name: call.name })
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const data = await def.run(parsed, ctx)
|
||||
results.push({ name: call.name, args: parsed, ok: true, data })
|
||||
toolMessages.push({ role: "tool", content: safeJson(data), toolCallId: call.id, name: call.name })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
results.push({ name: call.name, args: parsed, ok: false, error: msg })
|
||||
toolMessages.push({ role: "tool", content: JSON.stringify({ error: msg }), toolCallId: call.id, name: call.name })
|
||||
}
|
||||
}
|
||||
return { results, toolMessages }
|
||||
}
|
||||
export {
|
||||
buildDenialMessages,
|
||||
formatToolCallArgs,
|
||||
type ToolCall,
|
||||
type ToolResult,
|
||||
} from "@crema/aifirst-ui/tools"
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
// Agent personas — named, role-scoped sub-system prompts.
|
||||
// Each persona stacks on top of the main systemPrompt to specialize the
|
||||
// assistant for a task. Persisted in localStorage; reactive across tabs.
|
||||
// Arcadia Admin's agent roster + migration config.
|
||||
// The persona machinery lives in @crema/aifirst-ui/agents — this file
|
||||
// just owns the *which personas* config and re-exports the runtime so
|
||||
// route code keeps importing from "~/lib/agents".
|
||||
|
||||
import { useEffect, useSyncExternalStore } from "react"
|
||||
|
||||
export type Agent = {
|
||||
id: string
|
||||
name: string
|
||||
role: string
|
||||
prompt: string
|
||||
}
|
||||
import { configureAgents, type Agent } from "@crema/aifirst-ui/agents"
|
||||
|
||||
export const DEFAULT_AGENTS: Agent[] = [
|
||||
{
|
||||
@@ -21,148 +15,58 @@ export const DEFAULT_AGENTS: Agent[] = [
|
||||
},
|
||||
{
|
||||
id: "auditor",
|
||||
name: "Ledger",
|
||||
name: "Notary",
|
||||
role: "Auditor",
|
||||
prompt:
|
||||
"You're an audit-focused assistant inside Arcadia Admin. Specialise in audit logs, access reviews, and 'who did what when' questions. Always cite the actor_type (user / platform_admin / api_key / system) and timestamp when summarising audit entries. Be cautious about claims you can't back with a tool result — call a tool first.",
|
||||
},
|
||||
{
|
||||
id: "triage",
|
||||
name: "Beacon",
|
||||
name: "Tracer",
|
||||
role: "Incident Triage",
|
||||
prompt:
|
||||
"You're an incident-triage assistant inside Arcadia Admin. When the user reports a problem (a tenant member can't sign in, a billing call is 402'ing, a webhook is failing), walk the diagnostic tree: identify the tenant, check tenant status, check the user's roles, check the billing-config / api-metering / feature-flag overrides as relevant. Suggest impersonation only when it's the right escalation. Keep a clear hypothesis → check → result rhythm.",
|
||||
},
|
||||
{
|
||||
id: "analyst",
|
||||
name: "Tally",
|
||||
name: "Census",
|
||||
role: "Platform Analyst",
|
||||
prompt:
|
||||
"You're an analyst inside Arcadia Admin. Answer numerical and aggregate questions across the platform: tenant counts by status, plan distribution, audit-log volume, growth. Always pull live data via tools — never guess from stale snapshots. Present findings in plain prose first, then a small table when the breakdown helps.",
|
||||
},
|
||||
{
|
||||
id: "ui-driver",
|
||||
name: "Cursor",
|
||||
name: "Pilot",
|
||||
role: "UI Operator",
|
||||
prompt:
|
||||
"You specialise in driving Arcadia Admin's UI on the operator's behalf. Prefer doing over explaining. When the user asks for an action that maps to a UI element, emit an action block immediately (using `data-action` ids the host has documented). For data questions, prefer tool calls over UI navigation.",
|
||||
},
|
||||
]
|
||||
|
||||
const STORAGE_KEY = "crema.agents"
|
||||
const ACTIVE_KEY = "crema.assistant.activeAgent"
|
||||
const CHANGE_EVENT = "crema:agents-change"
|
||||
|
||||
function isAgent(v: unknown): v is Agent {
|
||||
return (
|
||||
!!v &&
|
||||
typeof v === "object" &&
|
||||
typeof (v as Agent).id === "string" &&
|
||||
typeof (v as Agent).name === "string" &&
|
||||
typeof (v as Agent).role === "string" &&
|
||||
typeof (v as Agent).prompt === "string"
|
||||
)
|
||||
}
|
||||
|
||||
// Old Vibespace agent ids — used to auto-migrate operators stuck on the
|
||||
// generic defaults from before Arcadia Admin had its own personas.
|
||||
const LEGACY_AGENT_IDS = new Set(["generalist", "coder", "writer", "researcher"])
|
||||
|
||||
function isLegacyDefaultSet(agents: Agent[]): boolean {
|
||||
return agents.some((a) => LEGACY_AGENT_IDS.has(a.id))
|
||||
}
|
||||
// Retired arcadia-era persona names. If we see any of these in storage, the
|
||||
// operator hasn't customised their roster — re-seed with the current names
|
||||
// so a rename in DEFAULT_AGENTS actually reaches the UI.
|
||||
const RETIRED_AGENT_NAMES = new Set(["Ledger", "Beacon", "Tally", "Cursor"])
|
||||
|
||||
function readFromStorage(): Agent[] {
|
||||
if (typeof window === "undefined") return DEFAULT_AGENTS
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return DEFAULT_AGENTS
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!Array.isArray(parsed)) return DEFAULT_AGENTS
|
||||
const cleaned = parsed.filter(isAgent)
|
||||
if (cleaned.length === 0) return DEFAULT_AGENTS
|
||||
if (isLegacyDefaultSet(cleaned)) {
|
||||
// Auto-migrate: stored set still contains pre-arcadia personas.
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(DEFAULT_AGENTS))
|
||||
localStorage.removeItem(ACTIVE_KEY)
|
||||
return DEFAULT_AGENTS
|
||||
}
|
||||
return cleaned
|
||||
} catch {
|
||||
return DEFAULT_AGENTS
|
||||
}
|
||||
}
|
||||
configureAgents({
|
||||
defaults: DEFAULT_AGENTS,
|
||||
shouldReseed: (stored) =>
|
||||
stored.some((a) => LEGACY_AGENT_IDS.has(a.id)) ||
|
||||
stored.some((a) => RETIRED_AGENT_NAMES.has(a.name)),
|
||||
})
|
||||
|
||||
export function loadAgents(): Agent[] {
|
||||
return readFromStorage()
|
||||
}
|
||||
|
||||
export function saveAgents(next: Agent[]) {
|
||||
if (typeof window === "undefined") return
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
|
||||
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
|
||||
}
|
||||
|
||||
export function resetAgents() {
|
||||
saveAgents(DEFAULT_AGENTS)
|
||||
}
|
||||
|
||||
let cached: Agent[] | null = null
|
||||
|
||||
function subscribe(cb: () => void): () => void {
|
||||
const onChange = () => {
|
||||
cached = null
|
||||
cb()
|
||||
}
|
||||
window.addEventListener(CHANGE_EVENT, onChange)
|
||||
window.addEventListener("storage", (e) => {
|
||||
if (e.key === STORAGE_KEY || e.key === ACTIVE_KEY) onChange()
|
||||
})
|
||||
return () => {
|
||||
window.removeEventListener(CHANGE_EVENT, onChange)
|
||||
}
|
||||
}
|
||||
|
||||
function getSnapshot(): Agent[] {
|
||||
if (!cached) cached = readFromStorage()
|
||||
return cached
|
||||
}
|
||||
|
||||
function getServerSnapshot(): Agent[] {
|
||||
return DEFAULT_AGENTS
|
||||
}
|
||||
|
||||
export function useAgents(): Agent[] {
|
||||
const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
|
||||
useEffect(() => {
|
||||
cached = null
|
||||
}, [])
|
||||
return value
|
||||
}
|
||||
|
||||
export function loadActiveAgentId(): string {
|
||||
if (typeof window === "undefined") return DEFAULT_AGENTS[0].id
|
||||
try {
|
||||
return localStorage.getItem(ACTIVE_KEY) ?? DEFAULT_AGENTS[0].id
|
||||
} catch {
|
||||
return DEFAULT_AGENTS[0].id
|
||||
}
|
||||
}
|
||||
|
||||
export function saveActiveAgentId(id: string) {
|
||||
if (typeof window === "undefined") return
|
||||
localStorage.setItem(ACTIVE_KEY, id)
|
||||
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
|
||||
}
|
||||
|
||||
export function composeSystemPrompt(
|
||||
base: string,
|
||||
agent: Agent | undefined,
|
||||
): string {
|
||||
if (!agent) return base
|
||||
return `${base}\n\nActive persona: ${agent.name} — ${agent.role}\n${agent.prompt}`
|
||||
}
|
||||
|
||||
export function newAgentId(): string {
|
||||
return `agent-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`
|
||||
}
|
||||
export {
|
||||
composeSystemPrompt,
|
||||
loadActiveAgentId,
|
||||
loadAgents,
|
||||
newAgentId,
|
||||
resetAgents,
|
||||
saveActiveAgentId,
|
||||
saveAgents,
|
||||
useAgents,
|
||||
type Agent,
|
||||
} from "@crema/aifirst-ui/agents"
|
||||
|
||||
@@ -16,6 +16,7 @@ Core entities and how they relate:
|
||||
- **Audit log entry** — append-only record of who did what. \`actor_type\` is one of: \`user\`, \`platform_admin\`, \`api_key\`, \`system\`. Per-tenant and platform-wide entries coexist.
|
||||
- **Feature flag** — boolean / variant gate. Platform-wide default + per-tenant override.
|
||||
- **Storage / billing config / SSO IdP / inbound webhook / API quota / data retention policy / approval workflow / announcement** — per-tenant or platform-level configurations the operator can manage.
|
||||
- **Search corpus** — a Tantivy index over a set of source documents, served by the arcadia-search service. Each corpus belongs to a search tenant (a separate id space from platform tenants — typically \`platform-admin\` for the operator's own knowledge). The operator manages corpora at \`/search\`: create/edit configuration JSON, rebuild on demand, restart the service. Built-ins on \`platform-admin\`: \`docs\` (arcadia architecture), \`operator-tools\` (arcadia-search + arcadia-admin docs), \`files\` (uploaded markdown/text files).
|
||||
|
||||
Tenant lifecycle (status field):
|
||||
|
||||
@@ -31,6 +32,7 @@ Things to keep in mind when assisting:
|
||||
- The operator can impersonate tenant users for debugging (POST /api/v1/admin/impersonate/:user_id) — surface this when they ask "why can't user X log in".
|
||||
- Quotas / rate cards / billing config errors usually surface as 402/403 from /api/v1 endpoints — diagnose by checking the tenant's billing-config and api-metering quotas.
|
||||
- The reference Phoenix app lives at \`reference/arcadia-app/\` in the workspace; its OpenAPI spec is at /api/openapi (sync via \`node ../lib-arcadia-client/scripts/sync-spec.mjs\`).
|
||||
- Search admin (arcadia-search) is a separate service. Manage tenants/corpora at \`/search\`. Use \`list_search_corpora\` if you don't know what's indexed; \`rebuild_search_corpus\` after uploads or when results look stale; \`search_kb\` / \`read_chunk\` to query.
|
||||
|
||||
When the user asks something that maps to a tool, call it. When they ask about a concept, explain it from this primer in plain language. Write tools (suspend_tenant, activate_tenant) prompt the operator with an inline confirm card before they actually run — you do not need to ask in prose first; just call the tool and the user will see the confirmation UI. If the user denies a write, do not retry it; ask what they'd like to do differently.
|
||||
|
||||
|
||||
79
app/lib/arcadia/announcements.ts
Normal file
79
app/lib/arcadia/announcements.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
// Platform announcements helpers.
|
||||
// Backend: /api/v1/admin/announcements (admin CRUD).
|
||||
|
||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
||||
|
||||
export type AnnouncementType =
|
||||
| "info"
|
||||
| "warning"
|
||||
| "maintenance"
|
||||
| "incident"
|
||||
| "feature"
|
||||
| string
|
||||
|
||||
export type AnnouncementAudience = "all" | "tenant" | "platform" | string
|
||||
|
||||
export interface Announcement {
|
||||
id: string
|
||||
tenant_id: string | null
|
||||
announcement_type: AnnouncementType
|
||||
title: string
|
||||
body: string | null
|
||||
action_label: string | null
|
||||
action_url: string | null
|
||||
starts_at: string | null
|
||||
ends_at: string | null
|
||||
audience: AnnouncementAudience
|
||||
dismissible: boolean
|
||||
active: boolean
|
||||
created_by_id: string | null
|
||||
inserted_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface AnnouncementInput {
|
||||
title: string
|
||||
body?: string
|
||||
announcement_type?: AnnouncementType
|
||||
audience?: AnnouncementAudience
|
||||
action_label?: string | null
|
||||
action_url?: string | null
|
||||
starts_at?: string | null
|
||||
ends_at?: string | null
|
||||
dismissible?: boolean
|
||||
active?: boolean
|
||||
/** Platform-wide if null, otherwise scoped. */
|
||||
tenant_id?: string | null
|
||||
}
|
||||
|
||||
const BASE = "/api/v1/admin/announcements"
|
||||
|
||||
export async function listAnnouncements(arcadia: ArcadiaClient): Promise<Announcement[]> {
|
||||
const res = await arcadia.GET<{ data: Announcement[] }>(BASE)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function createAnnouncement(
|
||||
arcadia: ArcadiaClient,
|
||||
input: AnnouncementInput,
|
||||
): Promise<Announcement> {
|
||||
const res = await arcadia.POST<{ data: Announcement }>(BASE, {
|
||||
body: { announcement: input },
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function updateAnnouncement(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
input: Partial<AnnouncementInput>,
|
||||
): Promise<Announcement> {
|
||||
const res = await arcadia.PUT<{ data: Announcement }>(`${BASE}/${id}`, {
|
||||
body: { announcement: input },
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function deleteAnnouncement(arcadia: ArcadiaClient, id: string): Promise<void> {
|
||||
await arcadia.DELETE(`${BASE}/${id}`)
|
||||
}
|
||||
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
|
||||
}
|
||||
217
app/lib/arcadia/buckets.ts
Normal file
217
app/lib/arcadia/buckets.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
// Platform-level bucket management.
|
||||
// Backend: /api/v1/platform/buckets/*. All operations require a
|
||||
// storage_config_id pointing at a credential row in /api/v1/storage_configs.
|
||||
|
||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
||||
|
||||
export interface Bucket {
|
||||
name: string
|
||||
region?: string
|
||||
size_bytes?: number | null
|
||||
object_count?: number | null
|
||||
created_at?: string | null
|
||||
/** Backend may return additional provider-specific fields. */
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface BucketObject {
|
||||
key: string
|
||||
size: number
|
||||
last_modified: string | null
|
||||
etag: string | null
|
||||
storage_class: string | null
|
||||
}
|
||||
|
||||
export interface ListObjectsResponse {
|
||||
objects: BucketObject[]
|
||||
is_truncated: boolean
|
||||
continuation_token: string | null
|
||||
prefix: string | null
|
||||
bucket_name: string
|
||||
}
|
||||
|
||||
export interface CreateBucketInput {
|
||||
storage_config_id: string
|
||||
bucket_name: string
|
||||
region?: string
|
||||
acl?: "private" | "public-read" | string
|
||||
versioning?: boolean
|
||||
/** Pre-validate without creating. Default false. */
|
||||
dry_run?: boolean
|
||||
}
|
||||
|
||||
export interface DeleteBucketInput {
|
||||
storage_config_id: string
|
||||
bucket_name: string
|
||||
/** 6-digit code from /confirmation-code. */
|
||||
confirmation_code?: string
|
||||
/** DANGEROUS — empty the bucket first. */
|
||||
force_empty?: boolean
|
||||
/** Verify a backup exists before delete. Default true. */
|
||||
verify_backup?: boolean
|
||||
/** Preview-only. Default true on first call so the UI can confirm. */
|
||||
dry_run?: boolean
|
||||
}
|
||||
|
||||
const BASE = "/api/v1/platform/buckets"
|
||||
|
||||
export async function listBuckets(
|
||||
arcadia: ArcadiaClient,
|
||||
storageConfigId: string,
|
||||
): Promise<Bucket[]> {
|
||||
const res = await arcadia.GET<{ buckets: Bucket[]; count: number }>(`${BASE}/list`, {
|
||||
params: { storage_config_id: storageConfigId },
|
||||
})
|
||||
return res.buckets ?? []
|
||||
}
|
||||
|
||||
export async function createBucket(
|
||||
arcadia: ArcadiaClient,
|
||||
input: CreateBucketInput,
|
||||
): Promise<unknown> {
|
||||
return arcadia.POST(`${BASE}/create`, { body: input })
|
||||
}
|
||||
|
||||
export async function deleteBucket(
|
||||
arcadia: ArcadiaClient,
|
||||
input: DeleteBucketInput,
|
||||
): Promise<unknown> {
|
||||
return arcadia.POST(`${BASE}/delete`, { body: input })
|
||||
}
|
||||
|
||||
export async function generateConfirmationCode(
|
||||
arcadia: ArcadiaClient,
|
||||
storageConfigId: string,
|
||||
bucketName: string,
|
||||
): Promise<{ code: string; expires_at?: string }> {
|
||||
return arcadia.GET(`${BASE}/confirmation-code`, {
|
||||
params: { storage_config_id: storageConfigId, bucket_name: bucketName },
|
||||
})
|
||||
}
|
||||
|
||||
export async function listRegions(
|
||||
arcadia: ArcadiaClient,
|
||||
storageConfigId: string,
|
||||
): Promise<string[]> {
|
||||
const res = await arcadia.GET<{ regions?: string[]; data?: string[] }>(`${BASE}/regions`, {
|
||||
params: { storage_config_id: storageConfigId },
|
||||
})
|
||||
return res.regions ?? res.data ?? []
|
||||
}
|
||||
|
||||
// --- Versioning / lifecycle / replication / policy / CORS -----------------
|
||||
|
||||
export async function configureVersioning(
|
||||
arcadia: ArcadiaClient,
|
||||
input: { storage_config_id: string; bucket_name: string; enabled: boolean; dry_run?: boolean },
|
||||
): Promise<unknown> {
|
||||
return arcadia.POST(`${BASE}/versioning`, { body: input })
|
||||
}
|
||||
|
||||
export async function configureLifecycle(
|
||||
arcadia: ArcadiaClient,
|
||||
input: {
|
||||
storage_config_id: string
|
||||
bucket_name: string
|
||||
rules: Array<Record<string, unknown>>
|
||||
dry_run?: boolean
|
||||
},
|
||||
): Promise<unknown> {
|
||||
return arcadia.POST(`${BASE}/lifecycle`, { body: input })
|
||||
}
|
||||
|
||||
export async function configureReplication(
|
||||
arcadia: ArcadiaClient,
|
||||
input: {
|
||||
storage_config_id: string
|
||||
bucket_name: string
|
||||
destination_bucket: string
|
||||
destination_region?: string
|
||||
dry_run?: boolean
|
||||
},
|
||||
): Promise<unknown> {
|
||||
return arcadia.POST(`${BASE}/replication`, { body: input })
|
||||
}
|
||||
|
||||
export async function configurePolicy(
|
||||
arcadia: ArcadiaClient,
|
||||
input: {
|
||||
storage_config_id: string
|
||||
bucket_name: string
|
||||
policy: Record<string, unknown>
|
||||
dry_run?: boolean
|
||||
},
|
||||
): Promise<unknown> {
|
||||
return arcadia.POST(`${BASE}/policy`, { body: input })
|
||||
}
|
||||
|
||||
export interface CorsRule {
|
||||
allowed_origins: string[]
|
||||
allowed_methods: string[]
|
||||
allowed_headers?: string[]
|
||||
expose_headers?: string[]
|
||||
max_age_seconds?: number
|
||||
}
|
||||
|
||||
export async function getCors(
|
||||
arcadia: ArcadiaClient,
|
||||
storageConfigId: string,
|
||||
bucketName: string,
|
||||
): Promise<{ rules: CorsRule[] } | null> {
|
||||
return arcadia.GET(`${BASE}/cors`, {
|
||||
params: { storage_config_id: storageConfigId, bucket_name: bucketName },
|
||||
})
|
||||
}
|
||||
|
||||
export async function configureCors(
|
||||
arcadia: ArcadiaClient,
|
||||
input: {
|
||||
storage_config_id: string
|
||||
bucket_name: string
|
||||
rules: CorsRule[]
|
||||
dry_run?: boolean
|
||||
},
|
||||
): Promise<unknown> {
|
||||
return arcadia.POST(`${BASE}/cors`, { body: input })
|
||||
}
|
||||
|
||||
export async function deleteCors(
|
||||
arcadia: ArcadiaClient,
|
||||
storageConfigId: string,
|
||||
bucketName: string,
|
||||
): Promise<unknown> {
|
||||
return arcadia.DELETE(`${BASE}/cors`, {
|
||||
params: { storage_config_id: storageConfigId, bucket_name: bucketName },
|
||||
})
|
||||
}
|
||||
|
||||
// --- Objects ---------------------------------------------------------------
|
||||
|
||||
export async function listObjects(
|
||||
arcadia: ArcadiaClient,
|
||||
params: {
|
||||
storage_config_id: string
|
||||
bucket_name: string
|
||||
prefix?: string
|
||||
max_keys?: number
|
||||
continuation_token?: string
|
||||
},
|
||||
): Promise<ListObjectsResponse> {
|
||||
return arcadia.GET<ListObjectsResponse>(`${BASE}/objects`, {
|
||||
params: params as Record<string, string | number | boolean | null | undefined>,
|
||||
})
|
||||
}
|
||||
|
||||
export async function getPresignedUrl(
|
||||
arcadia: ArcadiaClient,
|
||||
params: {
|
||||
storage_config_id: string
|
||||
bucket_name: string
|
||||
key: string
|
||||
expires_in?: number
|
||||
},
|
||||
): Promise<{ url: string; expires_at?: string; expires_in?: number }> {
|
||||
return arcadia.GET(`${BASE}/presigned-url`, {
|
||||
params: params as Record<string, string | number | undefined>,
|
||||
})
|
||||
}
|
||||
130
app/lib/arcadia/digital-objects.ts
Normal file
130
app/lib/arcadia/digital-objects.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
// Arcadia digital objects API — minimal client covering the upload flow
|
||||
// used by the avatar uploader. The full digital-objects API is much
|
||||
// larger; add endpoints here as we wire more features.
|
||||
|
||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
||||
|
||||
export interface DigitalObject {
|
||||
id: string
|
||||
tenant_id?: string
|
||||
user_id?: string
|
||||
storage_config_id?: string
|
||||
filename?: string
|
||||
original_filename?: string
|
||||
content_type?: string
|
||||
size_bytes?: number
|
||||
key?: string
|
||||
object_key?: string
|
||||
status?: "active" | "archived" | "deleted"
|
||||
inserted_at?: string
|
||||
}
|
||||
|
||||
interface UploadSession {
|
||||
id: string
|
||||
upload_url: string
|
||||
presigned_url?: string
|
||||
expires_at?: string
|
||||
}
|
||||
|
||||
interface CreateUploadSessionInput {
|
||||
filename: string
|
||||
content_type: string
|
||||
size_bytes: number
|
||||
storage_config_id?: string
|
||||
metadata?: Record<string, unknown>
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Three-step upload: open a session, PUT the bytes to the presigned URL
|
||||
* the session returns, complete the session to land the digital_object.
|
||||
*
|
||||
* Returns the finalized DigitalObject record.
|
||||
*/
|
||||
export async function uploadFile(
|
||||
arcadia: ArcadiaClient,
|
||||
file: File,
|
||||
opts: { storage_config_id?: string; tags?: string[] } = {},
|
||||
): Promise<DigitalObject> {
|
||||
const sessionInput: CreateUploadSessionInput = {
|
||||
filename: file.name,
|
||||
content_type: file.type || "application/octet-stream",
|
||||
size_bytes: file.size,
|
||||
storage_config_id: opts.storage_config_id,
|
||||
tags: opts.tags,
|
||||
}
|
||||
|
||||
const session = await arcadia.POST<{ data: UploadSession }>(
|
||||
"/api/v1/digital_objects/upload_sessions",
|
||||
{ body: { upload_session: sessionInput } },
|
||||
)
|
||||
|
||||
const uploadUrl = session.data.upload_url || session.data.presigned_url
|
||||
if (!uploadUrl) throw new Error("Upload session returned no upload URL")
|
||||
|
||||
const putRes = await fetch(uploadUrl, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": sessionInput.content_type },
|
||||
body: file,
|
||||
})
|
||||
if (!putRes.ok) {
|
||||
throw new Error(
|
||||
`Upload to storage failed: ${putRes.status} ${await putRes.text().catch(() => "")}`,
|
||||
)
|
||||
}
|
||||
|
||||
const completed = await arcadia.POST<{ data: DigitalObject }>(
|
||||
`/api/v1/digital_objects/upload_sessions/${encodeURIComponent(session.data.id)}/complete`,
|
||||
{ body: {} },
|
||||
)
|
||||
return completed.data
|
||||
}
|
||||
|
||||
export async function deleteDigitalObject(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
): Promise<void> {
|
||||
await arcadia.DELETE(`/api/v1/digital_objects/${encodeURIComponent(id)}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the raw bytes of a digital object and return a browser blob URL
|
||||
* suitable for `<img src>`. Used as an immediate-display fallback when
|
||||
* the async variant URLs aren't ready yet (e.g. fresh avatar upload).
|
||||
*
|
||||
* Bypasses the arcadia-client because that client only parses JSON or
|
||||
* text — for binary we need response.blob(). Auth is injected manually
|
||||
* from sessionStorage to match the rest of the auth surface.
|
||||
*
|
||||
* The returned blob URL is per-page; it does NOT survive a reload.
|
||||
* Caller should not persist it to localStorage — only render in memory
|
||||
* until the persistent variant URLs come through (e.g. on next mount).
|
||||
*/
|
||||
export async function fetchDigitalObjectAsBlobUrl(
|
||||
baseUrl: string,
|
||||
id: string,
|
||||
token: string,
|
||||
tenantId?: string,
|
||||
): Promise<string> {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "*/*",
|
||||
Authorization: `Bearer ${token}`,
|
||||
}
|
||||
if (tenantId) headers["X-Tenant-ID"] = tenantId
|
||||
|
||||
const url = `${baseUrl.replace(/\/+$/, "")}/api/v1/digital_objects/${encodeURIComponent(
|
||||
id,
|
||||
)}/content`
|
||||
const res = await fetch(url, { headers })
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch digital object content: ${res.status} ${await res.text().catch(() => "")}`,
|
||||
)
|
||||
}
|
||||
const blob = await res.blob()
|
||||
// eslint-disable-next-line no-console
|
||||
console.info(
|
||||
`[digital-objects] fetched blob id=${id} type=${blob.type} size=${blob.size}B`,
|
||||
)
|
||||
return URL.createObjectURL(blob)
|
||||
}
|
||||
94
app/lib/arcadia/health.ts
Normal file
94
app/lib/arcadia/health.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
// Arcadia health probes.
|
||||
//
|
||||
// Backed by /api/v1/health* (public — no auth). Each subsystem is probed
|
||||
// independently; the overall endpoint aggregates and returns 503 if any
|
||||
// subsystem is not "ok". See arcadia-app commit f427892.
|
||||
|
||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
||||
|
||||
export type HealthSubsystem = "api" | "db" | "workers" | "storage"
|
||||
|
||||
export type HealthStatus = "ok" | "degraded" | "error" | "unconfigured"
|
||||
|
||||
export interface SubsystemHealth {
|
||||
status: HealthStatus
|
||||
/** Optional human-readable detail. */
|
||||
message?: string
|
||||
/** Free-form metrics — shape is subsystem-specific. */
|
||||
details?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface OverallHealth {
|
||||
status: HealthStatus
|
||||
checked_at: string
|
||||
subsystems: Record<HealthSubsystem, SubsystemHealth>
|
||||
}
|
||||
|
||||
export interface DetailedHealth extends OverallHealth {
|
||||
/** BEAM info — present on /health/detailed only. */
|
||||
system?: {
|
||||
otp_release?: string
|
||||
elixir_version?: string
|
||||
process_count?: number
|
||||
memory_total_bytes?: number
|
||||
[k: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export interface HostStats {
|
||||
cpu: {
|
||||
util_pct: number | null
|
||||
per_cpu_pct: number[]
|
||||
load_avg_1: number | null
|
||||
load_avg_5: number | null
|
||||
load_avg_15: number | null
|
||||
schedulers_online: number
|
||||
num_cpus: number | null
|
||||
}
|
||||
memory: {
|
||||
total_bytes: number | null
|
||||
free_bytes: number | null
|
||||
available_bytes: number | null
|
||||
buffered_bytes: number | null
|
||||
cached_bytes: number | null
|
||||
swap_total_bytes: number | null
|
||||
swap_free_bytes: number | null
|
||||
}
|
||||
disks: Array<{ mount: string; total_kb: number; used_pct: number }>
|
||||
checked_at: string
|
||||
}
|
||||
|
||||
const BASE = "/api/v1/health"
|
||||
|
||||
export async function getHealth(arcadia: ArcadiaClient): Promise<OverallHealth> {
|
||||
const res = await arcadia.GET<{ data: OverallHealth } | OverallHealth>(BASE)
|
||||
return unwrap(res)
|
||||
}
|
||||
|
||||
export async function getServiceHealth(
|
||||
arcadia: ArcadiaClient,
|
||||
service: HealthSubsystem,
|
||||
): Promise<SubsystemHealth> {
|
||||
const res = await arcadia.GET<{ data: SubsystemHealth } | SubsystemHealth>(
|
||||
`${BASE}/${service}`,
|
||||
)
|
||||
return unwrap(res)
|
||||
}
|
||||
|
||||
export async function getHealthDetailed(arcadia: ArcadiaClient): Promise<DetailedHealth> {
|
||||
const res = await arcadia.GET<{ data: DetailedHealth } | DetailedHealth>(`${BASE}/detailed`)
|
||||
return unwrap(res)
|
||||
}
|
||||
|
||||
export async function getHostStats(arcadia: ArcadiaClient): Promise<HostStats> {
|
||||
const res = await arcadia.GET<{ data: HostStats } | HostStats>(`${BASE}/host`)
|
||||
return unwrap(res)
|
||||
}
|
||||
|
||||
export const SUBSYSTEMS: HealthSubsystem[] = ["api", "db", "workers", "storage"]
|
||||
|
||||
function unwrap<T>(res: { data: T } | T): T {
|
||||
return res && typeof res === "object" && "data" in (res as object)
|
||||
? (res as { data: T }).data
|
||||
: (res as T)
|
||||
}
|
||||
40
app/lib/arcadia/integrations.ts
Normal file
40
app/lib/arcadia/integrations.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
// Integration-registry client (operator surface) — thin shim over the shared
|
||||
// `@crema/integration-registry-client` lib, bound to `operator` mode. The lib
|
||||
// owns the types, the HTTP contract, and the display helpers (shared with
|
||||
// arcadia-console's tenant surface); this file just exposes operator-idiomatic
|
||||
// names so the page reads naturally.
|
||||
|
||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
||||
import {
|
||||
createIntegrationsApi,
|
||||
type CredentialInput,
|
||||
type IntegrationInput,
|
||||
type ScopeFilter,
|
||||
} from "@crema/integration-registry-client"
|
||||
|
||||
// Re-export the shared types + helpers so callers import from one place.
|
||||
export * from "@crema/integration-registry-client"
|
||||
|
||||
const op = (c: ArcadiaClient) => createIntegrationsApi(c, "operator")
|
||||
|
||||
export const listIntegrations = (c: ArcadiaClient, filter: ScopeFilter = {}) =>
|
||||
op(c).list(filter)
|
||||
export const createIntegration = (c: ArcadiaClient, input: IntegrationInput) =>
|
||||
op(c).create(input)
|
||||
export const updateIntegration = (
|
||||
c: ArcadiaClient,
|
||||
id: string,
|
||||
input: Partial<IntegrationInput>,
|
||||
) => op(c).update(id, input)
|
||||
export const deleteIntegration = (c: ArcadiaClient, id: string) => op(c).remove(id)
|
||||
export const addCredential = (c: ArcadiaClient, integrationId: string, input: CredentialInput) =>
|
||||
op(c).addCredential(integrationId, input)
|
||||
export const updateCredential = (
|
||||
c: ArcadiaClient,
|
||||
credentialId: string,
|
||||
input: Partial<CredentialInput>,
|
||||
) => op(c).updateCredential(credentialId, input)
|
||||
export const deleteCredential = (c: ArcadiaClient, credentialId: string) =>
|
||||
op(c).deleteCredential(credentialId)
|
||||
export const testIntegration = (c: ArcadiaClient, id: string) => op(c).test(id)
|
||||
export const usageSummary = (c: ArcadiaClient, filter: ScopeFilter = {}) => op(c).usage(filter)
|
||||
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`)
|
||||
}
|
||||
246
app/lib/arcadia/llm-configs.ts
Normal file
246
app/lib/arcadia/llm-configs.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
// Arcadia LLM configurations API.
|
||||
//
|
||||
// Backed by /api/v1/admin/llm-configurations — server-side persisted
|
||||
// provider/model/secret/cost settings. Replaces the localStorage-driven
|
||||
// settings the admin UI used previously, so configurations and costs
|
||||
// survive across browsers and operators.
|
||||
//
|
||||
// `tenant_id: null` configurations are platform-defaults visible to
|
||||
// every tenant. Names are unique within (tenant, name).
|
||||
|
||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
||||
|
||||
export type LlmProvider = "openai" | "anthropic" | "deepseek" | "qwen" | "lmstudio"
|
||||
|
||||
/**
|
||||
* Reasoning effort. Sent verbatim to OpenAI / DeepSeek (which take
|
||||
* `reasoning_effort` natively). Translated server-side into Anthropic's
|
||||
* thinking block. `off` (or null) skips the field entirely.
|
||||
*/
|
||||
export type ReasoningEffort = "off" | "low" | "medium" | "high" | "max"
|
||||
export const REASONING_EFFORTS: ReasoningEffort[] = [
|
||||
"off",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"max",
|
||||
]
|
||||
|
||||
export interface LlmConfiguration {
|
||||
id: string
|
||||
tenant_id: string | null
|
||||
name: string
|
||||
provider: LlmProvider
|
||||
model: string
|
||||
base_url: string | null
|
||||
secret_name: string | null
|
||||
input_cost_per_million: number | null
|
||||
output_cost_per_million: number | null
|
||||
enabled: boolean
|
||||
reasoning_effort: ReasoningEffort | null
|
||||
metadata: Record<string, unknown>
|
||||
inserted_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface LlmConfigurationInput {
|
||||
tenant_id?: string | null
|
||||
name: string
|
||||
provider: LlmProvider
|
||||
model: string
|
||||
base_url?: string | null
|
||||
secret_name?: string | null
|
||||
/** USD per 1M tokens. Omit to auto-fill from the catalog. */
|
||||
input_cost_per_million?: number | null
|
||||
output_cost_per_million?: number | null
|
||||
enabled?: boolean
|
||||
reasoning_effort?: ReasoningEffort | null
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface CatalogEntry {
|
||||
provider: LlmProvider
|
||||
model: string
|
||||
input_cost_per_million: number
|
||||
output_cost_per_million: number
|
||||
context_window: number | null
|
||||
notes: string | null
|
||||
}
|
||||
|
||||
const BASE = "/api/v1/admin/llm-configurations"
|
||||
|
||||
export async function listConfigurations(
|
||||
arcadia: ArcadiaClient,
|
||||
opts: { enabled?: boolean; tenant_id?: string } = {},
|
||||
): Promise<LlmConfiguration[]> {
|
||||
const params: Record<string, string | number | boolean | null | undefined> = {}
|
||||
if (opts.enabled != null) params.enabled = String(opts.enabled)
|
||||
if (opts.tenant_id) params.tenant_id = opts.tenant_id
|
||||
const res = await arcadia.GET<{ data: LlmConfiguration[] }>(BASE, { params })
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function getConfiguration(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
): Promise<LlmConfiguration> {
|
||||
const res = await arcadia.GET<{ data: LlmConfiguration }>(`${BASE}/${id}`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function createConfiguration(
|
||||
arcadia: ArcadiaClient,
|
||||
input: LlmConfigurationInput,
|
||||
): Promise<LlmConfiguration> {
|
||||
const res = await arcadia.POST<{ data: LlmConfiguration }>(BASE, {
|
||||
body: { configuration: input },
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function updateConfiguration(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
input: Partial<LlmConfigurationInput>,
|
||||
): Promise<LlmConfiguration> {
|
||||
const res = await arcadia.PATCH<{ data: LlmConfiguration }>(`${BASE}/${id}`, {
|
||||
body: { configuration: input },
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function deleteConfiguration(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
): Promise<void> {
|
||||
await arcadia.DELETE(`${BASE}/${id}`)
|
||||
}
|
||||
|
||||
export async function getCatalog(arcadia: ArcadiaClient): Promise<CatalogEntry[]> {
|
||||
const res = await arcadia.GET<{ data: CatalogEntry[] }>(`${BASE}/catalog`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute cost in cents for a given input/output token count using a
|
||||
* configuration's published rates. Mirrors `LlmConfiguration.compute_cost_cents/3`
|
||||
* in arcadia-app — keep in sync.
|
||||
*/
|
||||
export function computeCostCents(
|
||||
config: Pick<LlmConfiguration, "input_cost_per_million" | "output_cost_per_million">,
|
||||
inputTokens: number,
|
||||
outputTokens: number,
|
||||
): number {
|
||||
const inRate = config.input_cost_per_million ?? 0
|
||||
const outRate = config.output_cost_per_million ?? 0
|
||||
const cents = ((inputTokens * inRate + outputTokens * outRate) / 1_000_000) * 100
|
||||
return Math.round(cents)
|
||||
}
|
||||
|
||||
/** Format a cost in cents as "$X.XX" or "$0.0XX" for sub-dollar amounts. */
|
||||
export function formatCost(cents: number): string {
|
||||
if (cents === 0) return "$0"
|
||||
if (cents < 100) return `$${(cents / 100).toFixed(2)}`
|
||||
return `$${(cents / 100).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LLM usage summary (cost roll-up)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface LlmUsageSummary {
|
||||
total_requests: number | null
|
||||
total_input_tokens: number | null
|
||||
total_output_tokens: number | null
|
||||
total_tokens: number | null
|
||||
total_cost_cents: number | null
|
||||
avg_latency_ms: number | null
|
||||
}
|
||||
|
||||
export async function getUsageSummary(
|
||||
arcadia: ArcadiaClient,
|
||||
opts: { days?: number } = {},
|
||||
): Promise<LlmUsageSummary> {
|
||||
const params: Record<string, string | number | boolean | null | undefined> = {}
|
||||
if (opts.days != null) params.days = opts.days
|
||||
const res = await arcadia.GET<{ data: LlmUsageSummary } | LlmUsageSummary>(
|
||||
"/api/v1/ai/llm/usage/summary",
|
||||
{ params },
|
||||
)
|
||||
return "data" in (res as object) ? (res as { data: LlmUsageSummary }).data : (res as LlmUsageSummary)
|
||||
}
|
||||
|
||||
export interface UsageByModelRow {
|
||||
provider: string
|
||||
model: string
|
||||
requests: number
|
||||
total_tokens: number
|
||||
cost_cents: number
|
||||
}
|
||||
|
||||
export async function getUsageByModel(
|
||||
arcadia: ArcadiaClient,
|
||||
opts: { days?: number } = {},
|
||||
): Promise<UsageByModelRow[]> {
|
||||
const params: Record<string, string | number | boolean | null | undefined> = {}
|
||||
if (opts.days != null) params.days = opts.days
|
||||
const res = await arcadia.GET<{ data: UsageByModelRow[] } | UsageByModelRow[]>(
|
||||
"/api/v1/ai/llm/usage/by-model",
|
||||
{ params },
|
||||
)
|
||||
return "data" in (res as object) ? (res as { data: UsageByModelRow[] }).data : (res as UsageByModelRow[])
|
||||
}
|
||||
|
||||
/** Find the spend row matching a given config's (provider, model). */
|
||||
export function findSpend(
|
||||
rows: UsageByModelRow[],
|
||||
config: Pick<LlmConfiguration, "provider" | "model">,
|
||||
): UsageByModelRow | undefined {
|
||||
return rows.find((r) => r.provider === config.provider && r.model === config.model)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Active reasoning_effort (shared between settings panel and /ai composer)
|
||||
//
|
||||
// Stored under crema.ai.reasoning. Written when the operator stars a config
|
||||
// in the settings panel (so the chip on /ai inherits that config's default
|
||||
// on next mount) and when the operator cycles the THINK chip on /ai (per-
|
||||
// conversation override). Wiped on Clear conversation.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ACTIVE_REASONING_KEY = "crema.ai.reasoning"
|
||||
const ACTIVE_REASONING_EVENT = "crema:ai-reasoning-change"
|
||||
|
||||
export function loadActiveReasoning(): ReasoningEffort {
|
||||
if (typeof window === "undefined") return "off"
|
||||
const v = localStorage.getItem(ACTIVE_REASONING_KEY) as ReasoningEffort | null
|
||||
return v && REASONING_EFFORTS.includes(v) ? v : "off"
|
||||
}
|
||||
|
||||
export function saveActiveReasoning(v: ReasoningEffort): void {
|
||||
if (typeof window === "undefined") return
|
||||
if (v === "off") localStorage.removeItem(ACTIVE_REASONING_KEY)
|
||||
else localStorage.setItem(ACTIVE_REASONING_KEY, v)
|
||||
window.dispatchEvent(new CustomEvent(ACTIVE_REASONING_EVENT, { detail: v }))
|
||||
}
|
||||
|
||||
export function subscribeActiveReasoning(
|
||||
listener: (v: ReasoningEffort) => void,
|
||||
): () => void {
|
||||
if (typeof window === "undefined") return () => {}
|
||||
const onChange = (e: Event) => {
|
||||
const detail = (e as CustomEvent<ReasoningEffort>).detail
|
||||
if (detail) listener(detail)
|
||||
else listener(loadActiveReasoning())
|
||||
}
|
||||
// Same-tab via the custom event; cross-tab via the storage event.
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key === ACTIVE_REASONING_KEY) listener(loadActiveReasoning())
|
||||
}
|
||||
window.addEventListener(ACTIVE_REASONING_EVENT, onChange)
|
||||
window.addEventListener("storage", onStorage)
|
||||
return () => {
|
||||
window.removeEventListener(ACTIVE_REASONING_EVENT, onChange)
|
||||
window.removeEventListener("storage", onStorage)
|
||||
}
|
||||
}
|
||||
182
app/lib/arcadia/llm-proxy.ts
Normal file
182
app/lib/arcadia/llm-proxy.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
// Arcadia LLM proxy client.
|
||||
//
|
||||
// Implements the spec in docs/LLM_PROXY_CONTRACT.md against arcadia-app's
|
||||
// POST /api/v1/ai/llm/chat. The lib (@crema/llm-providers-ui buildAdapter)
|
||||
// owns the streaming chat path itself; this module exposes a lightweight
|
||||
// non-streaming probe so the Settings "Test connection" button can verify
|
||||
// the proxy round-trips end-to-end (auth → secret resolution → upstream
|
||||
// dispatch → response shape).
|
||||
|
||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
||||
|
||||
export type LLMProxyProvider =
|
||||
| "openai"
|
||||
| "anthropic"
|
||||
| "deepseek"
|
||||
| "qwen"
|
||||
| "lmstudio"
|
||||
|
||||
export type LLMProxyErrorCode =
|
||||
| "unauthorized"
|
||||
| "secret_disabled"
|
||||
| "secret_expired"
|
||||
| "secret_consumed"
|
||||
| "ip_not_allowed"
|
||||
| "unknown_provider"
|
||||
| "upstream_unavailable"
|
||||
| "rate_limited"
|
||||
| "unknown"
|
||||
|
||||
export interface LLMProxyChatRequest {
|
||||
provider: LLMProxyProvider
|
||||
/** Required for every provider except `lmstudio`. */
|
||||
secret_name?: string
|
||||
model: string
|
||||
messages: Array<{ role: "system" | "user" | "assistant"; content: string }>
|
||||
stream?: boolean
|
||||
max_tokens?: number
|
||||
temperature?: number
|
||||
}
|
||||
|
||||
export interface LLMProxyChatResponse {
|
||||
id: string
|
||||
object: "chat.completion"
|
||||
created: number
|
||||
model: string
|
||||
choices: Array<{
|
||||
index: number
|
||||
finish_reason: string | null
|
||||
message: { role: "assistant"; content: string; tool_calls: unknown }
|
||||
}>
|
||||
usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number }
|
||||
}
|
||||
|
||||
export class LLMProxyError extends Error {
|
||||
readonly code: LLMProxyErrorCode
|
||||
readonly status: number
|
||||
readonly retryAfter?: number
|
||||
|
||||
constructor(code: LLMProxyErrorCode, message: string, status: number, retryAfter?: number) {
|
||||
super(message)
|
||||
this.name = "LLMProxyError"
|
||||
this.code = code
|
||||
this.status = status
|
||||
this.retryAfter = retryAfter
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-streaming chat completion via the proxy. The streaming path is owned
|
||||
* by @crema/llm-providers-ui's buildAdapter; use this for probes and
|
||||
* one-shot calls where SSE is overkill.
|
||||
*/
|
||||
export async function chat(
|
||||
arcadia: ArcadiaClient,
|
||||
req: LLMProxyChatRequest,
|
||||
): Promise<LLMProxyChatResponse> {
|
||||
try {
|
||||
const res = await arcadia.POST<LLMProxyChatResponse>(
|
||||
"/api/v1/ai/llm/chat",
|
||||
{ body: { ...req, stream: false } },
|
||||
)
|
||||
return res
|
||||
} catch (e) {
|
||||
throw asProxyError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheap end-to-end probe for the Settings "Test connection" flow in proxy
|
||||
* mode. Sends a 1-token "ping" and reports whether the proxy is wired,
|
||||
* the secret resolves, and the upstream answered. Intentionally tolerant
|
||||
* of token-budget rejections — those still prove the round-trip works.
|
||||
*/
|
||||
export async function probeProxy(
|
||||
arcadia: ArcadiaClient,
|
||||
opts: { provider: LLMProxyProvider; model: string; secretName?: string },
|
||||
): Promise<{ ok: boolean; message: string }> {
|
||||
try {
|
||||
const res = await chat(arcadia, {
|
||||
provider: opts.provider,
|
||||
secret_name: opts.secretName,
|
||||
model: opts.model,
|
||||
messages: [{ role: "user", content: "ping" }],
|
||||
max_tokens: 1,
|
||||
stream: false,
|
||||
})
|
||||
const used = res.usage?.total_tokens
|
||||
return {
|
||||
ok: true,
|
||||
message: `Proxy OK — ${res.model}${used != null ? ` · ${used} tokens` : ""}.`,
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof LLMProxyError) {
|
||||
return { ok: false, message: friendly(e) }
|
||||
}
|
||||
return { ok: false, message: e instanceof Error ? e.message : String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
function asProxyError(e: unknown): LLMProxyError {
|
||||
// ArcadiaClient throws ArcadiaError with a wrapped { error: { code, message } }
|
||||
// body and HTTP status. Best-effort destructure without coupling to the
|
||||
// class shape (it lives in a sibling lib).
|
||||
if (e && typeof e === "object") {
|
||||
const anyE = e as {
|
||||
status?: number
|
||||
code?: string
|
||||
message?: string
|
||||
body?: { error?: { code?: string; message?: string } }
|
||||
headers?: Headers | Record<string, string>
|
||||
}
|
||||
const status = anyE.status ?? 0
|
||||
const code = (anyE.body?.error?.code ?? anyE.code) as LLMProxyErrorCode | undefined
|
||||
const message = anyE.body?.error?.message ?? anyE.message ?? "Proxy request failed."
|
||||
const retryAfter = readRetryAfter(anyE.headers)
|
||||
return new LLMProxyError(code ?? inferCodeFromStatus(status), message, status, retryAfter)
|
||||
}
|
||||
return new LLMProxyError("unknown", String(e), 0)
|
||||
}
|
||||
|
||||
function inferCodeFromStatus(status: number): LLMProxyErrorCode {
|
||||
if (status === 401) return "unauthorized"
|
||||
if (status === 403) return "ip_not_allowed"
|
||||
if (status === 404) return "unknown_provider"
|
||||
if (status === 410) return "secret_expired"
|
||||
if (status === 429) return "rate_limited"
|
||||
if (status === 502 || status === 503 || status === 504) return "upstream_unavailable"
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
function readRetryAfter(h: Headers | Record<string, string> | undefined): number | undefined {
|
||||
if (!h) return undefined
|
||||
const raw = h instanceof Headers ? h.get("retry-after") : h["retry-after"] ?? h["Retry-After"]
|
||||
if (!raw) return undefined
|
||||
const n = Number(raw)
|
||||
return Number.isFinite(n) ? n : undefined
|
||||
}
|
||||
|
||||
export function friendly(err: LLMProxyError): string {
|
||||
switch (err.code) {
|
||||
case "unauthorized":
|
||||
return "Sign in expired — refresh and try again."
|
||||
case "secret_disabled":
|
||||
return "The vault secret is disabled. Re-enable it under /secrets."
|
||||
case "secret_expired":
|
||||
return "The vault secret has expired. Rotate it under /secrets."
|
||||
case "secret_consumed":
|
||||
return "Read-once secret already used. Rotate it under /secrets."
|
||||
case "ip_not_allowed":
|
||||
return "This client's IP is blocked by the secret's allowlist."
|
||||
case "unknown_provider":
|
||||
return "The proxy doesn't recognise this provider. Check the provider id."
|
||||
case "upstream_unavailable":
|
||||
return "The upstream LLM provider returned an error or timed out."
|
||||
case "rate_limited":
|
||||
return err.retryAfter
|
||||
? `Rate limited. Retry in ${err.retryAfter}s.`
|
||||
: "Rate limited — slow down and try again."
|
||||
default:
|
||||
return err.message
|
||||
}
|
||||
}
|
||||
96
app/lib/arcadia/memberships.ts
Normal file
96
app/lib/arcadia/memberships.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
// Tenant memberships — the M:N glue between users and tenants.
|
||||
// Backend: /api/v1/admin/memberships (admin) + /api/v1/me/tenants (self).
|
||||
|
||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
||||
|
||||
export type MembershipStatus = "active" | "suspended" | "deactivated" | string
|
||||
|
||||
export interface MembershipUser {
|
||||
id: string
|
||||
email: string
|
||||
first_name: string | null
|
||||
last_name: string | null
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface MembershipTenant {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface MembershipRole {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
export interface Membership {
|
||||
id: string
|
||||
tenant_id: string
|
||||
tenant: MembershipTenant | null
|
||||
user_id: string
|
||||
user: MembershipUser | null
|
||||
status: MembershipStatus
|
||||
is_primary: boolean
|
||||
joined_at: string | null
|
||||
last_accessed_at: string | null
|
||||
metadata: Record<string, unknown>
|
||||
roles: MembershipRole[]
|
||||
}
|
||||
|
||||
export interface MembershipInput {
|
||||
user_id: string
|
||||
status?: MembershipStatus
|
||||
metadata?: Record<string, unknown>
|
||||
role_ids?: string[]
|
||||
}
|
||||
|
||||
const BASE = "/api/v1/admin/memberships"
|
||||
|
||||
export async function listMemberships(arcadia: ArcadiaClient): Promise<Membership[]> {
|
||||
const res = await arcadia.GET<{ data: Membership[] }>(BASE)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function createMembership(
|
||||
arcadia: ArcadiaClient,
|
||||
input: MembershipInput,
|
||||
): Promise<Membership> {
|
||||
const res = await arcadia.POST<{ data: Membership }>(BASE, {
|
||||
body: { membership: input },
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function updateMembership(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
input: Partial<MembershipInput>,
|
||||
): Promise<Membership> {
|
||||
const res = await arcadia.PATCH<{ data: Membership }>(`${BASE}/${id}`, {
|
||||
body: { membership: input },
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function deleteMembership(arcadia: ArcadiaClient, id: string): Promise<void> {
|
||||
await arcadia.DELETE(`${BASE}/${id}`)
|
||||
}
|
||||
|
||||
export async function suspendMembership(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
): Promise<Membership> {
|
||||
const res = await arcadia.POST<{ data: Membership }>(`${BASE}/${id}/suspend`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function activateMembership(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
): Promise<Membership> {
|
||||
const res = await arcadia.POST<{ data: Membership }>(`${BASE}/${id}/activate`)
|
||||
return res.data
|
||||
}
|
||||
199
app/lib/arcadia/monitoring.ts
Normal file
199
app/lib/arcadia/monitoring.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
// Server stats / health helpers.
|
||||
// Wraps /api/v1/admin/monitoring/* + /api/v1/platform/* + a few observability
|
||||
// endpoints used by the monitoring dashboard.
|
||||
|
||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
||||
|
||||
// --- Rate limits ---------------------------------------------------------
|
||||
|
||||
export interface RateLimit {
|
||||
type: string
|
||||
max_requests: number
|
||||
window_seconds: number
|
||||
}
|
||||
|
||||
export async function getRateLimits(arcadia: ArcadiaClient): Promise<RateLimit[]> {
|
||||
const res = await arcadia.GET<{ data: { limits: RateLimit[] } }>(
|
||||
"/api/v1/admin/monitoring/rate-limits",
|
||||
)
|
||||
return res.data.limits ?? []
|
||||
}
|
||||
|
||||
// --- Active sessions ----------------------------------------------------
|
||||
|
||||
export interface ActiveSession {
|
||||
user_id: string
|
||||
email: string
|
||||
first_name: string | null
|
||||
last_name: string | null
|
||||
status: string
|
||||
user_type: string | null
|
||||
last_sign_in_at: string
|
||||
tenant_id: string
|
||||
two_factor_enabled: boolean
|
||||
}
|
||||
|
||||
export async function getActiveSessions(
|
||||
arcadia: ArcadiaClient,
|
||||
): Promise<{ sessions: ActiveSession[]; count: number }> {
|
||||
const res = await arcadia.GET<{ data: { sessions: ActiveSession[]; count: number } }>(
|
||||
"/api/v1/admin/monitoring/sessions",
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
// --- Background jobs (Oban) ---------------------------------------------
|
||||
|
||||
export type JobState =
|
||||
| "available"
|
||||
| "executing"
|
||||
| "scheduled"
|
||||
| "retryable"
|
||||
| "discarded"
|
||||
| "cancelled"
|
||||
| "completed"
|
||||
|
||||
export interface JobStats {
|
||||
counts: Record<JobState, number>
|
||||
by_queue: Record<string, Partial<Record<JobState, number>>>
|
||||
queues: string[]
|
||||
}
|
||||
|
||||
export interface ObanJob {
|
||||
id: number
|
||||
queue: string
|
||||
state: JobState
|
||||
worker: string
|
||||
attempt: number
|
||||
max_attempts: number
|
||||
inserted_at: string
|
||||
attempted_at: string | null
|
||||
completed_at: string | null
|
||||
scheduled_at: string | null
|
||||
errors: Array<{ at?: string; attempt?: number; error?: string }> | null
|
||||
}
|
||||
|
||||
export async function getJobStats(arcadia: ArcadiaClient): Promise<JobStats> {
|
||||
const res = await arcadia.GET<{ data: JobStats }>(
|
||||
"/api/v1/admin/monitoring/jobs/stats",
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function getRecentJobs(
|
||||
arcadia: ArcadiaClient,
|
||||
params?: { limit?: number; state?: JobState; queue?: string },
|
||||
): Promise<ObanJob[]> {
|
||||
const res = await arcadia.GET<{ data: { jobs: ObanJob[]; count: number } }>(
|
||||
"/api/v1/admin/monitoring/jobs",
|
||||
{ params: params as Record<string, string | number | undefined> },
|
||||
)
|
||||
return res.data.jobs ?? []
|
||||
}
|
||||
|
||||
export async function retryJob(arcadia: ArcadiaClient, id: number): Promise<void> {
|
||||
await arcadia.POST(`/api/v1/admin/monitoring/jobs/${id}/retry`)
|
||||
}
|
||||
|
||||
// --- Platform infrastructure (DigitalOcean) -----------------------------
|
||||
|
||||
/** Provider returns whatever it returns; admin UI surfaces it loosely. */
|
||||
export type InfrastructureSummary = Record<string, unknown>
|
||||
export type Space = Record<string, unknown>
|
||||
|
||||
export async function getInfrastructureSummary(
|
||||
arcadia: ArcadiaClient,
|
||||
): Promise<InfrastructureSummary | null> {
|
||||
try {
|
||||
const res = await arcadia.GET<{ data: InfrastructureSummary }>(
|
||||
"/api/v1/platform/infrastructure/summary",
|
||||
)
|
||||
return res.data
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSpaces(arcadia: ArcadiaClient): Promise<Space[]> {
|
||||
try {
|
||||
const res = await arcadia.GET<{ data: Space[] }>(
|
||||
"/api/v1/platform/infrastructure/spaces",
|
||||
)
|
||||
return res.data ?? []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// --- Droplets ------------------------------------------------------------
|
||||
|
||||
export interface Droplet {
|
||||
id: number | string
|
||||
name: string
|
||||
status: string
|
||||
region?: { slug?: string; name?: string } | string
|
||||
size_slug?: string
|
||||
vcpus?: number
|
||||
memory?: number
|
||||
disk?: number
|
||||
created_at?: string
|
||||
networks?: unknown
|
||||
/** Provider-specific fields surface verbatim. */
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface DropletMetrics {
|
||||
cpu?: Array<{ time: string; value: number }>
|
||||
memory?: Array<{ time: string; value: number }>
|
||||
disk?: Array<{ time: string; value: number }>
|
||||
bandwidth?: Array<{ time: string; value: number }>
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export async function listDroplets(arcadia: ArcadiaClient): Promise<Droplet[]> {
|
||||
try {
|
||||
const res = await arcadia.GET<{ droplets?: Droplet[]; data?: Droplet[] }>(
|
||||
"/api/v1/platform/droplets",
|
||||
)
|
||||
return res.droplets ?? res.data ?? []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDropletMetrics(
|
||||
arcadia: ArcadiaClient,
|
||||
id: number | string,
|
||||
): Promise<DropletMetrics | null> {
|
||||
try {
|
||||
const res = await arcadia.GET<{ data: DropletMetrics }>(
|
||||
`/api/v1/platform/droplets/${id}/metrics`,
|
||||
)
|
||||
return res.data
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// --- Audit stats (already used by /activity, exposed here for the dashboard) ---
|
||||
|
||||
export interface AuditStats {
|
||||
total: number
|
||||
by_action?: Record<string, number>
|
||||
by_severity?: Record<string, number>
|
||||
by_resource_type?: Record<string, number>
|
||||
/** When backend supports it: { period: ISO, total: number }[] */
|
||||
over_time?: Array<{ period: string; total: number }>
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
162
app/lib/arcadia/networking.ts
Normal file
162
app/lib/arcadia/networking.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
// Networking helpers: firewalls, VPCs, domains + DNS records, floating IPs.
|
||||
// Backend: /api/v1/platform/{firewalls,vpcs,domains,floating_ips,...}.
|
||||
|
||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
||||
|
||||
const BASE = "/api/v1/platform"
|
||||
|
||||
// --- Firewalls ----------------------------------------------------------
|
||||
|
||||
export interface Firewall {
|
||||
id: string | number
|
||||
name: string
|
||||
status?: string
|
||||
inbound_rules?: unknown[]
|
||||
outbound_rules?: unknown[]
|
||||
droplet_ids?: Array<string | number>
|
||||
created_at?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export async function listFirewalls(arcadia: ArcadiaClient): Promise<Firewall[]> {
|
||||
try {
|
||||
const res = await arcadia.GET<{ firewalls?: Firewall[]; data?: Firewall[] }>(
|
||||
`${BASE}/firewalls`,
|
||||
)
|
||||
return res.firewalls ?? res.data ?? []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function createFirewall(
|
||||
arcadia: ArcadiaClient,
|
||||
input: Partial<Firewall>,
|
||||
): Promise<unknown> {
|
||||
return arcadia.POST(`${BASE}/firewalls`, { body: input })
|
||||
}
|
||||
|
||||
export async function deleteFirewall(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string | number,
|
||||
): Promise<void> {
|
||||
await arcadia.DELETE(`${BASE}/firewalls/${id}`)
|
||||
}
|
||||
|
||||
// --- VPCs ---------------------------------------------------------------
|
||||
|
||||
export interface Vpc {
|
||||
id: string
|
||||
name: string
|
||||
region?: string
|
||||
ip_range?: string
|
||||
default?: boolean
|
||||
created_at?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export async function listVpcs(arcadia: ArcadiaClient): Promise<Vpc[]> {
|
||||
try {
|
||||
const res = await arcadia.GET<{ vpcs?: Vpc[]; data?: Vpc[] }>(`${BASE}/vpcs`)
|
||||
return res.vpcs ?? res.data ?? []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// --- Domains + DNS records ----------------------------------------------
|
||||
|
||||
export interface Domain {
|
||||
name: string
|
||||
ttl?: number
|
||||
zone_file?: string | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface DnsRecord {
|
||||
id: string | number
|
||||
type: string
|
||||
name: string
|
||||
data: string
|
||||
priority?: number | null
|
||||
port?: number | null
|
||||
ttl?: number
|
||||
weight?: number | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export async function listDomains(arcadia: ArcadiaClient): Promise<Domain[]> {
|
||||
try {
|
||||
const res = await arcadia.GET<{ domains?: Domain[]; data?: Domain[] }>(`${BASE}/domains`)
|
||||
return res.domains ?? res.data ?? []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function listDnsRecords(
|
||||
arcadia: ArcadiaClient,
|
||||
domainName: string,
|
||||
): Promise<DnsRecord[]> {
|
||||
const res = await arcadia.GET<{ domain_records?: DnsRecord[]; data?: DnsRecord[] }>(
|
||||
`${BASE}/domains/${encodeURIComponent(domainName)}/records`,
|
||||
)
|
||||
return res.domain_records ?? res.data ?? []
|
||||
}
|
||||
|
||||
export async function createDnsRecord(
|
||||
arcadia: ArcadiaClient,
|
||||
domainName: string,
|
||||
input: { type: string; name: string; data: string; ttl?: number; priority?: number },
|
||||
): Promise<unknown> {
|
||||
return arcadia.POST(`${BASE}/domains/${encodeURIComponent(domainName)}/records`, {
|
||||
body: input,
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteDnsRecord(
|
||||
arcadia: ArcadiaClient,
|
||||
domainName: string,
|
||||
recordId: string | number,
|
||||
): Promise<void> {
|
||||
await arcadia.DELETE(
|
||||
`${BASE}/domains/${encodeURIComponent(domainName)}/records/${recordId}`,
|
||||
)
|
||||
}
|
||||
|
||||
// --- Floating IPs -------------------------------------------------------
|
||||
|
||||
export interface FloatingIp {
|
||||
ip: string
|
||||
region?: { slug?: string; name?: string } | string
|
||||
droplet?: { id: number | string; name?: string } | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export async function listFloatingIps(arcadia: ArcadiaClient): Promise<FloatingIp[]> {
|
||||
try {
|
||||
const res = await arcadia.GET<{ floating_ips?: FloatingIp[]; data?: FloatingIp[] }>(
|
||||
`${BASE}/floating_ips`,
|
||||
)
|
||||
return res.floating_ips ?? res.data ?? []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function assignFloatingIp(
|
||||
arcadia: ArcadiaClient,
|
||||
ip: string,
|
||||
dropletId: number | string,
|
||||
): Promise<unknown> {
|
||||
return arcadia.POST(`${BASE}/floating_ips/${ip}/assign`, {
|
||||
body: { droplet_id: dropletId },
|
||||
})
|
||||
}
|
||||
|
||||
export async function unassignFloatingIp(
|
||||
arcadia: ArcadiaClient,
|
||||
ip: string,
|
||||
): Promise<unknown> {
|
||||
return arcadia.POST(`${BASE}/floating_ips/${ip}/unassign`)
|
||||
}
|
||||
180
app/lib/arcadia/organizations.ts
Normal file
180
app/lib/arcadia/organizations.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
// Organizations — end-user workspaces nested under a tenant.
|
||||
// Backend: /api/v1/organizations + /api/v1/admin/organizations.
|
||||
//
|
||||
// Tenant admins (arcadia-admin) bypass per-org membership checks via the
|
||||
// `OrganizationContext` plug, so the same per-org routes used by end-users
|
||||
// are used here to mutate any org in the tenant.
|
||||
|
||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
||||
|
||||
export type OrgStatus = "active" | "frozen" | "pending_deletion" | string
|
||||
export type OnOwnerRemoval =
|
||||
| "delete"
|
||||
| "require_transfer"
|
||||
| "freeze_until_new_owner"
|
||||
export type OrgRole = "owner" | "admin" | "member"
|
||||
export type MembershipStatus = "active" | "suspended" | "invited" | string
|
||||
|
||||
export interface Organization {
|
||||
id: string
|
||||
tenant_id: string
|
||||
slug: string
|
||||
name: string
|
||||
status: OrgStatus
|
||||
on_owner_removal: OnOwnerRemoval
|
||||
settings: Record<string, unknown>
|
||||
metadata: Record<string, unknown>
|
||||
inserted_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface OrgMembership {
|
||||
id: string
|
||||
organization_id: string
|
||||
user_id: string
|
||||
role: OrgRole
|
||||
status: MembershipStatus
|
||||
joined_at: string | null
|
||||
}
|
||||
|
||||
export interface CreateOrgInput {
|
||||
name: string
|
||||
slug: string
|
||||
on_owner_removal?: OnOwnerRemoval
|
||||
settings?: Record<string, unknown>
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface UpdateOrgInput {
|
||||
name?: string
|
||||
status?: OrgStatus
|
||||
on_owner_removal?: OnOwnerRemoval
|
||||
settings?: Record<string, unknown>
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface InviteByEmailInput {
|
||||
email: string
|
||||
role?: OrgRole
|
||||
}
|
||||
|
||||
export interface AddRestrictedUserInput {
|
||||
email: string
|
||||
password: string
|
||||
first_name: string
|
||||
last_name: string
|
||||
role?: OrgRole
|
||||
}
|
||||
|
||||
const BASE = "/api/v1/organizations"
|
||||
const ADMIN_BASE = "/api/v1/admin/organizations"
|
||||
|
||||
// Tenant-wide list: every org in the current tenant. Admin-only.
|
||||
export async function listAllOrganizations(
|
||||
arcadia: ArcadiaClient,
|
||||
): Promise<Organization[]> {
|
||||
const res = await arcadia.GET<{ data: Organization[] }>(ADMIN_BASE)
|
||||
return res.data
|
||||
}
|
||||
|
||||
// End-user list: orgs the current user is a member of.
|
||||
export async function listMyOrganizations(
|
||||
arcadia: ArcadiaClient,
|
||||
): Promise<Organization[]> {
|
||||
const res = await arcadia.GET<{ data: Organization[] }>(BASE)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function createOrganization(
|
||||
arcadia: ArcadiaClient,
|
||||
input: CreateOrgInput,
|
||||
): Promise<Organization> {
|
||||
const res = await arcadia.POST<{ data: Organization }>(BASE, { body: input })
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function getOrganization(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
): Promise<Organization> {
|
||||
const res = await arcadia.GET<{ data: Organization }>(`${BASE}/${id}`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function updateOrganization(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
input: UpdateOrgInput,
|
||||
): Promise<Organization> {
|
||||
const res = await arcadia.PATCH<{ data: Organization }>(`${BASE}/${id}`, {
|
||||
body: input,
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function listMembers(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
status?: MembershipStatus,
|
||||
): Promise<OrgMembership[]> {
|
||||
const path = status
|
||||
? `${BASE}/${id}/members?status=${encodeURIComponent(status)}`
|
||||
: `${BASE}/${id}/members`
|
||||
const res = await arcadia.GET<{ data: OrgMembership[] }>(path)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function inviteMember(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
input: InviteByEmailInput,
|
||||
): Promise<{ type: "membership" | "email_invitation"; [k: string]: unknown }> {
|
||||
const res = await arcadia.POST<{
|
||||
data: { type: "membership" | "email_invitation"; [k: string]: unknown }
|
||||
}>(`${BASE}/${id}/members/invite`, { body: input })
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function addRestrictedMember(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
input: AddRestrictedUserInput,
|
||||
): Promise<{ user: { id: string; email: string; account_type: string }; membership: OrgMembership }> {
|
||||
const res = await arcadia.POST<{
|
||||
data: { user: { id: string; email: string; account_type: string }; membership: OrgMembership }
|
||||
}>(`${BASE}/${id}/members/add_restricted`, { body: input })
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function changeMemberRole(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
userId: string,
|
||||
role: "admin" | "member",
|
||||
): Promise<OrgMembership> {
|
||||
const res = await arcadia.PATCH<{ data: OrgMembership }>(
|
||||
`${BASE}/${id}/members/${userId}/role`,
|
||||
{ body: { role } },
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function removeMember(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
await arcadia.DELETE(`${BASE}/${id}/members/${userId}`)
|
||||
}
|
||||
|
||||
export async function transferOwnership(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
newOwnerUserId: string,
|
||||
): Promise<OrgMembership> {
|
||||
const res = await arcadia.POST<{ data: OrgMembership }>(
|
||||
`${BASE}/${id}/transfer_ownership`,
|
||||
{ body: { new_owner_user_id: newOwnerUserId } },
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
79
app/lib/arcadia/profiles.ts
Normal file
79
app/lib/arcadia/profiles.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
// Arcadia profile API. Backed by /api/v1/profile (current user) — handles
|
||||
// avatar wiring (avatar_digital_object_id + variant URLs) and the basic
|
||||
// profile fields. The "profile" here is the per-tenant profile row, not
|
||||
// the auth account.
|
||||
|
||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
||||
|
||||
export interface Profile {
|
||||
id: string
|
||||
user_id: string
|
||||
tenant_id: string
|
||||
avatar_url: string | null
|
||||
avatar_digital_object_id: string | null
|
||||
/**
|
||||
* Variant URLs keyed by size (e.g. "thumbnail", "medium", "original").
|
||||
* Shape depends on the storage backend; treat as best-effort.
|
||||
*/
|
||||
avatar_urls?: Record<string, string> | null
|
||||
bio?: string | null
|
||||
phone?: string | null
|
||||
location?: string | null
|
||||
timezone?: string | null
|
||||
inserted_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export interface ProfileUpdateInput {
|
||||
avatar_digital_object_id?: string | null
|
||||
bio?: string | null
|
||||
phone?: string | null
|
||||
location?: string | null
|
||||
timezone?: string | null
|
||||
}
|
||||
|
||||
export async function getProfile(arcadia: ArcadiaClient): Promise<Profile> {
|
||||
const res = await arcadia.GET<{ data: Profile } | Profile>("/api/v1/profile")
|
||||
return "data" in (res as object)
|
||||
? (res as { data: Profile }).data
|
||||
: (res as Profile)
|
||||
}
|
||||
|
||||
export async function updateProfile(
|
||||
arcadia: ArcadiaClient,
|
||||
input: ProfileUpdateInput,
|
||||
): Promise<Profile> {
|
||||
const res = await arcadia.PATCH<{ data: Profile } | Profile>("/api/v1/profile", {
|
||||
body: { profile: input },
|
||||
})
|
||||
return "data" in (res as object)
|
||||
? (res as { data: Profile }).data
|
||||
: (res as Profile)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the most appropriate avatar URL from a profile. Backend returns
|
||||
* `avatar_urls = {small, medium, large, original}` keyed by size. The
|
||||
* variants are populated async after image processing completes —
|
||||
* before that, all four are `null` and we fall back to the legacy
|
||||
* `avatar_url` string column (which is also usually null when uploads
|
||||
* use the digital_object pipeline).
|
||||
*
|
||||
* Returns null when nothing is ready; caller should fall back to
|
||||
* fetching the raw content as a blob URL.
|
||||
*/
|
||||
export function pickAvatarUrl(profile: Profile | null | undefined): string | null {
|
||||
if (!profile) return null
|
||||
const variants = profile.avatar_urls
|
||||
if (variants && typeof variants === "object") {
|
||||
return (
|
||||
variants.small ||
|
||||
variants.medium ||
|
||||
variants.large ||
|
||||
variants.original ||
|
||||
profile.avatar_url ||
|
||||
null
|
||||
)
|
||||
}
|
||||
return profile.avatar_url ?? null
|
||||
}
|
||||
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" },
|
||||
]
|
||||
99
app/lib/arcadia/sso.ts
Normal file
99
app/lib/arcadia/sso.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
// SSO / SAML helpers.
|
||||
// Backend: /api/v1/sso/identity-providers (tenant CRUD) + /sessions.
|
||||
// Note: certificates are large and write-only.
|
||||
|
||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
||||
|
||||
export interface IdentityProvider {
|
||||
id: string
|
||||
tenant_id: string
|
||||
name: string
|
||||
entity_id: string
|
||||
sso_url: string
|
||||
slo_url: string | null
|
||||
name_id_format: string | null
|
||||
attribute_mapping: Record<string, string>
|
||||
sp_entity_id: string | null
|
||||
sign_requests: boolean
|
||||
metadata_url: string | null
|
||||
callback_url: string | null
|
||||
enabled: boolean
|
||||
has_certificate: boolean
|
||||
inserted_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface IdentityProviderInput {
|
||||
name: string
|
||||
entity_id: string
|
||||
sso_url: string
|
||||
slo_url?: string | null
|
||||
name_id_format?: string | null
|
||||
attribute_mapping?: Record<string, string>
|
||||
sp_entity_id?: string | null
|
||||
sign_requests?: boolean
|
||||
metadata_url?: string | null
|
||||
callback_url?: string | null
|
||||
enabled?: boolean
|
||||
/** PEM cert from the IdP. Write-only. */
|
||||
certificate?: string
|
||||
}
|
||||
|
||||
export interface SamlSession {
|
||||
id: string
|
||||
user_id: string
|
||||
idp_id: string
|
||||
name_id: string | null
|
||||
session_index: string | null
|
||||
expires_at: string | null
|
||||
inserted_at: string
|
||||
}
|
||||
|
||||
const BASE = "/api/v1/sso"
|
||||
|
||||
export async function listIdentityProviders(arcadia: ArcadiaClient): Promise<IdentityProvider[]> {
|
||||
const res = await arcadia.GET<{ data: IdentityProvider[] }>(`${BASE}/identity-providers`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function createIdentityProvider(
|
||||
arcadia: ArcadiaClient,
|
||||
input: IdentityProviderInput,
|
||||
): Promise<IdentityProvider> {
|
||||
const res = await arcadia.POST<{ data: IdentityProvider }>(
|
||||
`${BASE}/identity-providers`,
|
||||
{ body: { identity_provider: input } },
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function updateIdentityProvider(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
input: Partial<IdentityProviderInput>,
|
||||
): Promise<IdentityProvider> {
|
||||
const res = await arcadia.PATCH<{ data: IdentityProvider }>(
|
||||
`${BASE}/identity-providers/${id}`,
|
||||
{ body: { identity_provider: input } },
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function deleteIdentityProvider(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
): Promise<void> {
|
||||
await arcadia.DELETE(`${BASE}/identity-providers/${id}`)
|
||||
}
|
||||
|
||||
export async function listSamlSessions(arcadia: ArcadiaClient): Promise<SamlSession[]> {
|
||||
const res = await arcadia.GET<{ data: SamlSession[] }>(`${BASE}/sessions`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function destroySamlSession(
|
||||
arcadia: ArcadiaClient,
|
||||
sessionId: string,
|
||||
): Promise<void> {
|
||||
await arcadia.DELETE(`${BASE}/sessions/${sessionId}`)
|
||||
}
|
||||
172
app/lib/arcadia/status-page.ts
Normal file
172
app/lib/arcadia/status-page.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
// Status page helpers — components, incidents, subscribers.
|
||||
// Backend: /api/v1/admin/status-page/* (admin CRUD).
|
||||
|
||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
||||
|
||||
export type ComponentStatus =
|
||||
| "operational"
|
||||
| "degraded_performance"
|
||||
| "partial_outage"
|
||||
| "major_outage"
|
||||
| "maintenance"
|
||||
| string
|
||||
|
||||
export interface StatusComponent {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
status: ComponentStatus
|
||||
display_order: number
|
||||
group_name: string | null
|
||||
inserted_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type IncidentStatus =
|
||||
| "investigating"
|
||||
| "identified"
|
||||
| "monitoring"
|
||||
| "resolved"
|
||||
| string
|
||||
|
||||
export type IncidentImpact = "none" | "minor" | "major" | "critical" | string
|
||||
|
||||
export interface IncidentUpdate {
|
||||
id: string
|
||||
status: IncidentStatus
|
||||
body: string
|
||||
inserted_at: string
|
||||
}
|
||||
|
||||
export interface Incident {
|
||||
id: string
|
||||
title: string
|
||||
status: IncidentStatus
|
||||
impact: IncidentImpact
|
||||
resolved_at: string | null
|
||||
metadata: Record<string, unknown>
|
||||
updates: IncidentUpdate[]
|
||||
components: StatusComponent[]
|
||||
inserted_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface Subscriber {
|
||||
id: string
|
||||
email: string
|
||||
confirmed_at: string | null
|
||||
inserted_at: string
|
||||
}
|
||||
|
||||
export interface ComponentInput {
|
||||
name: string
|
||||
description?: string
|
||||
status?: ComponentStatus
|
||||
display_order?: number
|
||||
group_name?: string | null
|
||||
}
|
||||
|
||||
export interface IncidentInput {
|
||||
title: string
|
||||
status?: IncidentStatus
|
||||
impact?: IncidentImpact
|
||||
/** IDs of affected components. */
|
||||
component_ids?: string[]
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface IncidentUpdateInput {
|
||||
status: IncidentStatus
|
||||
body: string
|
||||
}
|
||||
|
||||
const BASE = "/api/v1/admin/status-page"
|
||||
|
||||
// --- Components ---------------------------------------------------------
|
||||
|
||||
export async function listComponents(arcadia: ArcadiaClient): Promise<StatusComponent[]> {
|
||||
const res = await arcadia.GET<{ data: StatusComponent[] }>(`${BASE}/components`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function createComponent(
|
||||
arcadia: ArcadiaClient,
|
||||
input: ComponentInput,
|
||||
): Promise<StatusComponent> {
|
||||
const res = await arcadia.POST<{ data: StatusComponent }>(`${BASE}/components`, {
|
||||
body: { component: input },
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function updateComponent(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
input: Partial<ComponentInput>,
|
||||
): Promise<StatusComponent> {
|
||||
const res = await arcadia.PUT<{ data: StatusComponent }>(`${BASE}/components/${id}`, {
|
||||
body: { component: input },
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function deleteComponent(arcadia: ArcadiaClient, id: string): Promise<void> {
|
||||
await arcadia.DELETE(`${BASE}/components/${id}`)
|
||||
}
|
||||
|
||||
// --- Incidents ----------------------------------------------------------
|
||||
|
||||
export async function listIncidents(arcadia: ArcadiaClient): Promise<Incident[]> {
|
||||
const res = await arcadia.GET<{ data: Incident[] }>(`${BASE}/incidents`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function getIncident(arcadia: ArcadiaClient, id: string): Promise<Incident> {
|
||||
const res = await arcadia.GET<{ data: Incident }>(`${BASE}/incidents/${id}`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function createIncident(
|
||||
arcadia: ArcadiaClient,
|
||||
input: IncidentInput,
|
||||
): Promise<Incident> {
|
||||
const res = await arcadia.POST<{ data: Incident }>(`${BASE}/incidents`, {
|
||||
body: { incident: input },
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function updateIncident(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
input: Partial<IncidentInput>,
|
||||
): Promise<Incident> {
|
||||
const res = await arcadia.PUT<{ data: Incident }>(`${BASE}/incidents/${id}`, {
|
||||
body: { incident: input },
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function resolveIncident(arcadia: ArcadiaClient, id: string): Promise<Incident> {
|
||||
const res = await arcadia.POST<{ data: Incident }>(`${BASE}/incidents/${id}/resolve`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function addIncidentUpdate(
|
||||
arcadia: ArcadiaClient,
|
||||
incidentId: string,
|
||||
input: IncidentUpdateInput,
|
||||
): Promise<IncidentUpdate> {
|
||||
const res = await arcadia.POST<{ data: IncidentUpdate }>(
|
||||
`${BASE}/incidents/${incidentId}/updates`,
|
||||
{ body: { update: input } },
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
// --- Subscribers --------------------------------------------------------
|
||||
|
||||
export async function listSubscribers(arcadia: ArcadiaClient): Promise<Subscriber[]> {
|
||||
const res = await arcadia.GET<{ data: Subscriber[] }>(`${BASE}/subscribers`)
|
||||
return res.data
|
||||
}
|
||||
169
app/lib/arcadia/storage-configs.ts
Normal file
169
app/lib/arcadia/storage-configs.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
// 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 backend's filesystem root. Backend changeset rejects "path" — must
|
||||
// be `base_path`. Keep this in sync with `Arcadia.Storage.Adapters.Local`.
|
||||
local: ["base_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 === "***"
|
||||
}
|
||||
@@ -91,3 +91,23 @@ export async function deactivateTenant(arcadia: ArcadiaClient, id: string): Prom
|
||||
const res = await arcadia.POST<{ data: Tenant }>(`/api/v1/admin/tenants/${id}/deactivate`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export interface ProvisionTenantInput {
|
||||
tenant: { name: string; slug: string }
|
||||
admin_user: {
|
||||
email: string
|
||||
password: string
|
||||
first_name: string
|
||||
last_name: string
|
||||
}
|
||||
}
|
||||
|
||||
export async function provisionTenant(
|
||||
arcadia: ArcadiaClient,
|
||||
input: ProvisionTenantInput,
|
||||
): Promise<Tenant> {
|
||||
const res = await arcadia.POST<{ data: Tenant }>("/api/v1/admin/tenants/provision", {
|
||||
body: input,
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
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",
|
||||
]
|
||||
247
app/lib/block-schemas.ts
Normal file
247
app/lib/block-schemas.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
// Lazy-fetched schemas for the typed fenced blocks the assistant can emit.
|
||||
// The system prompt only ships a thin index (kind → one-line purpose). Full
|
||||
// JSON schemas + examples live here and are pulled on demand via the
|
||||
// `get_block_schema` tool. Keeps the always-on prompt small and lets new
|
||||
// blocks be added by editing this file alone — no prompt edits required.
|
||||
//
|
||||
// Renderer is in app/components/assistant/message-body.tsx — keep these in
|
||||
// sync (kinds, field names) when adding or changing blocks.
|
||||
|
||||
export type BlockKind =
|
||||
| "kpi"
|
||||
| "table"
|
||||
| "chart-bar"
|
||||
| "chart-line"
|
||||
| "chart-donut"
|
||||
| "chart-spark"
|
||||
| "code"
|
||||
| "diff"
|
||||
| "card"
|
||||
| "flowchart"
|
||||
| "orgchart"
|
||||
| "steps"
|
||||
| "checklist"
|
||||
| "welcome"
|
||||
| "hint"
|
||||
|
||||
export const BLOCK_INDEX: Record<BlockKind, string> = {
|
||||
kpi: "Headline numbers row (2–6 metrics).",
|
||||
table: "Tabular data (≥3 rows or ≥3 columns).",
|
||||
"chart-bar": "Compare ≤8 categories.",
|
||||
"chart-line": "Ordered series / trend over time.",
|
||||
"chart-donut": "Part-to-whole, ≤5 slices.",
|
||||
"chart-spark": "Inline trend, no axes.",
|
||||
code: "Syntax-highlighted snippet (SQL, JSON, YAML, etc).",
|
||||
diff: "Before/after comparison.",
|
||||
card: "Inline pill, stat chip, or callout banner.",
|
||||
flowchart: "Process / decision flow with shaped nodes (start/end/process/decision/io).",
|
||||
orgchart: "Tree of nested entities (org structure, dependency tree, taxonomy).",
|
||||
steps: "Multi-step plan with statuses (queued/running/done/error/skipped).",
|
||||
checklist: "Onboarding checklist with completable tasks (links/CTAs allowed).",
|
||||
welcome: "Hero welcome card with title, description, primary/secondary CTA.",
|
||||
hint: "Tip / lightbulb card with tone (info/success/warning/neutral/primary).",
|
||||
}
|
||||
|
||||
const SCHEMAS: Record<BlockKind, string> = {
|
||||
kpi: `\`\`\`kpi
|
||||
{ "items": [
|
||||
{ "label": "Tenants", "value": 42 },
|
||||
{ "label": "Active users", "value": 318, "unit": "/day" }
|
||||
] }
|
||||
\`\`\`
|
||||
Fields: items[]: { label: string, value: string|number, unit?: string }.
|
||||
Use 2–6 items. Don't repeat the numbers in prose.`,
|
||||
|
||||
table: `\`\`\`table
|
||||
{ "columns": [
|
||||
{ "id": "slug", "header": "Tenant" },
|
||||
{ "id": "users", "header": "Users", "align": "right" },
|
||||
{ "id": "status", "header": "Status" }
|
||||
],
|
||||
"rows": [
|
||||
{ "slug": "acme", "users": 42, "status": "active" },
|
||||
{ "slug": "globex", "users": 18, "status": "suspended" }
|
||||
],
|
||||
"idKey": "slug" }
|
||||
\`\`\`
|
||||
Fields:
|
||||
- columns[]: { id: string, header?: string, align?: "left"|"center"|"right", sortable?: boolean }
|
||||
- rows[]: object keyed by column id.
|
||||
- idKey?: string — column whose value is the row id (defaults to first column).
|
||||
Use for ≥3 rows OR ≥3 columns. Smaller lists → markdown table.`,
|
||||
|
||||
"chart-bar": `\`\`\`chart-bar
|
||||
{ "title": "Users by tenant",
|
||||
"data": [
|
||||
{ "label": "acme", "value": 42 },
|
||||
{ "label": "globex", "value": 18 }
|
||||
] }
|
||||
\`\`\`
|
||||
Fields: title?: string, data[]: { label: string, value: number, color?: string }.
|
||||
≤8 categories. For more, use a table.`,
|
||||
|
||||
"chart-line": `\`\`\`chart-line
|
||||
{ "title": "Signups over time",
|
||||
"series": [
|
||||
{ "x": 1, "y": 12 }, { "x": 2, "y": 19 }, { "x": 3, "y": 24 }
|
||||
] }
|
||||
\`\`\`
|
||||
Fields: title?: string, series[]: { x: number, y: number }.
|
||||
Use for ordered numeric series ≥3 points. x is treated as a numeric axis.`,
|
||||
|
||||
"chart-donut": `\`\`\`chart-donut
|
||||
{ "title": "Status breakdown",
|
||||
"data": [
|
||||
{ "label": "active", "value": 38 },
|
||||
{ "label": "suspended", "value": 4 }
|
||||
] }
|
||||
\`\`\`
|
||||
Fields: title?: string, data[]: { label: string, value: number, color?: string }.
|
||||
≤5 slices. Skip if one slice would be >90%.`,
|
||||
|
||||
"chart-spark": `\`\`\`chart-spark
|
||||
{ "values": [3, 5, 4, 8, 12, 9, 14] }
|
||||
\`\`\`
|
||||
Fields: values: number[], width?, height?, stroke?, fill?.
|
||||
Use inline next to a single number to show its recent trend.`,
|
||||
|
||||
code: `\`\`\`code
|
||||
{ "code": "SELECT count(*) FROM tenants WHERE status='active';",
|
||||
"language": "sql",
|
||||
"title": "Active tenant count",
|
||||
"lineNumbers": false,
|
||||
"highlightLines": [] }
|
||||
\`\`\`
|
||||
Fields: code: string, language?: string, title?: string, lineNumbers?: boolean, highlightLines?: number[].
|
||||
Languages with syntax: js/ts/tsx, python, rust, go, html, css, sql, json, yaml.
|
||||
Prefer this over plain markdown fences when the snippet matters (queries the user might copy, configs, etc.).`,
|
||||
|
||||
diff: `\`\`\`diff
|
||||
{ "oldCode": "max_users: 100\\n",
|
||||
"newCode": "max_users: 250\\n",
|
||||
"language": "yaml",
|
||||
"title": "Tenant quota change",
|
||||
"mode": "unified" }
|
||||
\`\`\`
|
||||
Fields: oldCode: string, newCode: string, language?: string, title?: string, mode?: "unified"|"split".
|
||||
Use for showing exactly what changed in a config, query, or file.`,
|
||||
|
||||
card: `\`\`\`card
|
||||
{ "kind": "callout", "tone": "warning", "title": "Heads up", "body": "This action is destructive." }
|
||||
\`\`\`
|
||||
Three sub-kinds:
|
||||
- pill: { "kind": "pill", "status": "active"|"suspended"|"deactivated"|other, "label"?: string } — small status badge.
|
||||
- stat: { "kind": "stat", "label": string, "value": string|number } — inline metric chip.
|
||||
- callout: { "kind": "callout", "tone": "info"|"warning"|"danger"|"success", "title"?: string, "body"?: string } — banner.
|
||||
Use sparingly. For multiple metrics use \`kpi\` instead of multiple \`stat\` cards.`,
|
||||
|
||||
flowchart: `\`\`\`flowchart
|
||||
{ "nodes": [
|
||||
{ "id": "a", "type": "start", "label": "Receive request", "x": 60, "y": 20 },
|
||||
{ "id": "b", "type": "process", "label": "Validate token", "x": 60, "y": 100 },
|
||||
{ "id": "c", "type": "decision", "label": "Token valid?", "x": 60, "y": 180 },
|
||||
{ "id": "d", "type": "process", "label": "Process", "x": 220, "y": 180 },
|
||||
{ "id": "e", "type": "end", "label": "Reject", "x": 60, "y": 280 }
|
||||
],
|
||||
"edges": [
|
||||
{ "from": "a", "to": "b" },
|
||||
{ "from": "b", "to": "c" },
|
||||
{ "from": "c", "to": "d", "label": "yes" },
|
||||
{ "from": "c", "to": "e", "label": "no" }
|
||||
] }
|
||||
\`\`\`
|
||||
Fields:
|
||||
- nodes[]: { id: string, type: "start"|"end"|"process"|"decision"|"io", label: string, x: number, y: number } — coordinates in pixels (canvas auto-sizes).
|
||||
- edges[]: { from: nodeId, to: nodeId, label?: string }.
|
||||
Use for control flow, workflows, request lifecycles. Keep ≤12 nodes; lay out top-to-bottom or left-to-right with ~80–120px spacing.`,
|
||||
|
||||
orgchart: `\`\`\`orgchart
|
||||
{ "data": {
|
||||
"id": "root", "name": "Platform", "title": "Tenant",
|
||||
"children": [
|
||||
{ "id": "a", "name": "Auth", "title": "Service",
|
||||
"children": [
|
||||
{ "id": "a1", "name": "Sessions", "title": "Module" },
|
||||
{ "id": "a2", "name": "MFA", "title": "Module" }
|
||||
] },
|
||||
{ "id": "b", "name": "Billing", "title": "Service" }
|
||||
] },
|
||||
"horizontal": false }
|
||||
\`\`\`
|
||||
Fields:
|
||||
- data: OrgNode = { id: string, name: string, title?: string, avatar?: string (url), children?: OrgNode[] }
|
||||
- horizontal?: boolean — left-to-right vs top-to-bottom (default).
|
||||
Use for nested hierarchies (org charts, dependency trees, taxonomies). Skip for flat lists.`,
|
||||
|
||||
steps: `\`\`\`steps
|
||||
{ "steps": [
|
||||
{ "id": "1", "title": "List tenants", "status": "done", "detail": "Found 42 tenants" },
|
||||
{ "id": "2", "title": "Filter suspended", "status": "running" },
|
||||
{ "id": "3", "title": "Build report", "status": "queued" }
|
||||
] }
|
||||
\`\`\`
|
||||
Fields:
|
||||
- steps[]: { id: string, title: string, status: "queued"|"planning"|"running"|"waiting"|"done"|"error"|"skipped", detail?: string, substeps?: same-shape[] }
|
||||
Use for: showing a multi-step plan you're about to execute, or a post-hoc trail of what you did. Skip for single-step actions.`,
|
||||
|
||||
checklist: `\`\`\`checklist
|
||||
{ "title": "Get started",
|
||||
"description": "Finish setting up your tenant.",
|
||||
"tasks": [
|
||||
{ "id": "1", "title": "Invite your team", "description": "Add at least one admin.", "completed": true, "estimate": "2 min" },
|
||||
{ "id": "2", "title": "Connect a storage bucket", "completed": false, "href": "/buckets", "estimate": "5 min" },
|
||||
{ "id": "3", "title": "Set up SSO", "completed": false, "optional": true, "href": "/sso", "estimate": "10 min" }
|
||||
] }
|
||||
\`\`\`
|
||||
Fields:
|
||||
- title?: string, description?: string
|
||||
- tasks[]: { id: string, title: string, description?: string, completed?: boolean, optional?: boolean, estimate?: string, href?: string }
|
||||
Use for: actionable setup lists with progress. Each task with an href becomes a click-through link. Toggling is read-only in chat (can't persist completion across turns).`,
|
||||
|
||||
welcome: `\`\`\`welcome
|
||||
{ "title": "Welcome to Arcadia Admin",
|
||||
"description": "Manage tenants, users, and platform settings from one place.",
|
||||
"badge": "v2",
|
||||
"primaryAction": { "label": "Create your first tenant", "href": "/tenants" },
|
||||
"secondaryAction": { "label": "Read the docs", "href": "/library" } }
|
||||
\`\`\`
|
||||
Fields:
|
||||
- title: string (required), description?: string, badge?: string
|
||||
- primaryAction?, secondaryAction?: { label: string, href?: string }
|
||||
Use sparingly — once at the top of a thread that's introducing a feature/product, never as a recurring response.`,
|
||||
|
||||
hint: `\`\`\`hint
|
||||
{ "title": "Tip", "tone": "info", "body": "Suspending a tenant blocks login but preserves data — use deactivate to permanently disable.", "action": { "label": "See suspension docs", "href": "/library?q=suspend" } }
|
||||
\`\`\`
|
||||
Fields:
|
||||
- title?: string, body: string
|
||||
- tone?: "info"|"success"|"warning"|"neutral"|"primary" (default "info")
|
||||
- action?: { label: string, href?: string }
|
||||
Use for: discoverability tips, gotchas, "did you know". One per reply.`,
|
||||
}
|
||||
|
||||
const ALL_KINDS = Object.keys(SCHEMAS) as BlockKind[]
|
||||
|
||||
export function isBlockKind(kind: string): kind is BlockKind {
|
||||
return (ALL_KINDS as string[]).includes(kind)
|
||||
}
|
||||
|
||||
export function getBlockSchema(kind: string): string | null {
|
||||
if (!isBlockKind(kind)) return null
|
||||
return SCHEMAS[kind]
|
||||
}
|
||||
|
||||
/** Thin index suitable for the always-on system prompt. */
|
||||
export function blockIndexForPrompt(): string {
|
||||
const lines = ALL_KINDS.map((k) => ` ${k} — ${BLOCK_INDEX[k]}`)
|
||||
return [
|
||||
"Rich output: when a UI primitive will communicate better than prose, emit a typed fenced ```<kind>\\n<json>\\n``` block. The chat renderer turns it into a @crema/*-ui component inline at that position.",
|
||||
"",
|
||||
"Available kinds:",
|
||||
...lines,
|
||||
"",
|
||||
"Before emitting a block for the FIRST time in a thread, call get_block_schema(kind) to fetch the exact JSON shape and field rules. Once you've seen a schema in this conversation, reuse it from memory.",
|
||||
"Always lead with one short sentence of prose, then the block. Don't repeat block data in prose.",
|
||||
"JSON must be valid (double quotes, no trailing commas). If unsure of the schema, fetch it.",
|
||||
].join("\n")
|
||||
}
|
||||
168
app/lib/capabilities.ts
Normal file
168
app/lib/capabilities.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
// Capability gating — the contract between roles, nav, and routes.
|
||||
//
|
||||
// A capability is a *thing the user can do in this UI*. The set held by
|
||||
// the current session is computed from their active membership's roles
|
||||
// + the slug of the active tenant (platform-admin gets the platform.*
|
||||
// fleet by default). Sidebar nav filters by it; per-route guards 403
|
||||
// when the user deep-links to one they don't hold.
|
||||
//
|
||||
// The server is the real authority — these checks are UI-shaping, not
|
||||
// security. Don't ever trust the client capability check on its own.
|
||||
|
||||
export type Capability =
|
||||
// tenant.* — held by tenant_admin on the active membership.
|
||||
| "tenant.home"
|
||||
| "tenant.users"
|
||||
| "tenant.invitations"
|
||||
| "tenant.roles"
|
||||
| "tenant.memberships"
|
||||
| "tenant.apps"
|
||||
| "tenant.plan"
|
||||
| "tenant.entitlements"
|
||||
| "tenant.storage"
|
||||
| "tenant.buckets"
|
||||
| "tenant.activity"
|
||||
| "tenant.settings"
|
||||
| "tenant.profile"
|
||||
// platform.* — held by platform_admin on platform-admin.
|
||||
| "platform.tenants"
|
||||
| "platform.organizations"
|
||||
| "platform.networking"
|
||||
| "platform.monitoring"
|
||||
| "platform.status_page"
|
||||
| "platform.scheduled_tasks"
|
||||
| "platform.secrets"
|
||||
| "platform.webhooks"
|
||||
| "platform.announcements"
|
||||
| "platform.sso"
|
||||
| "platform.library"
|
||||
| "platform.search"
|
||||
| "platform.ai"
|
||||
| "platform.integrations" // external-API registry (keys/budgets) on the gateway
|
||||
// Special — always-on; not gated.
|
||||
| "always.assistant"
|
||||
| "always.profile"
|
||||
|
||||
/** Roles arcadia issues that this UI knows about. */
|
||||
export type Role =
|
||||
| "platform_admin"
|
||||
| "tenant_admin"
|
||||
| "member"
|
||||
| (string & {}) // accept unknown roles forward-compat
|
||||
|
||||
const TENANT_ADMIN_CAPS: Capability[] = [
|
||||
"tenant.home",
|
||||
"tenant.users",
|
||||
"tenant.invitations",
|
||||
"tenant.roles",
|
||||
"tenant.memberships",
|
||||
"tenant.apps",
|
||||
"tenant.plan",
|
||||
"tenant.entitlements",
|
||||
"tenant.storage",
|
||||
"tenant.buckets",
|
||||
"tenant.activity",
|
||||
"tenant.settings",
|
||||
"tenant.profile",
|
||||
]
|
||||
|
||||
const PLATFORM_ADMIN_CAPS: Capability[] = [
|
||||
// platform_admin also gets every tenant.* — they're an admin of the
|
||||
// platform-admin tenant, so they manage *its* users, storage, etc.
|
||||
...TENANT_ADMIN_CAPS,
|
||||
"platform.tenants",
|
||||
"platform.organizations",
|
||||
"platform.networking",
|
||||
"platform.monitoring",
|
||||
"platform.status_page",
|
||||
"platform.scheduled_tasks",
|
||||
"platform.secrets",
|
||||
"platform.webhooks",
|
||||
"platform.announcements",
|
||||
"platform.sso",
|
||||
"platform.library",
|
||||
"platform.search",
|
||||
"platform.ai",
|
||||
"platform.integrations",
|
||||
]
|
||||
|
||||
const ALWAYS_CAPS: Capability[] = ["always.assistant", "always.profile"]
|
||||
|
||||
export function capabilitiesForRoles(roles: readonly string[] | undefined): Set<Capability> {
|
||||
const caps = new Set<Capability>(ALWAYS_CAPS)
|
||||
const has = (r: string) => (roles ?? []).includes(r)
|
||||
if (has("platform_admin")) PLATFORM_ADMIN_CAPS.forEach((c) => caps.add(c))
|
||||
if (has("tenant_admin") || has("admin")) TENANT_ADMIN_CAPS.forEach((c) => caps.add(c))
|
||||
// "member" / other roles get only the always-on set.
|
||||
return caps
|
||||
}
|
||||
|
||||
/** Pure helper — handy in tests + route loaders. */
|
||||
export function holds(caps: Set<Capability>, cap: Capability): boolean {
|
||||
return caps.has(cap)
|
||||
}
|
||||
|
||||
// ----------------------------- Route map ----------------------------
|
||||
//
|
||||
// Every protected route declares which capability it needs. Sidebar nav
|
||||
// and the per-route guard both read this map, so the contract lives in
|
||||
// one place.
|
||||
|
||||
export const ROUTE_CAPABILITY: Record<string, Capability> = {
|
||||
"/": "tenant.home",
|
||||
"/users": "tenant.users",
|
||||
"/memberships": "tenant.memberships",
|
||||
"/storage": "tenant.storage",
|
||||
"/buckets": "tenant.buckets",
|
||||
"/activity": "tenant.activity",
|
||||
"/settings": "tenant.settings",
|
||||
"/apps": "tenant.apps",
|
||||
"/plan": "tenant.plan",
|
||||
"/entitlements": "tenant.entitlements",
|
||||
|
||||
"/tenants": "platform.tenants",
|
||||
"/organizations": "platform.organizations",
|
||||
"/networking": "platform.networking",
|
||||
"/monitoring": "platform.monitoring",
|
||||
"/status-page": "platform.status_page",
|
||||
"/scheduled-tasks": "platform.scheduled_tasks",
|
||||
"/secrets": "platform.secrets",
|
||||
"/webhooks": "platform.webhooks",
|
||||
"/announcements": "platform.announcements",
|
||||
"/sso": "platform.sso",
|
||||
"/library": "platform.library",
|
||||
"/search": "platform.search",
|
||||
"/ai": "platform.ai",
|
||||
"/integrations": "platform.integrations",
|
||||
|
||||
"/assistant": "always.assistant",
|
||||
"/profile": "always.profile",
|
||||
}
|
||||
|
||||
// ----------------------------- Hooks --------------------------------
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { useSession } from "~/lib/session"
|
||||
|
||||
/** The active session's capability set. Empty when not signed in. */
|
||||
export function useCapabilities(): Set<Capability> {
|
||||
const session = useSession()
|
||||
return useMemo(() => capabilitiesForRoles(session?.roles), [session?.roles])
|
||||
}
|
||||
|
||||
export function useHasCapability(cap: Capability): boolean {
|
||||
return useCapabilities().has(cap)
|
||||
}
|
||||
|
||||
export function capabilityForPath(pathname: string): Capability | null {
|
||||
// Exact match first.
|
||||
if (ROUTE_CAPABILITY[pathname]) return ROUTE_CAPABILITY[pathname]
|
||||
// Then prefix match — "/users/123" inherits "/users"'s capability.
|
||||
// Walk known keys longest-first so "/scheduled-tasks/x" picks the
|
||||
// right one over "/s".
|
||||
const keys = Object.keys(ROUTE_CAPABILITY).sort((a, b) => b.length - a.length)
|
||||
for (const k of keys) {
|
||||
if (k !== "/" && pathname.startsWith(k + "/")) return ROUTE_CAPABILITY[k]
|
||||
}
|
||||
return null
|
||||
}
|
||||
38
app/lib/gateway.ts
Normal file
38
app/lib/gateway.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// Arcadia LLM-gateway client.
|
||||
//
|
||||
// The integration registry lives on arcadia-llm-gateway, not arcadia-app, so
|
||||
// it needs its own ArcadiaClient pointed at a different base URL. Everything
|
||||
// else is identical to the arcadia-app client: the same access token (the
|
||||
// gateway validates arcadia-app JWTs via the shared Guardian secret) and the
|
||||
// same 401 cleanup. The gateway's CORS already allows localhost + any
|
||||
// *.sky-ai.com origin, so the browser calls it directly.
|
||||
|
||||
import { createArcadiaClient, type ArcadiaClient } from "@crema/arcadia-client"
|
||||
|
||||
const GATEWAY_URL = import.meta.env.VITE_LLM_GATEWAY_URL ?? "http://localhost:4015"
|
||||
|
||||
const ACCESS_TOKEN_KEY = "arcadia_access_token"
|
||||
const REFRESH_TOKEN_KEY = "arcadia_refresh_token"
|
||||
|
||||
let client: ArcadiaClient | null = null
|
||||
|
||||
export function gatewayClient(): ArcadiaClient {
|
||||
if (!client) {
|
||||
client = createArcadiaClient({
|
||||
baseUrl: GATEWAY_URL,
|
||||
getToken: () =>
|
||||
typeof window === "undefined" ? null : sessionStorage.getItem(ACCESS_TOKEN_KEY),
|
||||
onUnauthorized: () => {
|
||||
if (typeof window !== "undefined") {
|
||||
sessionStorage.removeItem(ACCESS_TOKEN_KEY)
|
||||
sessionStorage.removeItem(REFRESH_TOKEN_KEY)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
export function useGatewayClient(): ArcadiaClient {
|
||||
return gatewayClient()
|
||||
}
|
||||
49
app/lib/jwt.ts
Normal file
49
app/lib/jwt.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
// Tiny JWT helpers — we never *verify* tokens client-side (the server
|
||||
// is the only authority), we just decode the payload to read claims
|
||||
// the UI uses for nav gating + tenant context.
|
||||
|
||||
export type ArcadiaClaims = {
|
||||
sub?: string
|
||||
email?: string
|
||||
tenant_id?: string
|
||||
tenant_slug?: string
|
||||
roles?: string[]
|
||||
available_tenants?: AvailableTenantClaim[]
|
||||
exp?: number
|
||||
iat?: number
|
||||
[k: string]: unknown
|
||||
}
|
||||
|
||||
export type AvailableTenantClaim = {
|
||||
id?: string
|
||||
slug?: string
|
||||
name?: string
|
||||
roles?: string[]
|
||||
}
|
||||
|
||||
function b64urlDecode(s: string): string {
|
||||
const pad = "=".repeat((4 - (s.length % 4)) % 4)
|
||||
const b64 = (s + pad).replace(/-/g, "+").replace(/_/g, "/")
|
||||
if (typeof atob === "function") return atob(b64)
|
||||
// Node fallback (SSR / tests)
|
||||
return Buffer.from(b64, "base64").toString("binary")
|
||||
}
|
||||
|
||||
export function decodeJwt(token: string): ArcadiaClaims | null {
|
||||
if (!token) return null
|
||||
const parts = token.split(".")
|
||||
if (parts.length !== 3) return null
|
||||
try {
|
||||
const raw = b64urlDecode(parts[1])
|
||||
// Handle UTF-8: atob returns binary string; reconstruct UTF-8.
|
||||
const utf8 =
|
||||
typeof TextDecoder !== "undefined"
|
||||
? new TextDecoder().decode(
|
||||
Uint8Array.from(raw, (c) => c.charCodeAt(0)),
|
||||
)
|
||||
: raw
|
||||
return JSON.parse(utf8) as ArcadiaClaims
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
91
app/lib/llm-config-bootstrap.tsx
Normal file
91
app/lib/llm-config-bootstrap.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
// One-time bootstrap of the active LLM settings from arcadia.
|
||||
//
|
||||
// On mount (and again whenever the session changes), if the operator has no
|
||||
// active LLM settings in localStorage, fetch the tenant's enabled
|
||||
// configurations and seed the active settings from the preferred row:
|
||||
//
|
||||
// 1. Any row with `metadata.default === true` (operator-marked default).
|
||||
// 2. Otherwise the first enabled row.
|
||||
//
|
||||
// Once active settings exist, this component does nothing — the settings
|
||||
// panel remains the place to switch between configs.
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { useArcadiaClient } from "@crema/arcadia-client"
|
||||
import {
|
||||
loadSettings,
|
||||
saveSettings,
|
||||
type LLMProvidersSettings,
|
||||
type ProviderId,
|
||||
} from "@crema/llm-providers-ui"
|
||||
|
||||
import {
|
||||
listConfigurations,
|
||||
saveActiveReasoning,
|
||||
type LlmConfiguration,
|
||||
} from "~/lib/arcadia/llm-configs"
|
||||
|
||||
const ACTIVE_KEY = "crema.llm-providers.settings"
|
||||
|
||||
function hasActiveSettings(): boolean {
|
||||
if (typeof window === "undefined") return false
|
||||
return !!localStorage.getItem(ACTIVE_KEY)
|
||||
}
|
||||
|
||||
function pickPreferred(configs: LlmConfiguration[]): LlmConfiguration | null {
|
||||
const enabled = configs.filter((c) => c.enabled)
|
||||
if (enabled.length === 0) return null
|
||||
const flagged = enabled.find(
|
||||
(c) => (c.metadata as { default?: boolean } | null)?.default === true,
|
||||
)
|
||||
return flagged ?? enabled[0]
|
||||
}
|
||||
|
||||
function applyConfig(c: LlmConfiguration): void {
|
||||
const current = loadSettings()
|
||||
const next: LLMProvidersSettings = {
|
||||
...current,
|
||||
providerId: c.provider as ProviderId,
|
||||
model: c.model,
|
||||
baseURL: c.base_url || undefined,
|
||||
secretName: c.secret_name || undefined,
|
||||
}
|
||||
saveSettings(next)
|
||||
saveActiveReasoning(c.reasoning_effort ?? "off")
|
||||
}
|
||||
|
||||
export function LlmConfigBootstrap() {
|
||||
const arcadia = useArcadiaClient()
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const tryBootstrap = async () => {
|
||||
if (hasActiveSettings()) return
|
||||
const token =
|
||||
typeof window !== "undefined"
|
||||
? sessionStorage.getItem("arcadia_access_token")
|
||||
: null
|
||||
if (!token) return
|
||||
try {
|
||||
const configs = await listConfigurations(arcadia, { enabled: true })
|
||||
if (cancelled) return
|
||||
const pick = pickPreferred(configs)
|
||||
if (pick && !hasActiveSettings()) applyConfig(pick)
|
||||
} catch {
|
||||
// 401 / network — silently skip; will retry on next session change.
|
||||
}
|
||||
}
|
||||
|
||||
void tryBootstrap()
|
||||
|
||||
const onSessionChange = () => void tryBootstrap()
|
||||
window.addEventListener("crema:session-change", onSessionChange)
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.removeEventListener("crema:session-change", onSessionChange)
|
||||
}
|
||||
}, [arcadia])
|
||||
|
||||
return null
|
||||
}
|
||||
90
app/lib/profile-bootstrap.tsx
Normal file
90
app/lib/profile-bootstrap.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
// Fetches the arcadia profile on app boot (and after login) and caches
|
||||
// the resolved avatar URL in localStorage so the appbar's <Avatar> shows
|
||||
// immediately, without waiting for the user to navigate to /profile.
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { useArcadiaClient } from "@crema/arcadia-client"
|
||||
|
||||
import { fetchDigitalObjectAsBlobUrl } from "~/lib/arcadia/digital-objects"
|
||||
import { getProfile, pickAvatarUrl } from "~/lib/arcadia/profiles"
|
||||
import { loadProfile, saveProfile } from "~/lib/profile"
|
||||
|
||||
export function ProfileBootstrap() {
|
||||
const arcadia = useArcadiaClient()
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const tryBootstrap = async () => {
|
||||
const token =
|
||||
typeof window !== "undefined"
|
||||
? sessionStorage.getItem("arcadia_access_token")
|
||||
: null
|
||||
if (!token) return
|
||||
try {
|
||||
const p = await getProfile(arcadia)
|
||||
if (cancelled) return
|
||||
|
||||
const persistentUrl = pickAvatarUrl(p)
|
||||
const current = loadProfile()
|
||||
const cachedIsStaleBlob = current.avatarUrl?.startsWith("blob:") ?? false
|
||||
|
||||
if (persistentUrl) {
|
||||
if (current.avatarUrl !== persistentUrl) {
|
||||
saveProfile({ ...current, avatarUrl: persistentUrl })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// No persistent variant yet but the user has an avatar — fetch
|
||||
// the raw bytes as a blob URL. This also covers the "stale blob
|
||||
// URL from previous session" case: replace it with a fresh one.
|
||||
if (p.avatar_digital_object_id) {
|
||||
if (cachedIsStaleBlob) {
|
||||
// Clear the stale URL immediately so the appbar drops back
|
||||
// to initials while we refetch (better than a broken image).
|
||||
saveProfile({ ...current, avatarUrl: "" })
|
||||
}
|
||||
try {
|
||||
const baseUrl =
|
||||
(import.meta.env.VITE_ARCADIA_URL as string | undefined) ??
|
||||
"http://localhost:4000"
|
||||
const tenantId =
|
||||
(import.meta.env.VITE_ARCADIA_TENANT as string | undefined) ??
|
||||
"default"
|
||||
const blobUrl = await fetchDigitalObjectAsBlobUrl(
|
||||
baseUrl,
|
||||
p.avatar_digital_object_id,
|
||||
token,
|
||||
tenantId,
|
||||
)
|
||||
if (cancelled) return
|
||||
const fresh = loadProfile()
|
||||
saveProfile({ ...fresh, avatarUrl: blobUrl })
|
||||
} catch {
|
||||
// Best-effort; appbar will show initials until processing completes.
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// No avatar at all — clear any stale URL the cache might still hold.
|
||||
if (current.avatarUrl) {
|
||||
saveProfile({ ...current, avatarUrl: "" })
|
||||
}
|
||||
} catch {
|
||||
// 401 / network — silently skip; will retry on next session change.
|
||||
}
|
||||
}
|
||||
|
||||
void tryBootstrap()
|
||||
|
||||
const onSessionChange = () => void tryBootstrap()
|
||||
window.addEventListener("crema:session-change", onSessionChange)
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.removeEventListener("crema:session-change", onSessionChange)
|
||||
}
|
||||
}, [arcadia])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1,26 +1,16 @@
|
||||
// User profile — name, email, title, bio, signature, default agent.
|
||||
// Persisted in localStorage; reactive across tabs.
|
||||
// Local mirror of the resolved avatar URL, so the appbar can render the
|
||||
// avatar before the profile fetch resolves on next mount. The real
|
||||
// profile (name, email, bio, phone, location, timezone, avatar) is
|
||||
// server-backed — see ~/lib/arcadia/profiles.ts.
|
||||
|
||||
import { useEffect, useSyncExternalStore } from "react"
|
||||
|
||||
export type Profile = {
|
||||
name: string
|
||||
email: string
|
||||
title: string
|
||||
bio: string
|
||||
signature: string
|
||||
avatarUrl: string
|
||||
defaultAgentId: string
|
||||
}
|
||||
|
||||
export const DEFAULT_PROFILE: Profile = {
|
||||
name: "Signed-in user",
|
||||
email: "user@example.com",
|
||||
title: "",
|
||||
bio: "",
|
||||
signature: "",
|
||||
avatarUrl: "",
|
||||
defaultAgentId: "",
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "crema.profile"
|
||||
@@ -33,27 +23,10 @@ function readFromStorage(): Profile {
|
||||
if (!raw) return DEFAULT_PROFILE
|
||||
const parsed = JSON.parse(raw) as Partial<Profile>
|
||||
return {
|
||||
name:
|
||||
typeof parsed.name === "string" && parsed.name.trim().length > 0
|
||||
? parsed.name
|
||||
: DEFAULT_PROFILE.name,
|
||||
email:
|
||||
typeof parsed.email === "string" ? parsed.email : DEFAULT_PROFILE.email,
|
||||
title:
|
||||
typeof parsed.title === "string" ? parsed.title : DEFAULT_PROFILE.title,
|
||||
bio: typeof parsed.bio === "string" ? parsed.bio : DEFAULT_PROFILE.bio,
|
||||
signature:
|
||||
typeof parsed.signature === "string"
|
||||
? parsed.signature
|
||||
: DEFAULT_PROFILE.signature,
|
||||
avatarUrl:
|
||||
typeof parsed.avatarUrl === "string"
|
||||
? parsed.avatarUrl
|
||||
: DEFAULT_PROFILE.avatarUrl,
|
||||
defaultAgentId:
|
||||
typeof parsed.defaultAgentId === "string"
|
||||
? parsed.defaultAgentId
|
||||
: DEFAULT_PROFILE.defaultAgentId,
|
||||
}
|
||||
} catch {
|
||||
return DEFAULT_PROFILE
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { describe, expect, it, beforeEach } from "vitest"
|
||||
|
||||
import {
|
||||
createResource,
|
||||
deleteResource,
|
||||
listResources,
|
||||
updateResource,
|
||||
} from "./resources"
|
||||
|
||||
describe("resources", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it("creates, updates, and deletes", () => {
|
||||
expect(listResources()).toEqual([])
|
||||
const r = createResource({ name: "Test", owner: "Atlas" })
|
||||
expect(r.status).toBe("active")
|
||||
expect(listResources()).toHaveLength(1)
|
||||
|
||||
const updated = updateResource(r.id, { status: "paused" })
|
||||
expect(updated?.status).toBe("paused")
|
||||
expect(updated?.updatedAt).toBeGreaterThanOrEqual(r.updatedAt)
|
||||
|
||||
deleteResource(r.id)
|
||||
expect(listResources()).toEqual([])
|
||||
})
|
||||
|
||||
it("ignores updates for unknown ids", () => {
|
||||
expect(updateResource("missing", { name: "x" })).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,157 +0,0 @@
|
||||
// Resource store — example domain entity.
|
||||
// Backed by localStorage today, but written so each call is a single function
|
||||
// you can swap with `api.get/post/put/del` once you have a real backend.
|
||||
|
||||
import { useEffect, useSyncExternalStore } from "react"
|
||||
|
||||
export type Resource = {
|
||||
id: string
|
||||
name: string
|
||||
status: "active" | "paused" | "archived"
|
||||
owner: string
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "crema.resources"
|
||||
const CHANGE_EVENT = "crema:resources-change"
|
||||
|
||||
function newId() {
|
||||
return `r-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`
|
||||
}
|
||||
|
||||
function readFromStorage(): Resource[] {
|
||||
if (typeof window === "undefined") return []
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return []
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!Array.isArray(parsed)) return []
|
||||
return parsed.filter(
|
||||
(r): r is Resource =>
|
||||
r &&
|
||||
typeof r.id === "string" &&
|
||||
typeof r.name === "string" &&
|
||||
["active", "paused", "archived"].includes(r.status) &&
|
||||
typeof r.owner === "string" &&
|
||||
typeof r.createdAt === "number" &&
|
||||
typeof r.updatedAt === "number",
|
||||
)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function write(items: Resource[]) {
|
||||
if (typeof window === "undefined") return
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(items))
|
||||
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
|
||||
} catch {
|
||||
/* quota */
|
||||
}
|
||||
}
|
||||
|
||||
// CRUD — these mirror what `api.get/post/put/del` would look like.
|
||||
export function listResources(): Resource[] {
|
||||
return readFromStorage()
|
||||
}
|
||||
|
||||
export function createResource(input: {
|
||||
name: string
|
||||
owner: string
|
||||
status?: Resource["status"]
|
||||
}): Resource {
|
||||
const now = Date.now()
|
||||
const r: Resource = {
|
||||
id: newId(),
|
||||
name: input.name,
|
||||
owner: input.owner,
|
||||
status: input.status ?? "active",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
write([r, ...readFromStorage()])
|
||||
return r
|
||||
}
|
||||
|
||||
export function updateResource(
|
||||
id: string,
|
||||
patch: Partial<Omit<Resource, "id" | "createdAt">>,
|
||||
): Resource | null {
|
||||
const items = readFromStorage()
|
||||
let updated: Resource | null = null
|
||||
const next = items.map((r) => {
|
||||
if (r.id !== id) return r
|
||||
updated = { ...r, ...patch, updatedAt: Date.now() }
|
||||
return updated
|
||||
})
|
||||
if (updated) write(next)
|
||||
return updated
|
||||
}
|
||||
|
||||
export function deleteResource(id: string) {
|
||||
write(readFromStorage().filter((r) => r.id !== id))
|
||||
}
|
||||
|
||||
let cached: Resource[] | null = null
|
||||
function subscribe(cb: () => void) {
|
||||
const onChange = () => {
|
||||
cached = null
|
||||
cb()
|
||||
}
|
||||
window.addEventListener(CHANGE_EVENT, onChange)
|
||||
window.addEventListener("storage", (e) => {
|
||||
if (e.key === STORAGE_KEY) onChange()
|
||||
})
|
||||
return () => window.removeEventListener(CHANGE_EVENT, onChange)
|
||||
}
|
||||
function getSnapshot(): Resource[] {
|
||||
if (!cached) cached = readFromStorage()
|
||||
return cached
|
||||
}
|
||||
function getServerSnapshot(): Resource[] {
|
||||
return []
|
||||
}
|
||||
|
||||
export function useResources(): Resource[] {
|
||||
const v = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
|
||||
useEffect(() => {
|
||||
cached = null
|
||||
}, [])
|
||||
return v
|
||||
}
|
||||
|
||||
/** Seed a few rows on first load so the table isn't empty. */
|
||||
export function seedResourcesIfEmpty() {
|
||||
if (typeof window === "undefined") return
|
||||
if (localStorage.getItem(STORAGE_KEY)) return
|
||||
const now = Date.now()
|
||||
const seed: Resource[] = [
|
||||
{
|
||||
id: newId(),
|
||||
name: "Acme dashboard",
|
||||
status: "active",
|
||||
owner: "Atlas",
|
||||
createdAt: now - 86_400_000 * 3,
|
||||
updatedAt: now - 3600_000,
|
||||
},
|
||||
{
|
||||
id: newId(),
|
||||
name: "Onboarding pipeline",
|
||||
status: "paused",
|
||||
owner: "Forge",
|
||||
createdAt: now - 86_400_000 * 7,
|
||||
updatedAt: now - 86_400_000,
|
||||
},
|
||||
{
|
||||
id: newId(),
|
||||
name: "Q1 report draft",
|
||||
status: "archived",
|
||||
owner: "Inkwell",
|
||||
createdAt: now - 86_400_000 * 30,
|
||||
updatedAt: now - 86_400_000 * 14,
|
||||
},
|
||||
]
|
||||
write(seed)
|
||||
}
|
||||
113
app/lib/search-admin.ts
Normal file
113
app/lib/search-admin.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
// Client for the arcadia-search admin sidecar (`/admin/*` on the
|
||||
// search box, default :7801). Used by the Search route to manage
|
||||
// tenants, corpora, and trigger rebuilds.
|
||||
//
|
||||
// Auth: static bearer token from VITE_ARCADIA_SEARCH_ADMIN_TOKEN,
|
||||
// matched constant-time against ADMIN_TOKEN on the sidecar. The token
|
||||
// ships in the client bundle — fine for an internal admin tool on a
|
||||
// trusted network; in production, proxy through arcadia-core.
|
||||
|
||||
const BASE_URL =
|
||||
import.meta.env.VITE_ARCADIA_SEARCH_ADMIN_URL ?? "http://127.0.0.1:7801"
|
||||
const TOKEN = import.meta.env.VITE_ARCADIA_SEARCH_ADMIN_TOKEN ?? ""
|
||||
|
||||
export class SearchAdminError extends Error {
|
||||
status: number
|
||||
constructor(message: string, status: number) {
|
||||
super(message)
|
||||
this.name = "SearchAdminError"
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
async function call<T>(
|
||||
method: "GET" | "POST" | "PUT" | "DELETE",
|
||||
path: string,
|
||||
body?: unknown,
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = {}
|
||||
if (TOKEN) headers["Authorization"] = `Bearer ${TOKEN}`
|
||||
if (body !== undefined) headers["Content-Type"] = "application/json"
|
||||
const res = await fetch(`${BASE_URL}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "")
|
||||
throw new SearchAdminError(
|
||||
text || `${res.status} ${res.statusText}`,
|
||||
res.status,
|
||||
)
|
||||
}
|
||||
if (res.status === 204) return undefined as T
|
||||
return (await res.json()) as T
|
||||
}
|
||||
|
||||
export type TenantSummary = { id: string; corpus_count: number }
|
||||
export type CorpusSummary = {
|
||||
tenant: string
|
||||
corpus: string
|
||||
indexed: boolean
|
||||
num_docs: number | null
|
||||
live_path: string | null
|
||||
}
|
||||
export type CorpusDetail = {
|
||||
config: Record<string, unknown>
|
||||
status: CorpusSummary
|
||||
}
|
||||
export type RebuildResult = {
|
||||
tenant: string
|
||||
corpus: string
|
||||
chunk_count: number
|
||||
live_path: string
|
||||
built_at: string
|
||||
}
|
||||
|
||||
export const searchAdmin = {
|
||||
baseUrl: BASE_URL,
|
||||
hasToken: !!TOKEN,
|
||||
listTenants: () =>
|
||||
call<{ tenants: TenantSummary[] }>("GET", "/admin/tenants"),
|
||||
createTenant: (id: string) =>
|
||||
call<TenantSummary>("POST", "/admin/tenants", { id }),
|
||||
deleteTenant: (id: string) =>
|
||||
call<void>("DELETE", `/admin/tenants/${encodeURIComponent(id)}`),
|
||||
listCorpora: (tenant: string) =>
|
||||
call<{ corpora: CorpusSummary[] }>(
|
||||
"GET",
|
||||
`/admin/tenants/${encodeURIComponent(tenant)}/corpora`,
|
||||
),
|
||||
createCorpus: (tenant: string, body: Record<string, unknown>) =>
|
||||
call<CorpusSummary>(
|
||||
"POST",
|
||||
`/admin/tenants/${encodeURIComponent(tenant)}/corpora`,
|
||||
body,
|
||||
),
|
||||
getCorpus: (tenant: string, corpus: string) =>
|
||||
call<CorpusDetail>(
|
||||
"GET",
|
||||
`/admin/tenants/${encodeURIComponent(tenant)}/corpora/${encodeURIComponent(corpus)}`,
|
||||
),
|
||||
updateCorpus: (
|
||||
tenant: string,
|
||||
corpus: string,
|
||||
body: Record<string, unknown>,
|
||||
) =>
|
||||
call<CorpusSummary>(
|
||||
"PUT",
|
||||
`/admin/tenants/${encodeURIComponent(tenant)}/corpora/${encodeURIComponent(corpus)}`,
|
||||
body,
|
||||
),
|
||||
deleteCorpus: (tenant: string, corpus: string) =>
|
||||
call<void>(
|
||||
"DELETE",
|
||||
`/admin/tenants/${encodeURIComponent(tenant)}/corpora/${encodeURIComponent(corpus)}`,
|
||||
),
|
||||
rebuild: (tenant: string, corpus: string) =>
|
||||
call<RebuildResult>(
|
||||
"POST",
|
||||
`/admin/tenants/${encodeURIComponent(tenant)}/corpora/${encodeURIComponent(corpus)}/rebuild`,
|
||||
),
|
||||
restart: () => call<void>("POST", "/admin/restart"),
|
||||
}
|
||||
@@ -1,10 +1,17 @@
|
||||
import { describe, expect, it, beforeEach } from "vitest"
|
||||
|
||||
import { hasSession, loadSession, signIn, signOut } from "./session"
|
||||
import {
|
||||
hasSession,
|
||||
loadSession,
|
||||
persistFromArcadiaLogin,
|
||||
signOut,
|
||||
updateSessionUser,
|
||||
} from "./session"
|
||||
|
||||
describe("session", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
it("starts unauthenticated", () => {
|
||||
@@ -12,20 +19,31 @@ describe("session", () => {
|
||||
expect(hasSession()).toBe(false)
|
||||
})
|
||||
|
||||
it("rejects empty credentials", async () => {
|
||||
await expect(signIn("", "")).rejects.toThrow(/required/i)
|
||||
await expect(signIn("not-an-email", "pw")).rejects.toThrow(/valid email/i)
|
||||
expect(hasSession()).toBe(false)
|
||||
})
|
||||
|
||||
it("creates a session on sign-in and clears on sign-out", async () => {
|
||||
const session = await signIn("alice@example.com", "hunter2")
|
||||
it("persists from an arcadia login and clears on sign-out", () => {
|
||||
const session = persistFromArcadiaLogin(
|
||||
{ access_token: "tok-123", refresh_token: "ref-456" },
|
||||
{ id: "u1", email: "alice@example.com", full_name: "Alice" },
|
||||
)
|
||||
expect(session.email).toBe("alice@example.com")
|
||||
expect(session.token).toMatch(/^dev-/)
|
||||
expect(session.name).toBe("Alice")
|
||||
expect(session.token).toBe("tok-123")
|
||||
expect(hasSession()).toBe(true)
|
||||
expect(sessionStorage.getItem("arcadia_access_token")).toBe("tok-123")
|
||||
|
||||
signOut()
|
||||
expect(loadSession()).toBeNull()
|
||||
expect(hasSession()).toBe(false)
|
||||
expect(sessionStorage.getItem("arcadia_access_token")).toBeNull()
|
||||
})
|
||||
|
||||
it("updates the stored session identity in place", () => {
|
||||
persistFromArcadiaLogin(
|
||||
{ access_token: "tok" },
|
||||
{ id: "u1", email: "a@x.com", full_name: "Alice" },
|
||||
)
|
||||
updateSessionUser({ name: "Alice Smith", email: "alice@x.com" })
|
||||
const s = loadSession()
|
||||
expect(s?.name).toBe("Alice Smith")
|
||||
expect(s?.email).toBe("alice@x.com")
|
||||
expect(s?.token).toBe("tok")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
// Session — minimal auth scaffold backed by localStorage.
|
||||
// Swap loadSession/signIn/signOut for real calls (cookies + server) when you
|
||||
// wire a backend. The shape here matches what AppShell + useUser expect.
|
||||
// Sign-in is owned by `persistFromArcadiaLogin`, which is called by the auth
|
||||
// routes after a successful arcadia API exchange. The shape here matches what
|
||||
// AppShell + useUser expect.
|
||||
|
||||
import { useEffect, useSyncExternalStore } from "react"
|
||||
|
||||
import { profileInitials } from "~/lib/profile"
|
||||
import { decodeJwt, type AvailableTenantClaim } from "~/lib/jwt"
|
||||
|
||||
export type AvailableTenant = {
|
||||
id: string
|
||||
slug?: string
|
||||
name?: string
|
||||
roles: string[]
|
||||
}
|
||||
|
||||
export type Session = {
|
||||
userId: string
|
||||
@@ -13,6 +22,11 @@ export type Session = {
|
||||
token: string
|
||||
// Issued at, ms since epoch.
|
||||
issuedAt: number
|
||||
// Active membership context — derived from the JWT.
|
||||
tenantId?: string
|
||||
tenantSlug?: string
|
||||
roles: string[]
|
||||
availableTenants: AvailableTenant[]
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "crema.session"
|
||||
@@ -40,6 +54,18 @@ function readFromStorage(): Session | null {
|
||||
token: parsed.token,
|
||||
issuedAt:
|
||||
typeof parsed.issuedAt === "number" ? parsed.issuedAt : Date.now(),
|
||||
tenantId: typeof parsed.tenantId === "string" ? parsed.tenantId : undefined,
|
||||
tenantSlug:
|
||||
typeof parsed.tenantSlug === "string" ? parsed.tenantSlug : undefined,
|
||||
roles: Array.isArray(parsed.roles)
|
||||
? parsed.roles.filter((r): r is string => typeof r === "string")
|
||||
: [],
|
||||
availableTenants: Array.isArray(parsed.availableTenants)
|
||||
? (parsed.availableTenants.filter(
|
||||
(t): t is AvailableTenant =>
|
||||
!!t && typeof (t as AvailableTenant).id === "string",
|
||||
) as AvailableTenant[])
|
||||
: [],
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
@@ -50,35 +76,6 @@ export function loadSession(): Session | null {
|
||||
return readFromStorage()
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock sign-in. Validates only that email + password are non-empty; returns
|
||||
* a fake session. Replace with a real fetch to your auth endpoint.
|
||||
*/
|
||||
export async function signIn(
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<Session> {
|
||||
await new Promise((r) => setTimeout(r, 250))
|
||||
if (!email.trim() || !password.trim()) {
|
||||
throw new Error("Email and password are required.")
|
||||
}
|
||||
if (!email.includes("@")) {
|
||||
throw new Error("Enter a valid email address.")
|
||||
}
|
||||
const session: Session = {
|
||||
userId: `u-${Date.now().toString(36)}`,
|
||||
name: email.split("@")[0].replace(/\W/g, " ").trim() || email,
|
||||
email,
|
||||
token: `dev-${Math.random().toString(36).slice(2, 14)}`,
|
||||
issuedAt: Date.now(),
|
||||
}
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(session))
|
||||
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
export function signOut() {
|
||||
if (typeof window === "undefined") return
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
@@ -100,12 +97,31 @@ export function persistFromArcadiaLogin(
|
||||
[user?.first_name, user?.last_name].filter(Boolean).join(" ") ||
|
||||
user?.email ||
|
||||
"Signed-in user"
|
||||
const claims = decodeJwt(tokens.access_token) ?? {}
|
||||
const availableTenants: AvailableTenant[] = Array.isArray(
|
||||
claims.available_tenants,
|
||||
)
|
||||
? (claims.available_tenants as AvailableTenantClaim[])
|
||||
.filter((t) => t && typeof t.id === "string")
|
||||
.map((t) => ({
|
||||
id: t.id as string,
|
||||
slug: t.slug,
|
||||
name: t.name,
|
||||
roles: Array.isArray(t.roles) ? t.roles : [],
|
||||
}))
|
||||
: []
|
||||
const session: Session = {
|
||||
userId: user?.id ?? `arcadia-${Date.now().toString(36)}`,
|
||||
name,
|
||||
email: user?.email ?? "",
|
||||
token: tokens.access_token,
|
||||
issuedAt: Date.now(),
|
||||
tenantId:
|
||||
typeof claims.tenant_id === "string" ? claims.tenant_id : undefined,
|
||||
tenantSlug:
|
||||
typeof claims.tenant_slug === "string" ? claims.tenant_slug : undefined,
|
||||
roles: Array.isArray(claims.roles) ? claims.roles : [],
|
||||
availableTenants,
|
||||
}
|
||||
if (typeof window !== "undefined") {
|
||||
sessionStorage.setItem("arcadia_access_token", tokens.access_token)
|
||||
@@ -116,6 +132,26 @@ export function persistFromArcadiaLogin(
|
||||
return session
|
||||
}
|
||||
|
||||
/** Patch the stored session's identity fields without changing the token.
|
||||
* Use after the operator edits their profile so the appbar avatar and
|
||||
* protected-shell greeting reflect the new name/email immediately. */
|
||||
export function updateSessionUser(patch: {
|
||||
name?: string
|
||||
email?: string
|
||||
}): Session | null {
|
||||
if (typeof window === "undefined") return null
|
||||
const current = readFromStorage()
|
||||
if (!current) return null
|
||||
const next: Session = {
|
||||
...current,
|
||||
name: patch.name?.trim() ? patch.name : current.name,
|
||||
email: patch.email?.trim() ? patch.email : current.email,
|
||||
}
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
|
||||
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
|
||||
return next
|
||||
}
|
||||
|
||||
/** True if a non-expired session is in storage. */
|
||||
export function hasSession(): boolean {
|
||||
return !!readFromStorage()
|
||||
|
||||
@@ -10,9 +10,11 @@ import {
|
||||
import type { Route } from "./+types/root"
|
||||
import "./app.css"
|
||||
|
||||
import { ToastProvider } from "@crema/notification-ui"
|
||||
import { ToastProvider, Toaster } from "@crema/notification-ui"
|
||||
import { CommandBusProvider } from "@crema/action-bus"
|
||||
import { ArcadiaProvider } from "@crema/arcadia-client"
|
||||
import { LlmConfigBootstrap } from "~/lib/llm-config-bootstrap"
|
||||
import { ProfileBootstrap } from "~/lib/profile-bootstrap"
|
||||
// CREMA:PROVIDERS-IMPORTS
|
||||
|
||||
const ARCADIA_URL = import.meta.env.VITE_ARCADIA_URL ?? "http://localhost:4000"
|
||||
@@ -28,7 +30,7 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
||||
<Links />
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `(function(){try{var t=localStorage.getItem('crema-theme');if(!t)t=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';if(t==='dark')document.documentElement.classList.add('dark');var f=localStorage.getItem('crema-font-scale');if(f&&/^(sm|md|lg|xl)$/.test(f))document.documentElement.dataset.fontScale=f;var b=localStorage.getItem('crema-bg');if(b&&/^(drift|static)$/.test(b)){document.addEventListener('DOMContentLoaded',function(){document.body.dataset.bg=b;});}var s=localStorage.getItem('crema-surface');if(s&&/^(snow|stone|sage|slate)$/.test(s)){document.addEventListener('DOMContentLoaded',function(){document.body.dataset.surface=s;});}}catch(e){}})();`,
|
||||
__html: `(function(){try{var t=localStorage.getItem('crema-theme');if(!t)t='dark';if(t==='dark')document.documentElement.classList.add('dark');var f=localStorage.getItem('crema-font-scale');if(!f||!/^(sm|md|lg|xl)$/.test(f))f='sm';document.documentElement.dataset.fontScale=f;var b=localStorage.getItem('crema-bg');if(b&&/^(drift|static)$/.test(b)){document.addEventListener('DOMContentLoaded',function(){document.body.dataset.bg=b;});}var s=localStorage.getItem('crema-surface');if(s&&/^(snow|stone|sage|slate)$/.test(s)){document.addEventListener('DOMContentLoaded',function(){document.body.dataset.surface=s;});}}catch(e){}})();`,
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
@@ -61,7 +63,10 @@ export default function App() {
|
||||
}}
|
||||
>
|
||||
<CommandBusProvider>
|
||||
<LlmConfigBootstrap />
|
||||
<ProfileBootstrap />
|
||||
<Outlet />
|
||||
<Toaster />
|
||||
</CommandBusProvider>
|
||||
</ArcadiaProvider>
|
||||
</ToastProvider>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { type RouteConfig, index, route } from "@react-router/dev/routes"
|
||||
|
||||
export default [
|
||||
index("routes/home.tsx"),
|
||||
route("resources", "routes/resources.tsx"),
|
||||
route("activity", "routes/activity.tsx"),
|
||||
route("assistant", "routes/assistant.tsx"),
|
||||
route("ai", "routes/ai.tsx"),
|
||||
@@ -10,6 +9,28 @@ export default [
|
||||
route("settings", "routes/settings.tsx"),
|
||||
route("profile", "routes/profile.tsx"),
|
||||
route("login", "routes/login.tsx"),
|
||||
route("login/forgot", "routes/login.forgot.tsx"),
|
||||
route("login/reset", "routes/login.reset.tsx"),
|
||||
route("login/2fa", "routes/login.2fa.tsx"),
|
||||
route("signup", "routes/signup.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"),
|
||||
route("buckets", "routes/buckets.tsx"),
|
||||
route("monitoring", "routes/monitoring.tsx"),
|
||||
route("memberships", "routes/memberships.tsx"),
|
||||
route("organizations", "routes/organizations.tsx"),
|
||||
route("networking", "routes/networking.tsx"),
|
||||
route("sso", "routes/sso.tsx"),
|
||||
route("announcements", "routes/announcements.tsx"),
|
||||
route("status-page", "routes/status-page.tsx"),
|
||||
route("search", "routes/search.tsx"),
|
||||
route("apps", "routes/apps.tsx"),
|
||||
route("plan", "routes/plan.tsx"),
|
||||
route("entitlements", "routes/entitlements.tsx"),
|
||||
route("integrations", "routes/integrations.tsx"),
|
||||
// CREMA:ROUTES
|
||||
] satisfies RouteConfig
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
import { Activity } from "lucide-react"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
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 +24,385 @@ 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 { useRegisterContext } from "@crema/aifirst-ui/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<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],
|
||||
)
|
||||
useRegisterContext("audit_log", summary)
|
||||
|
||||
const table = useTable<AuditLog>({
|
||||
data: logs,
|
||||
columns,
|
||||
getRowId: (l) => l.id,
|
||||
initialPageSize: 50,
|
||||
initialSearch: search,
|
||||
})
|
||||
useEffect(() => {
|
||||
table.setSearch(search)
|
||||
}, [search, table])
|
||||
|
||||
return (
|
||||
<AppShell title="Activity">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Activity</CardTitle>
|
||||
<CardDescription>
|
||||
Event stream, audit log, recent changes.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<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">
|
||||
<div className="flex size-12 items-center justify-center rounded-xl bg-background text-muted-foreground">
|
||||
<Activity className="size-6" />
|
||||
</div>
|
||||
<div className="max-w-md">
|
||||
<p className="font-medium">No activity yet</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Once your app is doing things, this is where audit events,
|
||||
webhook deliveries, and recent changes show up — pair with{" "}
|
||||
<code className="font-mono text-xs">@crema/log-ui</code>.
|
||||
<AppShell>
|
||||
<div className="flex flex-col gap-4">
|
||||
<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
|
||||
}, {})
|
||||
}
|
||||
|
||||
1156
app/routes/ai.tsx
1156
app/routes/ai.tsx
File diff suppressed because it is too large
Load Diff
823
app/routes/announcements.tsx
Normal file
823
app/routes/announcements.tsx
Normal file
@@ -0,0 +1,823 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import {
|
||||
CheckCircle2,
|
||||
Megaphone,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
} 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 {
|
||||
createAnnouncement,
|
||||
deleteAnnouncement,
|
||||
listAnnouncements,
|
||||
updateAnnouncement,
|
||||
type Announcement,
|
||||
type AnnouncementInput,
|
||||
type AnnouncementType,
|
||||
} from "~/lib/arcadia/announcements"
|
||||
import { listTenants, type Tenant } from "~/lib/arcadia/tenants"
|
||||
import { pageTitle } from "~/lib/page-meta"
|
||||
import { useSession } from "~/lib/session"
|
||||
import { useRegisterContext } from "@crema/aifirst-ui/context"
|
||||
|
||||
export const meta = () => pageTitle("Announcements")
|
||||
|
||||
const TYPES: AnnouncementType[] = ["info", "warning", "maintenance", "incident", "feature"]
|
||||
|
||||
const KIND_OPTIONS: { value: AnnouncementType; hint: string }[] = [
|
||||
{ value: "info", hint: "Neutral update" },
|
||||
{ value: "warning", hint: "Degraded service or heads-up" },
|
||||
{ value: "maintenance", hint: "Scheduled work" },
|
||||
{ value: "incident", hint: "Active outage" },
|
||||
{ value: "feature", hint: "Something new shipped" },
|
||||
]
|
||||
|
||||
function typeToAlertVariant(
|
||||
t: AnnouncementType,
|
||||
): "info" | "success" | "warning" | "error" | "neutral" {
|
||||
if (t === "incident") return "error"
|
||||
if (t === "warning" || t === "maintenance") return "warning"
|
||||
if (t === "feature") return "success"
|
||||
return "info"
|
||||
}
|
||||
|
||||
function publishButtonLabel(opts: {
|
||||
isEdit: boolean
|
||||
active: boolean
|
||||
audience: "platform" | "tenant"
|
||||
tenantId: string
|
||||
tenants: Tenant[]
|
||||
}): string {
|
||||
if (opts.isEdit) return "Save changes"
|
||||
if (!opts.active) return "Save draft"
|
||||
if (opts.audience === "tenant") {
|
||||
const name = opts.tenants.find((t) => t.id === opts.tenantId)?.name
|
||||
return name ? `Publish to ${name}` : "Publish to tenant"
|
||||
}
|
||||
return "Publish to all users"
|
||||
}
|
||||
|
||||
type Editor =
|
||||
| { kind: "create" }
|
||||
| { kind: "edit"; announcement: Announcement }
|
||||
| null
|
||||
|
||||
export default function AnnouncementsRoute() {
|
||||
const session = useSession()
|
||||
const arcadia = useArcadiaClient()
|
||||
|
||||
const [items, setItems] = useState<Announcement[]>([])
|
||||
const [tenants, setTenants] = useState<Tenant[]>([])
|
||||
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<Editor>(null)
|
||||
const [pendingDelete, setPendingDelete] = useState<Announcement | null>(null)
|
||||
const [refreshedAt, setRefreshedAt] = useState<number | null>(null)
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
try {
|
||||
const [a, t] = await Promise.all([
|
||||
listAnnouncements(arcadia),
|
||||
listTenants(arcadia).catch(() => [] as Tenant[]),
|
||||
])
|
||||
setItems(a)
|
||||
setTenants(t)
|
||||
setRefreshedAt(Date.now())
|
||||
} catch (err) {
|
||||
setError(err instanceof ArcadiaError ? err.message : "Failed to load announcements.")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [arcadia])
|
||||
|
||||
useEffect(() => {
|
||||
if (refreshedAt == null) return
|
||||
const id = window.setInterval(() => setNow(Date.now()), 30_000)
|
||||
return () => window.clearInterval(id)
|
||||
}, [refreshedAt])
|
||||
|
||||
const lastRefreshedLabel = useMemo(() => {
|
||||
if (refreshedAt == null) return null
|
||||
const seconds = Math.max(1, Math.round((now - refreshedAt) / 1000))
|
||||
if (seconds < 60) return `${seconds}s ago`
|
||||
const minutes = Math.round(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
return `${Math.round(minutes / 60)}h ago`
|
||||
}, [refreshedAt, now])
|
||||
|
||||
useEffect(() => {
|
||||
if (session) refresh()
|
||||
}, [session, refresh])
|
||||
|
||||
const columns = useMemo<Column<Announcement>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "title",
|
||||
header: "Title",
|
||||
accessor: "title",
|
||||
sortable: true,
|
||||
cell: (a) => (
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{a.title}</span>
|
||||
{a.body ? (
|
||||
<span className="line-clamp-1 text-xs text-muted-foreground">{a.body}</span>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "type",
|
||||
header: "Type",
|
||||
accessor: "announcement_type",
|
||||
sortable: true,
|
||||
cell: (a) => <BadgeCell label={a.announcement_type} tone={typeTone(a.announcement_type)} />,
|
||||
},
|
||||
{
|
||||
id: "scope",
|
||||
header: "Audience",
|
||||
cell: (a) => {
|
||||
if (!a.tenant_id) return <Badge>All apps</Badge>
|
||||
const t = tenants.find((x) => x.id === a.tenant_id)
|
||||
return <Badge variant="secondary">{t?.slug ?? "Single tenant"}</Badge>
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "active",
|
||||
header: "Active",
|
||||
accessor: "active",
|
||||
sortable: true,
|
||||
cell: (a) => (
|
||||
<BadgeCell label={a.active ? "live" : "off"} tone={a.active ? "success" : "default"} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "window",
|
||||
header: "Window",
|
||||
cell: (a) => (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{a.starts_at ? new Date(a.starts_at).toLocaleDateString() : "—"}
|
||||
{" → "}
|
||||
{a.ends_at ? new Date(a.ends_at).toLocaleDateString() : "∞"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "updated",
|
||||
header: "Updated",
|
||||
accessor: "updated_at",
|
||||
sortable: true,
|
||||
cell: (a) => <DateCell value={a.updated_at} format="short" />,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
align: "right",
|
||||
cell: (a) => {
|
||||
const items: ActionItem[] = [
|
||||
{
|
||||
id: "edit",
|
||||
label: "Edit",
|
||||
dataAction: `announcement-${a.id}-edit`,
|
||||
onSelect: () => setEditor({ kind: "edit", announcement: a }),
|
||||
},
|
||||
{
|
||||
id: "toggle",
|
||||
label: a.active ? "Deactivate" : "Activate",
|
||||
dataAction: `announcement-${a.id}-toggle`,
|
||||
onSelect: async () => {
|
||||
try {
|
||||
await updateAnnouncement(arcadia, a.id, { active: !a.active })
|
||||
setInfo(a.active ? "Announcement deactivated." : "Announcement activated.")
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
setError(err instanceof ArcadiaError ? err.message : "Toggle failed.")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "delete",
|
||||
label: "Delete",
|
||||
icon: <Trash2 className="size-4" />,
|
||||
destructive: true,
|
||||
dataAction: `announcement-${a.id}-delete`,
|
||||
onSelect: () => setPendingDelete(a),
|
||||
},
|
||||
]
|
||||
return <ActionsCell items={items} triggerDataAction={`announcement-${a.id}-actions`} />
|
||||
},
|
||||
},
|
||||
],
|
||||
[arcadia, refresh, tenants],
|
||||
)
|
||||
|
||||
const summary = useMemo(
|
||||
() => ({
|
||||
total: items.length,
|
||||
active: items.filter((a) => a.active).length,
|
||||
byType: countBy(items, (a) => a.announcement_type),
|
||||
}),
|
||||
[items],
|
||||
)
|
||||
useRegisterContext("announcements", summary)
|
||||
|
||||
const table = useTable<Announcement>({
|
||||
data: items,
|
||||
columns,
|
||||
getRowId: (a) => a.id,
|
||||
initialPageSize: 25,
|
||||
initialSearch: search,
|
||||
})
|
||||
useEffect(() => {
|
||||
table.setSearch(search)
|
||||
}, [search, table])
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="flex flex-col gap-4">
|
||||
<header className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-[26px] font-[620] leading-[1.1] tracking-[-0.02em]">
|
||||
Announcements
|
||||
</h1>
|
||||
<p className="mt-1.5 max-w-[56ch] text-[13.5px] leading-[1.5] text-muted-foreground">
|
||||
Banners that appear at the top of every Sky AI app. Use them for maintenance
|
||||
windows, incidents, or new features.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
{lastRefreshedLabel ? (
|
||||
<span
|
||||
className="text-xs tabular-nums text-muted-foreground"
|
||||
aria-live="polite"
|
||||
title={`Last refreshed ${lastRefreshedLabel}`}
|
||||
>
|
||||
<span className="hidden sm:inline">Updated </span>
|
||||
{lastRefreshedLabel}
|
||||
</span>
|
||||
) : null}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={refresh}
|
||||
disabled={loading}
|
||||
aria-label="Refresh announcements"
|
||||
data-action="announcements-refresh"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<RefreshCw className={`size-4 ${loading ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
{items.length > 0 ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setEditor({ kind: "create" })}
|
||||
data-action="announcements-create"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
New announcement
|
||||
</Button>
|
||||
) : null}
|
||||
</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 title, body, or type"
|
||||
data-action="announcements-search"
|
||||
className="max-w-sm flex-1"
|
||||
/>
|
||||
{items.length > 0 ? (
|
||||
<div className="ml-auto text-xs tabular-nums text-muted-foreground">
|
||||
{search && table.total !== items.length
|
||||
? `${table.total} of ${items.length}`
|
||||
: `${items.length} ${items.length === 1 ? "announcement" : "announcements"}`}
|
||||
</div>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="relative p-0">
|
||||
<LoadingOverlay
|
||||
active={loading && items.length === 0}
|
||||
label="Loading announcements…"
|
||||
/>
|
||||
{table.total === 0 && !loading ? (
|
||||
<EmptyState
|
||||
icon={
|
||||
<div
|
||||
className="grid size-14 place-items-center rounded-full"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(circle at center, color-mix(in oklch, var(--primary) 22%, transparent), transparent 70%)",
|
||||
}}
|
||||
>
|
||||
<Megaphone
|
||||
className="size-6"
|
||||
style={{ color: "var(--primary)" }}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
title={search ? "No announcements match." : "No announcements yet."}
|
||||
description={
|
||||
search
|
||||
? "Try a different search."
|
||||
: "Post your first banner. Show it to everyone, or scope it to a single tenant."
|
||||
}
|
||||
action={
|
||||
search ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setSearch("")}
|
||||
data-action="announcements-clear-search"
|
||||
>
|
||||
Clear search
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setEditor({ kind: "create" })}
|
||||
data-action="announcements-create-empty"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
New announcement
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={table.pageRows}
|
||||
getRowId={(a) => a.id}
|
||||
sort={table.sort}
|
||||
onSortToggle={table.toggleSort}
|
||||
loading={loading && items.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 announcement?"
|
||||
description={pendingDelete ? `${pendingDelete.title} will be removed for all users.` : ""}
|
||||
confirmLabel="Delete"
|
||||
variant="danger"
|
||||
onConfirm={async () => {
|
||||
if (!pendingDelete) return
|
||||
try {
|
||||
await deleteAnnouncement(arcadia, pendingDelete.id)
|
||||
setPendingDelete(null)
|
||||
setInfo("Announcement deleted.")
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
setError(err instanceof ArcadiaError ? err.message : "Delete failed.")
|
||||
setPendingDelete(null)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<AnnouncementEditorDialog
|
||||
state={editor}
|
||||
tenants={tenants}
|
||||
onClose={() => setEditor(null)}
|
||||
onSaved={async (msg) => {
|
||||
setEditor(null)
|
||||
if (msg) setInfo(msg)
|
||||
await refresh()
|
||||
}}
|
||||
onError={setError}
|
||||
/>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
function typeTone(t: AnnouncementType): BadgeTone {
|
||||
if (t === "incident") return "danger"
|
||||
if (t === "warning" || t === "maintenance") return "warning"
|
||||
if (t === "feature") return "success"
|
||||
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
|
||||
}, {})
|
||||
}
|
||||
|
||||
function AnnouncementEditorDialog({
|
||||
state,
|
||||
tenants,
|
||||
onClose,
|
||||
onSaved,
|
||||
onError,
|
||||
}: {
|
||||
state: Editor
|
||||
tenants: Tenant[]
|
||||
onClose: () => void
|
||||
onSaved: (msg?: string) => Promise<void>
|
||||
onError: (msg: string | null) => void
|
||||
}) {
|
||||
const arcadia = useArcadiaClient()
|
||||
const open = state !== null
|
||||
const isEdit = state?.kind === "edit"
|
||||
const initial = isEdit ? state.announcement : null
|
||||
|
||||
const [title, setTitle] = useState("")
|
||||
const [body, setBody] = useState("")
|
||||
const [type, setType] = useState<AnnouncementType>("info")
|
||||
const [audience, setAudience] = useState<"platform" | "tenant">("platform")
|
||||
const [tenantId, setTenantId] = useState<string>("")
|
||||
const [actionLabel, setActionLabel] = useState("")
|
||||
const [actionUrl, setActionUrl] = useState("")
|
||||
const [startsAt, setStartsAt] = useState("")
|
||||
const [endsAt, setEndsAt] = useState("")
|
||||
const [dismissible, setDismissible] = useState(true)
|
||||
const [active, setActive] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [localError, setLocalError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setLocalError(null)
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
if (initial) {
|
||||
setTitle(initial.title)
|
||||
setBody(initial.body ?? "")
|
||||
setType(initial.announcement_type)
|
||||
setAudience(initial.tenant_id ? "tenant" : "platform")
|
||||
setTenantId(initial.tenant_id ?? "")
|
||||
setActionLabel(initial.action_label ?? "")
|
||||
setActionUrl(initial.action_url ?? "")
|
||||
setStartsAt(initial.starts_at ? initial.starts_at.slice(0, 16) : "")
|
||||
setEndsAt(initial.ends_at ? initial.ends_at.slice(0, 16) : "")
|
||||
setDismissible(initial.dismissible)
|
||||
setActive(initial.active)
|
||||
} else {
|
||||
setTitle("")
|
||||
setBody("")
|
||||
setType("info")
|
||||
setAudience("platform")
|
||||
setTenantId("")
|
||||
setActionLabel("")
|
||||
setActionUrl("")
|
||||
setStartsAt("")
|
||||
setEndsAt("")
|
||||
setDismissible(true)
|
||||
setActive(true)
|
||||
}
|
||||
}, [open, initial])
|
||||
|
||||
const submit = async () => {
|
||||
onError(null)
|
||||
setLocalError(null)
|
||||
setSaving(true)
|
||||
try {
|
||||
const input: AnnouncementInput = {
|
||||
title,
|
||||
body: body || undefined,
|
||||
announcement_type: type,
|
||||
audience,
|
||||
action_label: actionLabel || null,
|
||||
action_url: actionUrl || null,
|
||||
starts_at: startsAt ? new Date(startsAt).toISOString() : null,
|
||||
ends_at: endsAt ? new Date(endsAt).toISOString() : null,
|
||||
dismissible,
|
||||
active,
|
||||
tenant_id: audience === "tenant" ? tenantId || null : null,
|
||||
}
|
||||
if (isEdit && initial) {
|
||||
await updateAnnouncement(arcadia, initial.id, input)
|
||||
await onSaved("Announcement updated.")
|
||||
} else {
|
||||
await createAnnouncement(arcadia, input)
|
||||
await onSaved("Announcement posted.")
|
||||
}
|
||||
} catch (err) {
|
||||
const msg =
|
||||
err instanceof ArcadiaError
|
||||
? err.message
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: "Save failed."
|
||||
setLocalError(msg)
|
||||
} 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 announcement" : "New announcement"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
A banner shows at the top of every Sky AI app. It's visible when it's switched on
|
||||
and today falls inside its date range.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Live preview — what users will see. Updates as the form is edited so
|
||||
the operator never has to imagine the output or publish blind. */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
Preview
|
||||
</Label>
|
||||
<div className="rounded-md border bg-muted/30 p-3">
|
||||
<AlertBanner
|
||||
variant={typeToAlertVariant(type)}
|
||||
title={title || "Your banner title appears here"}
|
||||
dismissible={dismissible}
|
||||
onDismiss={() => {}}
|
||||
action={
|
||||
actionLabel && actionUrl ? (
|
||||
<Button size="xs" variant="outline" type="button" tabIndex={-1}>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{body || (
|
||||
<span className="italic opacity-60">Body text appears here.</span>
|
||||
)}
|
||||
</AlertBanner>
|
||||
<p className="mt-2 text-[11px] text-muted-foreground">
|
||||
{audience === "tenant"
|
||||
? `Visible to users of ${
|
||||
tenants.find((t) => t.id === tenantId)?.name ?? "the selected tenant"
|
||||
} only.`
|
||||
: "Visible to everyone across every Sky AI app."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{localError ? (
|
||||
<AlertBanner
|
||||
variant="error"
|
||||
dismissible
|
||||
onDismiss={() => setLocalError(null)}
|
||||
>
|
||||
{localError}
|
||||
</AlertBanner>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="col-span-2 flex flex-col gap-1.5">
|
||||
<Label htmlFor="ann-title">Title</Label>
|
||||
<Input
|
||||
id="ann-title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
data-action="announcement-form-title"
|
||||
placeholder="Scheduled maintenance Sunday 2am AEST"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 flex flex-col gap-1.5">
|
||||
<Label htmlFor="ann-body">Body</Label>
|
||||
<Textarea
|
||||
id="ann-body"
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
rows={3}
|
||||
data-action="announcement-form-body"
|
||||
placeholder="Expect ~10 minutes of downtime while we ship the new tenant switcher."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Kind</Label>
|
||||
<Select value={type} onValueChange={setType}>
|
||||
<SelectTrigger data-action="announcement-form-type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{KIND_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium capitalize">{opt.value}</span>
|
||||
<span className="text-xs text-muted-foreground">{opt.hint}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Who sees this</Label>
|
||||
<Select value={audience} onValueChange={(v) => setAudience(v as "platform" | "tenant")}>
|
||||
<SelectTrigger data-action="announcement-form-audience">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="platform">Everyone</SelectItem>
|
||||
<SelectItem value="tenant">Just one tenant</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{audience === "tenant" ? (
|
||||
<div className="col-span-2 flex flex-col gap-1.5">
|
||||
<Label>Which tenant</Label>
|
||||
<Select value={tenantId} onValueChange={setTenantId}>
|
||||
<SelectTrigger data-action="announcement-form-tenant">
|
||||
<SelectValue placeholder="Pick a tenant" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tenants.map((t) => (
|
||||
<SelectItem key={t.id} value={t.id}>
|
||||
{t.name} ({t.slug})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="ann-starts">Starts</Label>
|
||||
<Input
|
||||
id="ann-starts"
|
||||
type="datetime-local"
|
||||
value={startsAt}
|
||||
onChange={(e) => setStartsAt(e.target.value)}
|
||||
data-action="announcement-form-starts"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="ann-ends">Ends</Label>
|
||||
<Input
|
||||
id="ann-ends"
|
||||
type="datetime-local"
|
||||
value={endsAt}
|
||||
onChange={(e) => setEndsAt(e.target.value)}
|
||||
data-action="announcement-form-ends"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Optional link group — heading clarifies these two are paired. */}
|
||||
<div className="col-span-2 flex flex-col gap-2 rounded-md border border-dashed p-3">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<Label className="text-sm">Add a link</Label>
|
||||
<span className="text-xs text-muted-foreground">Optional</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="ann-action-label" className="text-xs text-muted-foreground">
|
||||
Button text
|
||||
</Label>
|
||||
<Input
|
||||
id="ann-action-label"
|
||||
value={actionLabel}
|
||||
onChange={(e) => setActionLabel(e.target.value)}
|
||||
placeholder="Read more"
|
||||
data-action="announcement-form-action-label"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="ann-action-url" className="text-xs text-muted-foreground">
|
||||
Where it goes
|
||||
</Label>
|
||||
<Input
|
||||
id="ann-action-url"
|
||||
value={actionUrl}
|
||||
onChange={(e) => setActionUrl(e.target.value)}
|
||||
placeholder="/changelog/v2"
|
||||
data-action="announcement-form-action-url"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* End-user behavior toggle, not publish state — kept with content fields. */}
|
||||
<div className="col-span-2 flex items-center justify-between rounded-md border px-3 py-2">
|
||||
<div className="flex flex-col">
|
||||
<Label className="text-sm">Let users dismiss</Label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Adds an × users can click to hide the banner.
|
||||
</span>
|
||||
</div>
|
||||
<Switch
|
||||
checked={dismissible}
|
||||
onCheckedChange={setDismissible}
|
||||
data-action="announcement-form-dismissible"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-col items-stretch gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
{/* Active = publish state, paired with the publish button. */}
|
||||
<label
|
||||
htmlFor="ann-active"
|
||||
className="flex items-center gap-2 text-xs text-muted-foreground sm:mr-auto"
|
||||
>
|
||||
<Switch
|
||||
id="ann-active"
|
||||
checked={active}
|
||||
onCheckedChange={setActive}
|
||||
data-action="announcement-form-active"
|
||||
/>
|
||||
<span>{active ? "Switched on" : "Switched off (draft)"}</span>
|
||||
</label>
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button variant="outline" onClick={onClose} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={submit}
|
||||
disabled={saving || !title.trim() || (audience === "tenant" && !tenantId)}
|
||||
data-action="announcement-form-save"
|
||||
>
|
||||
{saving ? (
|
||||
<RefreshCw className="size-4 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="size-4" />
|
||||
)}
|
||||
{publishButtonLabel({ isEdit, active, audience, tenantId, tenants })}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
44
app/routes/apps.tsx
Normal file
44
app/routes/apps.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
// Tenant-scoped "Apps" — placeholder. Real surface is the apps this
|
||||
// tenant publishes (and their per-app users/grants on the personal
|
||||
// cloud side). Wired into the nav so tenant admins see the route they
|
||||
// expect; data layer follows.
|
||||
|
||||
import { LayoutGrid } from "lucide-react"
|
||||
import { AppShell } from "~/components/layout/app-shell"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card"
|
||||
|
||||
export default function AppsRoute() {
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<LayoutGrid className="size-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Apps</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Apps this tenant publishes — and the users that have granted them
|
||||
access to their personal clouds.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Coming soon</CardTitle>
|
||||
<CardDescription>
|
||||
App authoring lives in arcadia-agents-manager today. This view will
|
||||
surface published apps + per-app grants once the catalog endpoint
|
||||
is wired.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent />
|
||||
</Card>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
@@ -31,6 +31,15 @@ const PROBE_TIMEOUT_MS = 3000
|
||||
// "Available actions" in the system prompt only lists what's on screen NOW;
|
||||
// this catalog tells the model what exists elsewhere so it can plan
|
||||
// multi-step flows (navigate → wait_for → fill → click) in a single block.
|
||||
// Rich-output protocol: typed fenced blocks the chat renderer turns into UI
|
||||
// from @crema/*-ui. The system prompt only carries a thin INDEX (kind →
|
||||
// one-line purpose) — full schemas live in app/lib/block-schemas.ts and are
|
||||
// fetched on demand via the get_block_schema tool. Adding a new block kind
|
||||
// = edit block-schemas.ts + the renderer; no prompt edit required.
|
||||
import { blockIndexForPrompt } from "~/lib/block-schemas"
|
||||
const RICH_OUTPUT_PREFACE = blockIndexForPrompt()
|
||||
|
||||
|
||||
const UI_CONTROL_PREFACE = `You are the operator's assistant inside Arcadia Admin — the platform-admin web app for the Arcadia multi-tenant SaaS backend (Phoenix, /api/v1). The signed-in user is a platform administrator. Help them inspect and manage tenants, users, billing, audit logs, feature flags, and other platform surfaces.
|
||||
|
||||
You can both answer factual questions about the current state (use the "Admin context" block below) and drive the UI. A virtual cursor is shown to the user — every step should target an element so the cursor visibly moves.
|
||||
@@ -44,7 +53,7 @@ Rules:
|
||||
|
||||
Known action ids across the app (use these even if not in "Available actions" — the page may not be mounted yet):
|
||||
|
||||
Sidebar / nav: nav-overview, nav-tenants, nav-resources, nav-activity, nav-assistant, nav-library, nav-settings, sidebar-toggle, mobile-nav-toggle
|
||||
Sidebar / nav: nav-overview, nav-tenants, nav-resources, nav-activity, nav-search, nav-assistant, nav-library, nav-settings, sidebar-toggle, mobile-nav-toggle
|
||||
Appbar: appbar-search (input), appbar-scripts, appbar-font-size, appbar-surface, appbar-background, theme-toggle, appbar-notifications, appbar-avatar
|
||||
Account menu (after click appbar-avatar): avatar-profile (→ /profile), avatar-settings, avatar-help, avatar-signout
|
||||
Profile page: profile-avatar-upload, profile-avatar-remove, profile-name, profile-email, profile-title, profile-bio, profile-signature, profile-default-agent, profile-save, profile-revert, profile-reset
|
||||
@@ -57,6 +66,7 @@ Assistant page: assistant-model, assistant-agent, assistant-thread, assistant-th
|
||||
Library page: library-search, library-open-<id>, library-copy-<id>, library-download-<id>, library-delete-<id>
|
||||
Resources page: resources-search, resources-new-name, resources-create, resources-status-<id>, resources-delete-<id>
|
||||
Tenants page: tenants-refresh, tenants-search (input), tenants-create. Per-row (use the tenant's slug — see the "tenants" surface in Admin context for available slugs): tenant-<slug>-actions (open the kebab first), tenant-<slug>-suspend, tenant-<slug>-activate, tenant-<slug>-deactivate. Recipe to suspend a tenant: click nav-tenants, wait_for tenants-refresh, click tenant-<slug>-actions, wait_for tenant-<slug>-suspend, click tenant-<slug>-suspend.
|
||||
Search page (/search — manage arcadia-search tenants and corpora): search-refresh, search-restart (with confirm), search-new-tenant, search-new-corpus, corpora-search (input). Per-tenant chip: tenant-<id>-delete. Per-corpus row (id is "<tenant>-<corpus>"): corpus-<tenant>-<corpus>-actions (kebab), corpus-<tenant>-<corpus>-rebuild, corpus-<tenant>-<corpus>-edit, corpus-<tenant>-<corpus>-delete. New-tenant dialog: tenant-form-id (input), tenant-form-cancel, tenant-form-save. New/edit-corpus dialog: corpus-form-tenant (select, only when creating), corpus-form-config (textarea, JSON), corpus-form-cancel, corpus-form-save. Recipe to rebuild a corpus: click nav-search, wait_for search-refresh, click corpus-<tenant>-<corpus>-actions, wait_for corpus-<tenant>-<corpus>-rebuild, click corpus-<tenant>-<corpus>-rebuild. (NOTE: prefer the \`rebuild_search_corpus\` tool over UI-driving for rebuilds — it's atomic and gives a structured result; UI-drive only when the user explicitly wants to see it happen.)
|
||||
Login page: login-email, login-password, login-submit
|
||||
Notifications popover: appbar-notifications (open), notif-mark-all-read, notif-clear, notif-open-<id>, notif-dismiss-<id>
|
||||
Create a notification (hidden bridge — always available, even when not visible): fill the four hidden inputs, then click the submit button. Recipe:
|
||||
@@ -80,7 +90,7 @@ wait_for assistant-ui-control
|
||||
\`\`\`"`
|
||||
|
||||
|
||||
import { formatAdminContextForPrompt } from "~/lib/admin-context"
|
||||
import { formatContextForPrompt } from "@crema/aifirst-ui/context"
|
||||
import {
|
||||
buildDenialMessages,
|
||||
classifyCalls,
|
||||
@@ -101,9 +111,11 @@ function buildAdminPreface(activeAgent: Agent | undefined, uiControl: boolean):
|
||||
const persona = activeAgent
|
||||
? `Active persona: ${activeAgent.name} — ${activeAgent.role}\n${activeAgent.prompt}`
|
||||
: ""
|
||||
const ctx = formatAdminContextForPrompt()
|
||||
const ctx = formatContextForPrompt()
|
||||
const parts = [
|
||||
"You are the operator's assistant inside Arcadia Admin. Be precise and direct. You have native function tools attached to this conversation — call them whenever the user asks about live platform state (counts, statuses, listings, lookups). Never invent tenant slugs, user counts, or statuses; if you need data, call a tool.",
|
||||
"Two retrieval surfaces exist for documentation/knowledge: `search_docs` (browser-side, BM25 over the bundled arcadia docs — fast, always available, small corpus) and `search_kb` (server-side, BM25 over arcadia-search — `docs` (arcadia parity), `operator-tools` (arcadia-search + arcadia-admin admin docs), `files` (uploaded files), plus any custom corpora the operator adds via /search). For questions about the bundled arcadia docs either is fine; prefer `search_kb` for richer hits or for content outside the bundled docs (uploaded files, the admin tooling itself, tenant-specific knowledge). If unsure what corpora exist, call `list_search_corpora`. When `search_kb` returns a chunk_id you want to expand, call `read_chunk(chunk_id, corpus)`. When the operator says results look stale or after they've uploaded new files, call `rebuild_search_corpus(tenant, corpus)`.",
|
||||
RICH_OUTPUT_PREFACE,
|
||||
ARCADIA_KNOWLEDGE,
|
||||
persona,
|
||||
ctx,
|
||||
@@ -136,7 +148,6 @@ function withTimeout<T>(p: Promise<T>, ms: number, signal: AbortSignal): Promise
|
||||
import {
|
||||
LLMProvider,
|
||||
MockLLM,
|
||||
OpenAICompatibleAdapter,
|
||||
listModels,
|
||||
useChat,
|
||||
useCompletion,
|
||||
@@ -154,7 +165,11 @@ import {
|
||||
runActionBlocks,
|
||||
trimMessages,
|
||||
} from "@crema/action-bus"
|
||||
import { useLLMSettings } from "~/lib/llm-settings"
|
||||
import {
|
||||
buildAdapter,
|
||||
getProvider,
|
||||
useSettings as useProviderSettings,
|
||||
} from "@crema/llm-providers-ui"
|
||||
import {
|
||||
composeSystemPrompt,
|
||||
loadActiveAgentId,
|
||||
@@ -219,13 +234,13 @@ const mockAdapter = new MockLLM({
|
||||
},
|
||||
{
|
||||
matches: (req) =>
|
||||
/(take me to|open|navigate|go to).*(resources|library|settings|activity|assistant|overview|home)/i.test(
|
||||
/(take me to|open|navigate|go to).*(tenants|users|library|settings|activity|assistant|overview|home)/i.test(
|
||||
req.messages.at(-1)?.content ?? "",
|
||||
),
|
||||
response: [
|
||||
"On it.\n\n",
|
||||
"```action\n",
|
||||
"navigate /resources\n",
|
||||
"navigate /tenants\n",
|
||||
"```\n",
|
||||
],
|
||||
},
|
||||
@@ -233,7 +248,9 @@ const mockAdapter = new MockLLM({
|
||||
})
|
||||
|
||||
export default function AssistantRoute() {
|
||||
const settings = useLLMSettings()
|
||||
const settings = useProviderSettings()
|
||||
const arcadia = useArcadiaClient()
|
||||
const provider = getProvider(settings.providerId)
|
||||
const agents = useAgents()
|
||||
const threads = useThreads()
|
||||
const [status, setStatus] = useState<Status>({ kind: "probing" })
|
||||
@@ -282,32 +299,108 @@ export default function AssistantRoute() {
|
||||
updateThread(id, { title })
|
||||
}, [])
|
||||
|
||||
const [adapter, setAdapter] = useState<LLMAdapter>(mockAdapter)
|
||||
|
||||
// When the user switches providers in /settings, follow.
|
||||
useEffect(() => {
|
||||
if (settings.model) setModel(settings.model)
|
||||
}, [settings.providerId, settings.model])
|
||||
|
||||
const probe = useCallback(() => {
|
||||
const ac = new AbortController()
|
||||
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 () => {
|
||||
try {
|
||||
const a = await buildAdapter({
|
||||
settings,
|
||||
resolveSecret,
|
||||
arcadiaBaseURL,
|
||||
arcadiaAuthToken,
|
||||
arcadiaTenantId,
|
||||
})
|
||||
setAdapter(a)
|
||||
} catch {
|
||||
setAdapter(mockAdapter)
|
||||
}
|
||||
|
||||
// Anthropic has no /v1/models endpoint — use the catalog defaults.
|
||||
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 {}
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = await withTimeout(
|
||||
listModels({ baseURL, apiKey, signal: ac.signal }),
|
||||
PROBE_TIMEOUT_MS,
|
||||
ac.signal,
|
||||
)
|
||||
.then((rows) => {
|
||||
const ids = rows.map((m) => m.id)
|
||||
if (ids.length === 0) {
|
||||
setStatus({ kind: "mock", reason: "LM Studio returned no models" })
|
||||
setStatus({ kind: "mock", reason: "endpoint returned no models" })
|
||||
return
|
||||
}
|
||||
setStatus({ kind: "live", models: ids })
|
||||
setModel((cur) => (cur && ids.includes(cur) ? cur : ids[0]))
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
setModel((cur) => (cur && ids.includes(cur) ? cur : settings.model || ids[0]))
|
||||
} catch (err: unknown) {
|
||||
if ((err as DOMException)?.name === "AbortError") return
|
||||
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: err instanceof Error ? err.message : "LM Studio unreachable",
|
||||
})
|
||||
reason: err instanceof Error ? err.message : "endpoint unreachable",
|
||||
})
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
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])
|
||||
|
||||
@@ -315,19 +408,11 @@ export default function AssistantRoute() {
|
||||
if (model && model !== "mock") localStorage.setItem(STORAGE_KEY, model)
|
||||
}, [model])
|
||||
|
||||
const adapter: LLMAdapter = useMemo(
|
||||
() =>
|
||||
status.kind === "live"
|
||||
? new OpenAICompatibleAdapter({ baseURL: settings.baseURL })
|
||||
: mockAdapter,
|
||||
[status.kind, settings.baseURL],
|
||||
)
|
||||
|
||||
const activeModel =
|
||||
status.kind === "live" ? model || status.models[0] : "mock"
|
||||
|
||||
return (
|
||||
<AppShell title="Assistant">
|
||||
<AppShell>
|
||||
<LLMProvider adapter={adapter} model={activeModel}>
|
||||
<AssistantSurface
|
||||
key={`${activeThreadId}-${compactNonce}`}
|
||||
@@ -339,7 +424,7 @@ export default function AssistantRoute() {
|
||||
onModelChange={setModel}
|
||||
contextTokens={settings.contextTokens}
|
||||
responseBudget={settings.responseBudget}
|
||||
baseURL={settings.baseURL}
|
||||
baseURL={settings.baseURL || provider.baseURL}
|
||||
basePrompt={settings.systemPrompt}
|
||||
onRetryProbe={probe}
|
||||
onRemount={remount}
|
||||
|
||||
1470
app/routes/buckets.tsx
Normal file
1470
app/routes/buckets.tsx
Normal file
File diff suppressed because it is too large
Load Diff
42
app/routes/entitlements.tsx
Normal file
42
app/routes/entitlements.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
// Tenant entitlements — placeholder. Lists the metered allowances
|
||||
// (AI tokens, storage GB, etc.) granted to the active tenant and how
|
||||
// much of each has been consumed. Data source not wired yet.
|
||||
|
||||
import { Gauge } from "lucide-react"
|
||||
import { AppShell } from "~/components/layout/app-shell"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card"
|
||||
|
||||
export default function EntitlementsRoute() {
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<Gauge className="size-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Entitlements</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Metered allowances for this tenant — included units and usage to
|
||||
date per meter.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Coming soon</CardTitle>
|
||||
<CardDescription>
|
||||
Personal-cloud entitlements are tracked per account today. A
|
||||
tenant-rollup endpoint is pending.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent />
|
||||
</Card>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,23 @@
|
||||
import { ArrowRight, Sparkles, Boxes, Activity, BookOpen } from "lucide-react"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { Link } from "react-router"
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
Building2,
|
||||
CheckCircle2,
|
||||
CircleAlert,
|
||||
HeartPulse,
|
||||
RefreshCw,
|
||||
Users as UsersIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
|
||||
import { AlertBanner } from "@crema/feedback-ui"
|
||||
|
||||
import { AppShell } from "~/components/layout/app-shell"
|
||||
import { PageHeader } from "~/components/layout/page-header"
|
||||
import { Button } from "~/components/ui/button"
|
||||
import { Skeleton } from "~/components/ui/skeleton"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -9,88 +25,406 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card"
|
||||
import { listAuditLogs, type AuditLog } from "~/lib/arcadia/audit-logs"
|
||||
import {
|
||||
getHealth,
|
||||
SUBSYSTEMS,
|
||||
type HealthStatus,
|
||||
type HealthSubsystem,
|
||||
type OverallHealth,
|
||||
} from "~/lib/arcadia/health"
|
||||
import { listTenants, type Tenant } from "~/lib/arcadia/tenants"
|
||||
import { listUsers, type User } from "~/lib/arcadia/users"
|
||||
import { useRegisterContext } from "@crema/aifirst-ui/context"
|
||||
import { pageTitle } from "~/lib/page-meta"
|
||||
import { useSession } from "~/lib/session"
|
||||
|
||||
export const meta = () => pageTitle("Overview")
|
||||
|
||||
const tiles = [
|
||||
{
|
||||
to: "/assistant",
|
||||
icon: Sparkles,
|
||||
title: "Assistant",
|
||||
body: "AI-first surface — chat, suggestions, and full UI control.",
|
||||
accent: true,
|
||||
},
|
||||
{
|
||||
to: "/resources",
|
||||
icon: Boxes,
|
||||
title: "Resources",
|
||||
body: "Traditional list + detail surface for managed entities.",
|
||||
},
|
||||
{
|
||||
to: "/activity",
|
||||
icon: Activity,
|
||||
title: "Activity",
|
||||
body: "Event stream and audit log.",
|
||||
},
|
||||
{
|
||||
to: "/library",
|
||||
icon: BookOpen,
|
||||
title: "Library",
|
||||
body: "Saved items, templates, reusable artifacts.",
|
||||
},
|
||||
]
|
||||
interface DashboardData {
|
||||
tenants: Tenant[]
|
||||
users: User[]
|
||||
audit: AuditLog[]
|
||||
health: OverallHealth | null
|
||||
}
|
||||
|
||||
const EMPTY: DashboardData = { tenants: [], users: [], audit: [], health: null }
|
||||
|
||||
export default function HomeRoute() {
|
||||
const session = useSession()
|
||||
const arcadia = useArcadiaClient()
|
||||
|
||||
const [data, setData] = useState<DashboardData>(EMPTY)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [refreshedAt, setRefreshedAt] = useState<Date | null>(null)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
const [tenants, users, audit, health] = await Promise.all([
|
||||
listTenants(arcadia).catch((err) => {
|
||||
throw err
|
||||
}),
|
||||
listUsers(arcadia),
|
||||
listAuditLogs(arcadia, { limit: 10 }),
|
||||
getHealth(arcadia).catch(() => null),
|
||||
]).catch((err) => {
|
||||
setError(err instanceof ArcadiaError ? err.message : "Failed to load overview.")
|
||||
return [[], [], [], null] as [Tenant[], User[], AuditLog[], OverallHealth | null]
|
||||
})
|
||||
setData({ tenants, users, audit, health })
|
||||
setRefreshedAt(new Date())
|
||||
setLoading(false)
|
||||
}, [arcadia])
|
||||
|
||||
useEffect(() => {
|
||||
if (session) refresh()
|
||||
}, [session, refresh])
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const activeTenants = data.tenants.filter((t) => t.status === "active").length
|
||||
const activeUsers = data.users.filter((u) => u.status === "active").length
|
||||
const errorEvents = data.audit.filter(
|
||||
(a) => a.severity === "error" || a.severity === "critical",
|
||||
).length
|
||||
return {
|
||||
tenants: { total: data.tenants.length, active: activeTenants },
|
||||
users: { total: data.users.length, active: activeUsers },
|
||||
audit: { recent: data.audit.length, errors: errorEvents },
|
||||
health: data.health?.status ?? "unconfigured",
|
||||
}
|
||||
}, [data])
|
||||
|
||||
useRegisterContext("overview", stats)
|
||||
|
||||
return (
|
||||
<AppShell title="Overview">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Welcome</CardTitle>
|
||||
<AppShell>
|
||||
<PageHeader
|
||||
title="Overview"
|
||||
description={
|
||||
<>
|
||||
Live snapshot of the platform — tenants, users, recent activity, and health.
|
||||
{refreshedAt ? (
|
||||
<>
|
||||
{" "}
|
||||
Refreshed {refreshedAt.toLocaleTimeString()}.
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<Button
|
||||
data-action="overview-refresh"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={refresh}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className={loading ? "size-4 animate-spin" : "size-4"} />
|
||||
Refresh
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
|
||||
{error}
|
||||
</AlertBanner>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<StatTile
|
||||
to="/tenants"
|
||||
dataAction="overview-tile-tenants"
|
||||
icon={Building2}
|
||||
label="Tenants"
|
||||
value={stats.tenants.total}
|
||||
sub={`${stats.tenants.active} active`}
|
||||
loading={loading}
|
||||
/>
|
||||
<StatTile
|
||||
to="/users"
|
||||
dataAction="overview-tile-users"
|
||||
icon={UsersIcon}
|
||||
label="Users"
|
||||
value={stats.users.total}
|
||||
sub={`${stats.users.active} active`}
|
||||
loading={loading}
|
||||
/>
|
||||
<StatTile
|
||||
to="/activity"
|
||||
dataAction="overview-tile-activity"
|
||||
icon={Activity}
|
||||
label="Recent events"
|
||||
value={stats.audit.recent}
|
||||
sub={
|
||||
stats.audit.errors > 0
|
||||
? `${stats.audit.errors} error${stats.audit.errors === 1 ? "" : "s"}`
|
||||
: "no errors"
|
||||
}
|
||||
loading={loading}
|
||||
tone={stats.audit.errors > 0 ? "warning" : "default"}
|
||||
/>
|
||||
<StatTile
|
||||
to="/monitoring"
|
||||
dataAction="overview-tile-health"
|
||||
icon={HeartPulse}
|
||||
label="Platform health"
|
||||
value={statusLabel(stats.health)}
|
||||
sub={data.health ? `as of ${new Date(data.health.checked_at).toLocaleTimeString()}` : "unreachable"}
|
||||
loading={loading}
|
||||
tone={statusTone(stats.health)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader className="flex-row items-center justify-between gap-2">
|
||||
<div>
|
||||
<CardTitle>Recent activity</CardTitle>
|
||||
<CardDescription>
|
||||
A hybrid traditional + AI-first scaffold. Use the rail to navigate;
|
||||
the Assistant can drive the UI on your behalf — try{" "}
|
||||
<kbd className="rounded border bg-muted px-1.5 py-0.5 font-mono text-xs">
|
||||
⌘⇧P
|
||||
</kbd>{" "}
|
||||
for the script runner.
|
||||
Latest audit events across the platform.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Link
|
||||
to="/activity"
|
||||
data-action="overview-activity-all"
|
||||
className="text-xs font-medium text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
View all →
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RecentActivity logs={data.audit} loading={loading} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{tiles.map((t) => {
|
||||
const Icon = t.icon
|
||||
return (
|
||||
<Link
|
||||
key={t.to}
|
||||
to={t.to}
|
||||
data-action={`home-tile-${t.title.toLowerCase()}`}
|
||||
className="group block"
|
||||
>
|
||||
<Card
|
||||
className={[
|
||||
"h-full transition-colors",
|
||||
t.accent
|
||||
? "border-primary/30 bg-primary/5 hover:border-primary/50"
|
||||
: "hover:border-foreground/20",
|
||||
].join(" ")}
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="mb-2 flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<Icon className="size-5" />
|
||||
</div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{t.title}
|
||||
<ArrowRight className="size-4 opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</CardTitle>
|
||||
<CardDescription>{t.body}</CardDescription>
|
||||
<CardTitle>Subsystems</CardTitle>
|
||||
<CardDescription>
|
||||
Live probe of each platform subsystem.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<SubsystemList health={data.health} loading={loading} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
function StatTile({
|
||||
to,
|
||||
dataAction,
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
loading,
|
||||
tone = "default",
|
||||
}: {
|
||||
to: string
|
||||
dataAction: string
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
label: string
|
||||
value: number | string
|
||||
sub: string
|
||||
loading: boolean
|
||||
tone?: "default" | "warning" | "error" | "ok"
|
||||
}) {
|
||||
const accent =
|
||||
tone === "error"
|
||||
? "border-destructive/40 bg-destructive/5"
|
||||
: tone === "warning"
|
||||
? "border-amber-500/40 bg-amber-500/5"
|
||||
: tone === "ok"
|
||||
? "border-emerald-500/40 bg-emerald-500/5"
|
||||
: ""
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
data-action={dataAction}
|
||||
className="group block focus:outline-none"
|
||||
>
|
||||
<Card className={`h-full transition-colors hover:border-foreground/20 ${accent}`}>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
<Icon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-1">
|
||||
{loading ? (
|
||||
<Skeleton className="h-9 w-20" />
|
||||
) : (
|
||||
<span className="text-3xl font-semibold tabular-nums">{value}</span>
|
||||
)}
|
||||
{loading ? (
|
||||
<Skeleton className="h-3 w-24" />
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">{sub}</span>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function RecentActivity({ logs, loading }: { logs: AuditLog[]; loading: boolean }) {
|
||||
if (loading && logs.length === 0) {
|
||||
return (
|
||||
<p className="py-4 text-sm text-muted-foreground">
|
||||
<RefreshCw className="mr-1 inline size-3.5 animate-spin" /> Loading…
|
||||
</p>
|
||||
)
|
||||
}
|
||||
if (logs.length === 0) {
|
||||
return (
|
||||
<p className="py-4 text-sm text-muted-foreground">No recent events.</p>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<ul className="flex flex-col divide-y">
|
||||
{logs.slice(0, 8).map((l) => (
|
||||
<li
|
||||
key={l.id}
|
||||
className="flex items-start justify-between gap-3 py-2.5 text-sm"
|
||||
>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="flex items-center gap-2">
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">
|
||||
{l.action}
|
||||
</code>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{l.resource_type}
|
||||
{l.resource_id ? ` · ${l.resource_id.slice(0, 8)}…` : ""}
|
||||
</span>
|
||||
</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{l.user?.email ?? "system"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-0.5">
|
||||
<SeverityDot severity={l.severity} />
|
||||
<time
|
||||
className="text-[11px] text-muted-foreground"
|
||||
dateTime={l.inserted_at}
|
||||
>
|
||||
{timeAgo(l.inserted_at)}
|
||||
</time>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
function SubsystemList({
|
||||
health,
|
||||
loading,
|
||||
}: {
|
||||
health: OverallHealth | null
|
||||
loading: boolean
|
||||
}) {
|
||||
if (loading && !health) {
|
||||
return (
|
||||
<p className="py-4 text-sm text-muted-foreground">
|
||||
<RefreshCw className="mr-1 inline size-3.5 animate-spin" /> Probing…
|
||||
</p>
|
||||
)
|
||||
}
|
||||
if (!health) {
|
||||
return (
|
||||
<p className="py-4 text-sm text-muted-foreground">
|
||||
Health endpoint unreachable.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<ul className="flex flex-col divide-y">
|
||||
{SUBSYSTEMS.map((sys) => {
|
||||
const sub = health.subsystems[sys]
|
||||
return (
|
||||
<li
|
||||
key={sys}
|
||||
className="flex items-center justify-between gap-3 py-2.5 text-sm"
|
||||
>
|
||||
<span className="font-medium capitalize">{labelFor(sys)}</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<StatusIcon status={sub?.status ?? "unconfigured"} />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{sub?.message ?? statusLabel(sub?.status ?? "unconfigured")}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
function SeverityDot({ severity }: { severity: string }) {
|
||||
const tone =
|
||||
severity === "critical" || severity === "error"
|
||||
? "bg-destructive"
|
||||
: severity === "warning"
|
||||
? "bg-amber-500"
|
||||
: "bg-emerald-500"
|
||||
return (
|
||||
<span
|
||||
aria-label={severity}
|
||||
title={severity}
|
||||
className={`size-2 rounded-full ${tone}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusIcon({ status }: { status: HealthStatus }) {
|
||||
if (status === "ok")
|
||||
return <CheckCircle2 className="size-4 text-emerald-500" aria-label="ok" />
|
||||
if (status === "degraded")
|
||||
return <AlertTriangle className="size-4 text-amber-500" aria-label="degraded" />
|
||||
if (status === "error")
|
||||
return <CircleAlert className="size-4 text-destructive" aria-label="error" />
|
||||
return <CircleAlert className="size-4 text-muted-foreground" aria-label="unconfigured" />
|
||||
}
|
||||
|
||||
function labelFor(sys: HealthSubsystem): string {
|
||||
if (sys === "api") return "API"
|
||||
if (sys === "db") return "Database"
|
||||
return sys
|
||||
}
|
||||
|
||||
function statusLabel(status: HealthStatus | string): string {
|
||||
if (status === "ok") return "Healthy"
|
||||
if (status === "degraded") return "Degraded"
|
||||
if (status === "error") return "Down"
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
function statusTone(status: HealthStatus | string): "default" | "ok" | "warning" | "error" {
|
||||
if (status === "ok") return "ok"
|
||||
if (status === "degraded") return "warning"
|
||||
if (status === "error") return "error"
|
||||
return "default"
|
||||
}
|
||||
|
||||
function timeAgo(iso: string): string {
|
||||
const t = new Date(iso).getTime()
|
||||
if (Number.isNaN(t)) return ""
|
||||
const diff = Date.now() - t
|
||||
const sec = Math.round(diff / 1000)
|
||||
if (sec < 60) return `${sec}s ago`
|
||||
const min = Math.round(sec / 60)
|
||||
if (min < 60) return `${min}m ago`
|
||||
const hr = Math.round(min / 60)
|
||||
if (hr < 24) return `${hr}h ago`
|
||||
const d = Math.round(hr / 24)
|
||||
return `${d}d ago`
|
||||
}
|
||||
|
||||
632
app/routes/integrations.tsx
Normal file
632
app/routes/integrations.tsx
Normal file
@@ -0,0 +1,632 @@
|
||||
// Integrations (operator) — platform/pooled external-API arrangements across
|
||||
// every scope, backed by the integration registry on arcadia-llm-gateway
|
||||
// (`/api/v1/integrations*`). The operator manages pooled credentials and
|
||||
// inspects cross-tenant usage metadata; secrets are write-only.
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
FlaskConical,
|
||||
KeyRound,
|
||||
Pencil,
|
||||
Plug,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from "lucide-react"
|
||||
import { ArcadiaError } from "@crema/arcadia-client"
|
||||
|
||||
import { AppShell } from "~/components/layout/app-shell"
|
||||
import { Badge } from "~/components/ui/badge"
|
||||
import { Button } from "~/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog"
|
||||
import { Input } from "~/components/ui/input"
|
||||
import { Label } from "~/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select"
|
||||
import { Switch } from "~/components/ui/switch"
|
||||
import { useGatewayClient } from "~/lib/gateway"
|
||||
import {
|
||||
addCredential,
|
||||
createIntegration,
|
||||
credentialHealth,
|
||||
deleteIntegration,
|
||||
formatUsd,
|
||||
listIntegrations,
|
||||
testIntegration,
|
||||
updateIntegration,
|
||||
usageSummary,
|
||||
type AuthKind,
|
||||
type Integration,
|
||||
type Scope,
|
||||
type UsageEntry,
|
||||
} from "~/lib/arcadia/integrations"
|
||||
|
||||
const AUTH_KINDS: AuthKind[] = ["bearer_static", "api_key_header", "basic", "oauth2"]
|
||||
const SCOPES: Scope[] = ["platform", "tenant", "app", "user", "agent"]
|
||||
const SCOPE_FILTERS: Array<Scope | "all"> = ["all", ...SCOPES]
|
||||
|
||||
type Form = {
|
||||
scope: Scope
|
||||
scope_id: string
|
||||
provider: string
|
||||
capability: string
|
||||
display_name: string
|
||||
unit: string
|
||||
price_usd: string
|
||||
monthly_budget_usd: string
|
||||
secret_name: string
|
||||
auth_kind: AuthKind
|
||||
secret: string
|
||||
pooled: boolean
|
||||
}
|
||||
|
||||
const emptyForm: Form = {
|
||||
scope: "platform",
|
||||
scope_id: "",
|
||||
provider: "",
|
||||
capability: "",
|
||||
display_name: "",
|
||||
unit: "call",
|
||||
price_usd: "",
|
||||
monthly_budget_usd: "",
|
||||
secret_name: "",
|
||||
auth_kind: "bearer_static",
|
||||
secret: "",
|
||||
pooled: true,
|
||||
}
|
||||
|
||||
export default function IntegrationsRoute() {
|
||||
const gw = useGatewayClient()
|
||||
const [items, setItems] = useState<Integration[]>([])
|
||||
const [usage, setUsage] = useState<UsageEntry[]>([])
|
||||
const [scopeFilter, setScopeFilter] = useState<Scope | "all">("all")
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [editing, setEditing] = useState<Integration | "new" | null>(null)
|
||||
const [tests, setTests] = useState<Record<string, { ok: boolean; message: string }>>({})
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setError(null)
|
||||
const filter = scopeFilter === "all" ? {} : { scope: scopeFilter }
|
||||
try {
|
||||
const [list, use] = await Promise.all([
|
||||
listIntegrations(gw, filter),
|
||||
usageSummary(gw, filter).catch(() => [] as UsageEntry[]),
|
||||
])
|
||||
setItems(list)
|
||||
setUsage(use)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to load integrations.")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [gw, scopeFilter])
|
||||
|
||||
useEffect(() => {
|
||||
void refresh()
|
||||
}, [refresh])
|
||||
|
||||
const usageById = useMemo(
|
||||
() => new Map(usage.map((u) => [u.integration_id, u] as const)),
|
||||
[usage],
|
||||
)
|
||||
|
||||
const runTest = useCallback(
|
||||
async (it: Integration) => {
|
||||
setTests((t) => ({ ...t, [it.id]: { ok: true, message: "Testing…" } }))
|
||||
try {
|
||||
const verdict = await testIntegration(gw, it.id)
|
||||
const remaining = verdict.policy?.remaining_budget_usd
|
||||
setTests((t) => ({
|
||||
...t,
|
||||
[it.id]: {
|
||||
ok: true,
|
||||
message:
|
||||
verdict.status === "ok"
|
||||
? `OK — within budget & rate${remaining ? ` (${formatUsd(remaining)} left)` : ""}`
|
||||
: verdict.status,
|
||||
},
|
||||
}))
|
||||
} catch (e) {
|
||||
const msg =
|
||||
e instanceof ArcadiaError
|
||||
? e.status === 409
|
||||
? "Credential expired — rotate it"
|
||||
: e.status === 429
|
||||
? "Over budget / rate limit"
|
||||
: e.status === 404
|
||||
? "No credential to test"
|
||||
: e.message
|
||||
: "Test failed"
|
||||
setTests((t) => ({ ...t, [it.id]: { ok: false, message: msg } }))
|
||||
}
|
||||
},
|
||||
[gw],
|
||||
)
|
||||
|
||||
const toggleEnabled = useCallback(
|
||||
async (it: Integration, enabled: boolean) => {
|
||||
setItems((xs) => xs.map((x) => (x.id === it.id ? { ...x, enabled } : x)))
|
||||
try {
|
||||
await updateIntegration(gw, it.id, { enabled })
|
||||
} catch {
|
||||
setItems((xs) => xs.map((x) => (x.id === it.id ? { ...x, enabled: !enabled } : x)))
|
||||
}
|
||||
},
|
||||
[gw],
|
||||
)
|
||||
|
||||
const remove = useCallback(
|
||||
async (it: Integration) => {
|
||||
if (!window.confirm(`Delete ${it.display_name || it.provider} and its credentials?`)) return
|
||||
await deleteIntegration(gw, it.id)
|
||||
await refresh()
|
||||
},
|
||||
[gw, refresh],
|
||||
)
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<Plug className="size-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Integrations</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Platform & pooled external-API credentials across every scope.
|
||||
Keys are stored encrypted and never shown; usage is metadata only.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={scopeFilter} onValueChange={(v) => setScopeFilter((v as Scope | "all") ?? "all")}>
|
||||
<SelectTrigger className="w-36">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SCOPE_FILTERS.map((s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
{s === "all" ? "All scopes" : s}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={() => setEditing("new")}>
|
||||
<Plus className="size-4" /> Add integration
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<Card className="border-destructive/40">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive">Couldn’t load integrations</CardTitle>
|
||||
<CardDescription>{error}</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
) : loading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||
) : items.length === 0 ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>No integrations in this scope</CardTitle>
|
||||
<CardDescription>
|
||||
Register a platform/pooled arrangement — a shared key the platform
|
||||
meters and bills to tenants who opt in.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button onClick={() => setEditing("new")}>
|
||||
<Plus className="size-4" /> Add integration
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{items.map((it) => {
|
||||
const u = usageById.get(it.id)
|
||||
const test = tests[it.id]
|
||||
return (
|
||||
<Card key={it.id}>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{it.display_name || it.provider}
|
||||
<Badge>{it.scope}</Badge>
|
||||
{it.scope_id ? (
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{it.scope_id}
|
||||
</span>
|
||||
) : null}
|
||||
{it.capability ? (
|
||||
<Badge variant="secondary">{it.capability}</Badge>
|
||||
) : null}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{it.provider}
|
||||
{it.cost_model?.price_usd
|
||||
? ` · ${formatUsd(it.cost_model.price_usd)}/${it.cost_model.unit ?? "call"}`
|
||||
: ""}
|
||||
{it.constraints?.monthly_budget_usd
|
||||
? ` · budget ${formatUsd(it.constraints.monthly_budget_usd)}/mo`
|
||||
: ""}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor={`en-${it.id}`} className="text-xs text-muted-foreground">
|
||||
{it.enabled ? "Enabled" : "Disabled"}
|
||||
</Label>
|
||||
<Switch
|
||||
id={`en-${it.id}`}
|
||||
checked={it.enabled}
|
||||
onCheckedChange={(v) => toggleEnabled(it, v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
{it.credentials.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No credential set.</p>
|
||||
) : (
|
||||
it.credentials.map((cred) => {
|
||||
const health = credentialHealth(cred)
|
||||
return (
|
||||
<div key={cred.id} className="flex items-center gap-2 text-sm">
|
||||
<KeyRound className="size-4 text-muted-foreground" />
|
||||
<span className="font-mono">{cred.secret_name}</span>
|
||||
<Badge variant="outline">{cred.source}</Badge>
|
||||
<HealthBadge health={health} />
|
||||
{cred.expires_at ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
expires {new Date(cred.expires_at).toLocaleDateString()}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{u ? `${u.calls} calls · ${formatUsd(u.cost_usd)} this month` : "No usage yet"}
|
||||
</p>
|
||||
|
||||
{test ? (
|
||||
<p
|
||||
className={`text-sm ${test.ok ? "text-emerald-600 dark:text-emerald-400" : "text-destructive"}`}
|
||||
>
|
||||
{test.message}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
<Button variant="outline" size="sm" onClick={() => runTest(it)}>
|
||||
<FlaskConical className="size-4" /> Test
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setEditing(it)}>
|
||||
<Pencil className="size-4" /> Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive"
|
||||
onClick={() => remove(it)}
|
||||
>
|
||||
<Trash2 className="size-4" /> Delete
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing ? (
|
||||
<IntegrationDialog
|
||||
mode={editing === "new" ? "new" : "edit"}
|
||||
initial={editing === "new" ? null : editing}
|
||||
onClose={() => setEditing(null)}
|
||||
onSaved={async () => {
|
||||
setEditing(null)
|
||||
await refresh()
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
function HealthBadge({ health }: { health: ReturnType<typeof credentialHealth> }) {
|
||||
if (health === "ok")
|
||||
return (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<CheckCircle2 className="size-3" /> healthy
|
||||
</Badge>
|
||||
)
|
||||
const label = health === "missing" ? "no secret" : health
|
||||
return (
|
||||
<Badge variant="destructive" className="gap-1">
|
||||
<AlertTriangle className="size-3" /> {label}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
function IntegrationDialog({
|
||||
mode,
|
||||
initial,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
mode: "new" | "edit"
|
||||
initial: Integration | null
|
||||
onClose: () => void
|
||||
onSaved: () => void | Promise<void>
|
||||
}) {
|
||||
const gw = useGatewayClient()
|
||||
const [form, setForm] = useState<Form>(() =>
|
||||
initial
|
||||
? {
|
||||
...emptyForm,
|
||||
scope: initial.scope,
|
||||
scope_id: initial.scope_id ?? "",
|
||||
provider: initial.provider,
|
||||
capability: initial.capability ?? "",
|
||||
display_name: initial.display_name ?? "",
|
||||
unit: initial.cost_model?.unit ?? "call",
|
||||
price_usd: initial.cost_model?.price_usd?.toString() ?? "",
|
||||
monthly_budget_usd: initial.constraints?.monthly_budget_usd?.toString() ?? "",
|
||||
}
|
||||
: emptyForm,
|
||||
)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
const set = (patch: Partial<Form>) => setForm((f) => ({ ...f, ...patch }))
|
||||
|
||||
const needsScopeId = form.scope !== "platform"
|
||||
|
||||
const submit = async () => {
|
||||
setSaving(true)
|
||||
setErr(null)
|
||||
try {
|
||||
const cost_model = form.price_usd
|
||||
? { unit: form.unit as "call" | "search" | "1k_tokens", price_usd: form.price_usd, currency: "USD" }
|
||||
: undefined
|
||||
const constraints = form.monthly_budget_usd
|
||||
? { monthly_budget_usd: form.monthly_budget_usd }
|
||||
: undefined
|
||||
|
||||
if (mode === "edit" && initial) {
|
||||
await updateIntegration(gw, initial.id, {
|
||||
provider: form.provider.trim(),
|
||||
capability: form.capability.trim() || undefined,
|
||||
display_name: form.display_name.trim() || undefined,
|
||||
cost_model,
|
||||
constraints,
|
||||
})
|
||||
} else {
|
||||
const created = await createIntegration(gw, {
|
||||
scope: form.scope,
|
||||
scope_id: needsScopeId ? form.scope_id.trim() || undefined : undefined,
|
||||
provider: form.provider.trim(),
|
||||
capability: form.capability.trim() || undefined,
|
||||
display_name: form.display_name.trim() || undefined,
|
||||
cost_model,
|
||||
constraints,
|
||||
})
|
||||
if (form.secret_name.trim() && form.secret.trim()) {
|
||||
await addCredential(gw, created.id, {
|
||||
secret_name: form.secret_name.trim(),
|
||||
auth_kind: form.auth_kind,
|
||||
secret: form.secret,
|
||||
source: form.pooled ? "pooled" : "byo",
|
||||
})
|
||||
}
|
||||
}
|
||||
await onSaved()
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : "Save failed.")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => (!o ? onClose() : undefined)}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{mode === "new" ? "Add integration" : "Edit integration"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Register an external-API arrangement. Platform scope = a pooled key
|
||||
the platform meters and bills.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-2">
|
||||
{mode === "new" ? (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Scope">
|
||||
<Select value={form.scope} onValueChange={(v) => set({ scope: (v as Scope) ?? "platform" })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SCOPES.map((s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
{s}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Scope ID" hint={needsScopeId ? "tenant/app/user/agent id" : "n/a for platform"}>
|
||||
<Input
|
||||
value={form.scope_id}
|
||||
onChange={(e) => set({ scope_id: e.target.value })}
|
||||
disabled={!needsScopeId}
|
||||
placeholder={needsScopeId ? "acme" : "—"}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Field label="Provider" hint="e.g. tavily, google_maps, duffel">
|
||||
<Input
|
||||
value={form.provider}
|
||||
onChange={(e) => set({ provider: e.target.value })}
|
||||
placeholder="tavily"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Capability (optional)" hint="e.g. web_search, geocode">
|
||||
<Input
|
||||
value={form.capability}
|
||||
onChange={(e) => set({ capability: e.target.value })}
|
||||
placeholder="web_search"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Display name (optional)">
|
||||
<Input
|
||||
value={form.display_name}
|
||||
onChange={(e) => set({ display_name: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Price (USD)" hint="per unit, for metering">
|
||||
<Input
|
||||
inputMode="decimal"
|
||||
value={form.price_usd}
|
||||
onChange={(e) => set({ price_usd: e.target.value })}
|
||||
placeholder="0.01"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Unit">
|
||||
<Select value={form.unit} onValueChange={(v) => set({ unit: v ?? "call" })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="call">call</SelectItem>
|
||||
<SelectItem value="search">search</SelectItem>
|
||||
<SelectItem value="1k_tokens">1k_tokens</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Monthly budget (USD, optional)" hint="resolve is refused past this">
|
||||
<Input
|
||||
inputMode="decimal"
|
||||
value={form.monthly_budget_usd}
|
||||
onChange={(e) => set({ monthly_budget_usd: e.target.value })}
|
||||
placeholder="500"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{mode === "new" ? (
|
||||
<div className="space-y-3 rounded-lg border p-3">
|
||||
<p className="text-sm font-medium">Credential (optional)</p>
|
||||
<Field label="Secret name" hint="the stable handle tools resolve by">
|
||||
<Input
|
||||
value={form.secret_name}
|
||||
onChange={(e) => set({ secret_name: e.target.value })}
|
||||
placeholder="tavily_default"
|
||||
/>
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Auth kind">
|
||||
<Select
|
||||
value={form.auth_kind}
|
||||
onValueChange={(v) => set({ auth_kind: (v as AuthKind) ?? "bearer_static" })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AUTH_KINDS.map((k) => (
|
||||
<SelectItem key={k} value={k}>
|
||||
{k}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Source">
|
||||
<div className="flex h-9 items-center gap-2">
|
||||
<Switch
|
||||
id="pooled"
|
||||
checked={form.pooled}
|
||||
onCheckedChange={(v) => set({ pooled: v })}
|
||||
/>
|
||||
<Label htmlFor="pooled" className="text-sm">
|
||||
{form.pooled ? "pooled (billed)" : "BYO key"}
|
||||
</Label>
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Secret value" hint="stored encrypted, never shown again">
|
||||
<Input
|
||||
type="password"
|
||||
value={form.secret}
|
||||
onChange={(e) => set({ secret: e.target.value })}
|
||||
placeholder="sk-…"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{err ? <p className="text-sm text-destructive">{err}</p> : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={saving || !form.provider.trim()}>
|
||||
{saving ? "Saving…" : mode === "new" ? "Create" : "Save"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
hint?: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<Label>{label}</Label>
|
||||
{children}
|
||||
{hint ? <p className="text-xs text-muted-foreground">{hint}</p> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -38,7 +38,7 @@ export default function LibraryRoute() {
|
||||
const open = items.find((x) => x.id === openId) ?? null
|
||||
|
||||
return (
|
||||
<AppShell title="Library">
|
||||
<AppShell>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Library</CardTitle>
|
||||
|
||||
61
app/routes/login.2fa.tsx
Normal file
61
app/routes/login.2fa.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useState } from "react"
|
||||
import { useNavigate, useSearchParams } from "react-router"
|
||||
|
||||
import { TwoFactorChallengeForm } from "@crema/arcadia-auth-ui"
|
||||
import { pageTitle } from "~/lib/page-meta"
|
||||
import { persistFromArcadiaLogin } from "~/lib/session"
|
||||
import { AuthBrand, AuthShell } from "~/components/auth/auth-shell"
|
||||
|
||||
export const meta = () => pageTitle("Two-factor verification")
|
||||
|
||||
export default function TwoFactorRoute() {
|
||||
const [params] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const challenge = params.get("challenge") ?? ""
|
||||
const next = params.get("next") || "/"
|
||||
const [mode, setMode] = useState<"totp" | "recovery">("totp")
|
||||
|
||||
if (!challenge) {
|
||||
return (
|
||||
<AuthShell>
|
||||
<div
|
||||
className="flex w-full max-w-sm flex-col items-center gap-3 rounded-xl border bg-card p-6 text-center text-sm"
|
||||
style={{ borderColor: "var(--border)" }}
|
||||
>
|
||||
<h1 className="text-base font-semibold">Challenge missing</h1>
|
||||
<p className="text-muted-foreground">
|
||||
This page is only reachable after a sign-in attempt. Start over.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/login")}
|
||||
className="mt-2 text-xs font-medium text-primary hover:underline"
|
||||
data-action="2fa-back-to-login"
|
||||
>
|
||||
Back to sign in
|
||||
</button>
|
||||
</div>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthShell>
|
||||
<TwoFactorChallengeForm
|
||||
brand={<AuthBrand />}
|
||||
challenge={challenge}
|
||||
mode={mode}
|
||||
onUseRecoveryCode={mode === "totp" ? () => setMode("recovery") : undefined}
|
||||
onBack={
|
||||
mode === "recovery"
|
||||
? () => setMode("totp")
|
||||
: () => navigate("/login")
|
||||
}
|
||||
onSuccess={({ tokens, user }) => {
|
||||
persistFromArcadiaLogin(tokens, user)
|
||||
navigate(next, { replace: true })
|
||||
}}
|
||||
/>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
50
app/routes/login.forgot.tsx
Normal file
50
app/routes/login.forgot.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useState } from "react"
|
||||
import { useNavigate } from "react-router"
|
||||
import { CheckCircle2 } from "lucide-react"
|
||||
|
||||
import { PasswordResetRequestForm } from "@crema/arcadia-auth-ui"
|
||||
import { pageTitle } from "~/lib/page-meta"
|
||||
import { AuthBrand, AuthShell } from "~/components/auth/auth-shell"
|
||||
|
||||
export const meta = () => pageTitle("Reset password")
|
||||
|
||||
export default function ForgotPasswordRoute() {
|
||||
const navigate = useNavigate()
|
||||
const [sentTo, setSentTo] = useState<string | null>(null)
|
||||
|
||||
if (sentTo) {
|
||||
return (
|
||||
<AuthShell>
|
||||
<div
|
||||
className="flex w-full max-w-sm flex-col items-center gap-3 rounded-xl border bg-card p-6 text-center text-sm"
|
||||
style={{ borderColor: "var(--border)" }}
|
||||
>
|
||||
<CheckCircle2 className="size-8 text-emerald-500" />
|
||||
<h1 className="text-base font-semibold">Check your email</h1>
|
||||
<p className="text-muted-foreground">
|
||||
If an account exists for <strong>{sentTo}</strong>, we've sent a link
|
||||
to reset your password.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/login")}
|
||||
className="mt-2 text-xs font-medium text-primary hover:underline"
|
||||
data-action="forgot-back-to-login"
|
||||
>
|
||||
Back to sign in
|
||||
</button>
|
||||
</div>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthShell>
|
||||
<PasswordResetRequestForm
|
||||
brand={<AuthBrand />}
|
||||
onBack={() => navigate("/login")}
|
||||
onSuccess={(email) => setSentTo(email)}
|
||||
/>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
47
app/routes/login.reset.tsx
Normal file
47
app/routes/login.reset.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useNavigate, useSearchParams } from "react-router"
|
||||
|
||||
import { PasswordResetConfirmForm } from "@crema/arcadia-auth-ui"
|
||||
import { pageTitle } from "~/lib/page-meta"
|
||||
import { AuthBrand, AuthShell } from "~/components/auth/auth-shell"
|
||||
|
||||
export const meta = () => pageTitle("Set new password")
|
||||
|
||||
export default function ResetPasswordRoute() {
|
||||
const [params] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const token = params.get("token") ?? ""
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<AuthShell>
|
||||
<div
|
||||
className="flex w-full max-w-sm flex-col items-center gap-3 rounded-xl border bg-card p-6 text-center text-sm"
|
||||
style={{ borderColor: "var(--border)" }}
|
||||
>
|
||||
<h1 className="text-base font-semibold">Reset link invalid</h1>
|
||||
<p className="text-muted-foreground">
|
||||
No token in the URL. Request a fresh password reset email.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/login/forgot")}
|
||||
className="mt-2 text-xs font-medium text-primary hover:underline"
|
||||
data-action="reset-request-new"
|
||||
>
|
||||
Request a new link
|
||||
</button>
|
||||
</div>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthShell>
|
||||
<PasswordResetConfirmForm
|
||||
brand={<AuthBrand />}
|
||||
token={token}
|
||||
onSuccess={() => navigate("/login?reset=ok", { replace: true })}
|
||||
/>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { LoginForm } from "@crema/arcadia-auth-ui"
|
||||
import { useBrand } from "~/lib/identity"
|
||||
import { pageTitle } from "~/lib/page-meta"
|
||||
import { useSession, persistFromArcadiaLogin } from "~/lib/session"
|
||||
import { AuthBrand, AuthShell } from "~/components/auth/auth-shell"
|
||||
|
||||
export const meta = () => pageTitle("Sign in")
|
||||
|
||||
@@ -13,37 +14,24 @@ export default function LoginRoute() {
|
||||
const [params] = useSearchParams()
|
||||
const session = useSession()
|
||||
const brand = useBrand()
|
||||
const BrandIcon = brand.icon
|
||||
|
||||
const next = params.get("next") || "/"
|
||||
|
||||
// Already signed in? Bounce.
|
||||
useEffect(() => {
|
||||
if (session) navigate(next, { replace: true })
|
||||
}, [session, next, navigate])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative isolate flex min-h-svh items-center justify-center p-4"
|
||||
style={{ background: "var(--background)" }}
|
||||
>
|
||||
<AuthShell>
|
||||
<LoginForm
|
||||
brand={
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="flex size-8 items-center justify-center rounded-lg"
|
||||
style={{ background: "var(--primary)", color: "var(--primary-foreground)" }}
|
||||
>
|
||||
<BrandIcon className="size-4" />
|
||||
</span>
|
||||
<span className="text-sm font-semibold">{brand.name}</span>
|
||||
</div>
|
||||
}
|
||||
brand={<AuthBrand />}
|
||||
heading={`Sign in to ${brand.name}`}
|
||||
subhead="Use your arcadia credentials. In dev seeds: admin@example.com / AdminP@ssw0rd."
|
||||
onSuccess={async ({ tokens, user, twoFactorRequired, twoFactorChallenge }) => {
|
||||
if (twoFactorRequired && twoFactorChallenge) {
|
||||
navigate(`/login/2fa?challenge=${encodeURIComponent(twoFactorChallenge)}&next=${encodeURIComponent(next)}`)
|
||||
navigate(
|
||||
`/login/2fa?challenge=${encodeURIComponent(twoFactorChallenge)}&next=${encodeURIComponent(next)}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
persistFromArcadiaLogin(tokens, user)
|
||||
@@ -52,6 +40,6 @@ export default function LoginRoute() {
|
||||
onForgotPassword={() => navigate("/login/forgot")}
|
||||
onSignup={() => navigate("/signup")}
|
||||
/>
|
||||
</div>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
|
||||
647
app/routes/memberships.tsx
Normal file
647
app/routes/memberships.tsx
Normal file
@@ -0,0 +1,647 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import {
|
||||
CheckCircle2,
|
||||
Network,
|
||||
Pause,
|
||||
Play,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
} 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 {
|
||||
activateMembership,
|
||||
createMembership,
|
||||
deleteMembership,
|
||||
listMemberships,
|
||||
suspendMembership,
|
||||
updateMembership,
|
||||
type Membership,
|
||||
type MembershipStatus,
|
||||
} from "~/lib/arcadia/memberships"
|
||||
import { listUsers, type User } from "~/lib/arcadia/users"
|
||||
import { listRoles, type Role } from "~/lib/arcadia/roles"
|
||||
import { pageTitle } from "~/lib/page-meta"
|
||||
import { useSession } from "~/lib/session"
|
||||
import { useRegisterContext } from "@crema/aifirst-ui/context"
|
||||
|
||||
export const meta = () => pageTitle("Memberships")
|
||||
|
||||
type Editor =
|
||||
| { kind: "create" }
|
||||
| { kind: "edit"; membership: Membership }
|
||||
| null
|
||||
|
||||
export default function MembershipsRoute() {
|
||||
const session = useSession()
|
||||
const arcadia = useArcadiaClient()
|
||||
|
||||
const [memberships, setMemberships] = useState<Membership[]>([])
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [roles, setRoles] = useState<Role[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [info, setInfo] = useState<string | null>(null)
|
||||
const [search, setSearch] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState<"all" | MembershipStatus>("all")
|
||||
const [editor, setEditor] = useState<Editor>(null)
|
||||
const [pendingDelete, setPendingDelete] = useState<Membership | null>(null)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
try {
|
||||
const [m, u, r] = await Promise.all([
|
||||
listMemberships(arcadia),
|
||||
listUsers(arcadia).catch(() => [] as User[]),
|
||||
listRoles(arcadia).catch(() => [] as Role[]),
|
||||
])
|
||||
setMemberships(m)
|
||||
setUsers(u)
|
||||
setRoles(r)
|
||||
} catch (err) {
|
||||
setError(err instanceof ArcadiaError ? err.message : "Failed to load memberships.")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [arcadia])
|
||||
|
||||
useEffect(() => {
|
||||
if (session) refresh()
|
||||
}, [session, refresh])
|
||||
|
||||
const filtered = useMemo(
|
||||
() =>
|
||||
statusFilter === "all"
|
||||
? memberships
|
||||
: memberships.filter((m) => m.status === statusFilter),
|
||||
[memberships, statusFilter],
|
||||
)
|
||||
|
||||
const columns = useMemo<Column<Membership>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "user",
|
||||
header: "User",
|
||||
accessor: (m) => m.user?.email ?? m.user_id,
|
||||
sortable: true,
|
||||
cell: (m) => (
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">{m.user?.email ?? "—"}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{m.user?.first_name || m.user?.last_name
|
||||
? `${m.user?.first_name ?? ""} ${m.user?.last_name ?? ""}`.trim()
|
||||
: m.user_id.slice(0, 8) + "…"}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "tenant",
|
||||
header: "Tenant",
|
||||
accessor: (m) => m.tenant?.name ?? "",
|
||||
sortable: true,
|
||||
cell: (m) =>
|
||||
m.tenant ? (
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">{m.tenant.name}</span>
|
||||
<code className="rounded bg-muted px-1 font-mono text-[10px] text-muted-foreground">
|
||||
{m.tenant.slug}
|
||||
</code>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
accessor: "status",
|
||||
sortable: true,
|
||||
cell: (m) => <BadgeCell label={m.status} tone={statusTone(m.status)} />,
|
||||
},
|
||||
{
|
||||
id: "primary",
|
||||
header: "Primary",
|
||||
accessor: "is_primary",
|
||||
sortable: true,
|
||||
cell: (m) =>
|
||||
m.is_primary ? (
|
||||
<CheckCircle2 className="size-4 text-emerald-500" />
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "roles",
|
||||
header: "Roles",
|
||||
cell: (m) =>
|
||||
m.roles.length === 0 ? (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{m.roles.map((r) => (
|
||||
<Badge key={r.id} variant="secondary" className="font-mono text-xs">
|
||||
{r.slug}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "joined",
|
||||
header: "Joined",
|
||||
accessor: "joined_at",
|
||||
sortable: true,
|
||||
cell: (m) =>
|
||||
m.joined_at ? (
|
||||
<DateCell value={m.joined_at} format="short" />
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
align: "right",
|
||||
cell: (m) => {
|
||||
const items: ActionItem[] = [
|
||||
{
|
||||
id: "edit",
|
||||
label: "Edit",
|
||||
dataAction: `membership-${m.id}-edit`,
|
||||
onSelect: () => setEditor({ kind: "edit", membership: m }),
|
||||
},
|
||||
m.status === "active"
|
||||
? {
|
||||
id: "suspend",
|
||||
label: "Suspend",
|
||||
icon: <Pause className="size-4" />,
|
||||
dataAction: `membership-${m.id}-suspend`,
|
||||
onSelect: async () => {
|
||||
try {
|
||||
await suspendMembership(arcadia, m.id)
|
||||
setInfo("Membership suspended.")
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
setError(err instanceof ArcadiaError ? err.message : "Suspend failed.")
|
||||
}
|
||||
},
|
||||
}
|
||||
: {
|
||||
id: "activate",
|
||||
label: "Activate",
|
||||
icon: <Play className="size-4" />,
|
||||
dataAction: `membership-${m.id}-activate`,
|
||||
onSelect: async () => {
|
||||
try {
|
||||
await activateMembership(arcadia, m.id)
|
||||
setInfo("Membership activated.")
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
setError(err instanceof ArcadiaError ? err.message : "Activate failed.")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "delete",
|
||||
label: "Remove",
|
||||
icon: <Trash2 className="size-4" />,
|
||||
destructive: true,
|
||||
dataAction: `membership-${m.id}-delete`,
|
||||
onSelect: () => setPendingDelete(m),
|
||||
},
|
||||
]
|
||||
return (
|
||||
<ActionsCell items={items} triggerDataAction={`membership-${m.id}-actions`} />
|
||||
)
|
||||
},
|
||||
},
|
||||
],
|
||||
[arcadia, refresh],
|
||||
)
|
||||
|
||||
const summary = useMemo(
|
||||
() => ({
|
||||
total: memberships.length,
|
||||
byStatus: countBy(memberships, (m) => m.status),
|
||||
uniqueTenants: new Set(memberships.map((m) => m.tenant_id)).size,
|
||||
uniqueUsers: new Set(memberships.map((m) => m.user_id)).size,
|
||||
}),
|
||||
[memberships],
|
||||
)
|
||||
useRegisterContext("memberships", summary)
|
||||
|
||||
const table = useTable<Membership>({
|
||||
data: filtered,
|
||||
columns,
|
||||
getRowId: (m) => m.id,
|
||||
initialPageSize: 25,
|
||||
initialSearch: search,
|
||||
})
|
||||
useEffect(() => {
|
||||
table.setSearch(search)
|
||||
}, [search, table])
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="flex flex-col gap-4">
|
||||
<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">Memberships</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Who belongs to which tenant. A user can have memberships in multiple tenants;
|
||||
one is marked primary.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={refresh}
|
||||
disabled={loading}
|
||||
data-action="memberships-refresh"
|
||||
>
|
||||
<RefreshCw className={`size-4 ${loading ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setEditor({ kind: "create" })}
|
||||
data-action="memberships-create"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
Add member
|
||||
</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 user, tenant, or role"
|
||||
data-action="memberships-search"
|
||||
className="max-w-sm flex-1"
|
||||
/>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onValueChange={(v) => setStatusFilter(v as typeof statusFilter)}
|
||||
>
|
||||
<SelectTrigger className="w-40" data-action="memberships-status-filter">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All statuses</SelectItem>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="suspended">Suspended</SelectItem>
|
||||
<SelectItem value="deactivated">Deactivated</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="ml-auto text-xs text-muted-foreground">
|
||||
{table.total} of {memberships.length}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="relative p-0">
|
||||
<LoadingOverlay
|
||||
active={loading && memberships.length === 0}
|
||||
label="Loading memberships…"
|
||||
/>
|
||||
{table.total === 0 && !loading ? (
|
||||
<EmptyState
|
||||
icon={<Network className="size-6" />}
|
||||
title={
|
||||
search || statusFilter !== "all"
|
||||
? "No memberships match those filters."
|
||||
: "No memberships yet."
|
||||
}
|
||||
description={
|
||||
search || statusFilter !== "all"
|
||||
? "Loosen the filter set."
|
||||
: "Add a user to a tenant to create the first membership."
|
||||
}
|
||||
className="py-12"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={table.pageRows}
|
||||
getRowId={(m) => m.id}
|
||||
sort={table.sort}
|
||||
onSortToggle={table.toggleSort}
|
||||
loading={loading && memberships.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="Remove membership?"
|
||||
description={
|
||||
pendingDelete
|
||||
? `${pendingDelete.user?.email ?? pendingDelete.user_id} will lose access to ${pendingDelete.tenant?.name ?? "this tenant"}.`
|
||||
: ""
|
||||
}
|
||||
confirmLabel="Remove"
|
||||
variant="danger"
|
||||
onConfirm={async () => {
|
||||
if (!pendingDelete) return
|
||||
try {
|
||||
await deleteMembership(arcadia, pendingDelete.id)
|
||||
setPendingDelete(null)
|
||||
setInfo("Membership removed.")
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
setError(err instanceof ArcadiaError ? err.message : "Remove failed.")
|
||||
setPendingDelete(null)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<MembershipEditorDialog
|
||||
state={editor}
|
||||
users={users}
|
||||
roles={roles}
|
||||
existingUserIds={new Set(memberships.map((m) => m.user_id))}
|
||||
onClose={() => setEditor(null)}
|
||||
onSaved={async (msg) => {
|
||||
setEditor(null)
|
||||
if (msg) setInfo(msg)
|
||||
await refresh()
|
||||
}}
|
||||
onError={setError}
|
||||
/>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
function statusTone(s: MembershipStatus): BadgeTone {
|
||||
if (s === "active") return "success"
|
||||
if (s === "suspended") return "warning"
|
||||
return "default"
|
||||
}
|
||||
|
||||
function MembershipEditorDialog({
|
||||
state,
|
||||
users,
|
||||
roles,
|
||||
existingUserIds,
|
||||
onClose,
|
||||
onSaved,
|
||||
onError,
|
||||
}: {
|
||||
state: Editor
|
||||
users: User[]
|
||||
roles: Role[]
|
||||
existingUserIds: Set<string>
|
||||
onClose: () => void
|
||||
onSaved: (msg?: string) => Promise<void>
|
||||
onError: (msg: string | null) => void
|
||||
}) {
|
||||
const arcadia = useArcadiaClient()
|
||||
const open = state !== null
|
||||
const isEdit = state?.kind === "edit"
|
||||
const initial = isEdit ? state.membership : null
|
||||
|
||||
const [userId, setUserId] = useState("")
|
||||
const [status, setStatus] = useState<MembershipStatus>("active")
|
||||
const [selectedRoles, setSelectedRoles] = useState<Set<string>>(new Set())
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
if (initial) {
|
||||
setUserId(initial.user_id)
|
||||
setStatus(initial.status)
|
||||
setSelectedRoles(new Set(initial.roles.map((r) => r.id)))
|
||||
} else {
|
||||
setUserId("")
|
||||
setStatus("active")
|
||||
setSelectedRoles(new Set())
|
||||
}
|
||||
}, [open, initial])
|
||||
|
||||
const eligibleUsers = useMemo(
|
||||
() => (isEdit ? users : users.filter((u) => !existingUserIds.has(u.id))),
|
||||
[users, existingUserIds, isEdit],
|
||||
)
|
||||
|
||||
const submit = async () => {
|
||||
onError(null)
|
||||
setSaving(true)
|
||||
try {
|
||||
const input = {
|
||||
user_id: userId,
|
||||
status,
|
||||
role_ids: Array.from(selectedRoles),
|
||||
}
|
||||
if (isEdit && initial) {
|
||||
await updateMembership(arcadia, initial.id, input)
|
||||
await onSaved("Membership updated.")
|
||||
} else {
|
||||
await createMembership(arcadia, input)
|
||||
await onSaved("Member added.")
|
||||
}
|
||||
} 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-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? "Edit membership" : "Add member"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit
|
||||
? "Update status and role assignments."
|
||||
: "Pick a user and assign roles within the current tenant."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>User</Label>
|
||||
<Select value={userId} onValueChange={setUserId} disabled={isEdit}>
|
||||
<SelectTrigger data-action="membership-form-user">
|
||||
<SelectValue placeholder="Pick a user" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{eligibleUsers.length === 0 ? (
|
||||
<SelectItem value="__none" disabled>
|
||||
No eligible users
|
||||
</SelectItem>
|
||||
) : (
|
||||
eligibleUsers.map((u) => (
|
||||
<SelectItem key={u.id} value={u.id}>
|
||||
{u.email}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Status</Label>
|
||||
<Select
|
||||
value={status}
|
||||
onValueChange={(v) => setStatus(v as MembershipStatus)}
|
||||
>
|
||||
<SelectTrigger data-action="membership-form-status">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="suspended">Suspended</SelectItem>
|
||||
<SelectItem value="deactivated">Deactivated</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Roles</Label>
|
||||
{roles.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No roles defined. Create some on the Users tab.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5 rounded-md border p-2">
|
||||
{roles.map((r) => {
|
||||
const active = selectedRoles.has(r.id)
|
||||
return (
|
||||
<button
|
||||
key={r.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedRoles((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(r.id)) next.delete(r.id)
|
||||
else next.add(r.id)
|
||||
return next
|
||||
})
|
||||
}}
|
||||
data-action={`membership-form-role-${r.slug}`}
|
||||
className={[
|
||||
"rounded-full border px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
active
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border text-muted-foreground hover:bg-accent",
|
||||
].join(" ")}
|
||||
>
|
||||
{r.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={submit}
|
||||
disabled={saving || !userId}
|
||||
data-action="membership-form-save"
|
||||
>
|
||||
{saving ? <RefreshCw className="size-4 animate-spin" /> : <CheckCircle2 className="size-4" />}
|
||||
{isEdit ? "Save" : "Add"}
|
||||
</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
|
||||
}, {})
|
||||
}
|
||||
|
||||
// File-local alias just to keep the Editor type narrowable inside the dialog.
|
||||
type Editor =
|
||||
| { kind: "create" }
|
||||
| { kind: "edit"; membership: Membership }
|
||||
| null
|
||||
1212
app/routes/monitoring.tsx
Normal file
1212
app/routes/monitoring.tsx
Normal file
File diff suppressed because it is too large
Load Diff
837
app/routes/networking.tsx
Normal file
837
app/routes/networking.tsx
Normal file
@@ -0,0 +1,837 @@
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import {
|
||||
CheckCircle2,
|
||||
Globe,
|
||||
Network,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Shield,
|
||||
Trash2,
|
||||
Wifi,
|
||||
} from "lucide-react"
|
||||
|
||||
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
|
||||
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 { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"
|
||||
import {
|
||||
assignFloatingIp,
|
||||
createDnsRecord,
|
||||
deleteDnsRecord,
|
||||
deleteFirewall,
|
||||
listDnsRecords,
|
||||
listDomains,
|
||||
listFirewalls,
|
||||
listFloatingIps,
|
||||
listVpcs,
|
||||
unassignFloatingIp,
|
||||
type DnsRecord,
|
||||
type Domain,
|
||||
type Firewall,
|
||||
type FloatingIp,
|
||||
type Vpc,
|
||||
} from "~/lib/arcadia/networking"
|
||||
import { listDroplets, type Droplet } from "~/lib/arcadia/monitoring"
|
||||
import { pageTitle } from "~/lib/page-meta"
|
||||
import { useSession } from "~/lib/session"
|
||||
import { useRegisterContext } from "@crema/aifirst-ui/context"
|
||||
|
||||
export const meta = () => pageTitle("Networking")
|
||||
|
||||
const DNS_TYPES = ["A", "AAAA", "CNAME", "MX", "TXT", "NS", "SRV", "CAA"]
|
||||
|
||||
export default function NetworkingRoute() {
|
||||
const session = useSession()
|
||||
const arcadia = useArcadiaClient()
|
||||
|
||||
const [firewalls, setFirewalls] = useState<Firewall[]>([])
|
||||
const [vpcs, setVpcs] = useState<Vpc[]>([])
|
||||
const [domains, setDomains] = useState<Domain[]>([])
|
||||
const [floatingIps, setFloatingIps] = useState<FloatingIp[]>([])
|
||||
const [droplets, setDroplets] = useState<Droplet[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [info, setInfo] = useState<string | null>(null)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
try {
|
||||
const [f, v, d, fi, dr] = await Promise.all([
|
||||
listFirewalls(arcadia),
|
||||
listVpcs(arcadia),
|
||||
listDomains(arcadia),
|
||||
listFloatingIps(arcadia),
|
||||
listDroplets(arcadia),
|
||||
])
|
||||
setFirewalls(f)
|
||||
setVpcs(v)
|
||||
setDomains(d)
|
||||
setFloatingIps(fi)
|
||||
setDroplets(dr)
|
||||
} catch (err) {
|
||||
setError(err instanceof ArcadiaError ? err.message : "Failed to load networking.")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [arcadia])
|
||||
|
||||
useEffect(() => {
|
||||
if (session) refresh()
|
||||
}, [session, refresh])
|
||||
|
||||
useRegisterContext("networking", {
|
||||
firewalls: firewalls.length,
|
||||
vpcs: vpcs.length,
|
||||
domains: domains.length,
|
||||
floating_ips: floatingIps.length,
|
||||
droplets: droplets.length,
|
||||
})
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="flex flex-col gap-4">
|
||||
<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">Networking</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Firewalls, VPCs, DNS, and floating IPs on the platform's underlying provider.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={refresh}
|
||||
disabled={loading}
|
||||
data-action="networking-refresh"
|
||||
>
|
||||
<RefreshCw className={`size-4 ${loading ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
{error ? (
|
||||
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
|
||||
{error}
|
||||
</AlertBanner>
|
||||
) : null}
|
||||
{info ? (
|
||||
<AlertBanner variant="success" dismissible onDismiss={() => setInfo(null)}>
|
||||
{info}
|
||||
</AlertBanner>
|
||||
) : null}
|
||||
|
||||
<Tabs defaultValue="firewalls">
|
||||
<TabsList>
|
||||
<TabsTrigger value="firewalls" data-action="networking-tab-firewalls">
|
||||
Firewalls ({firewalls.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="vpcs" data-action="networking-tab-vpcs">
|
||||
VPCs ({vpcs.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="domains" data-action="networking-tab-domains">
|
||||
DNS ({domains.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="floating-ips" data-action="networking-tab-floating-ips">
|
||||
Floating IPs ({floatingIps.length})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="firewalls" className="pt-4">
|
||||
<FirewallsPanel
|
||||
firewalls={firewalls}
|
||||
loading={loading}
|
||||
onChanged={refresh}
|
||||
onError={setError}
|
||||
onInfo={setInfo}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="vpcs" className="pt-4">
|
||||
<VpcsPanel vpcs={vpcs} loading={loading} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="domains" className="pt-4">
|
||||
<DomainsPanel
|
||||
domains={domains}
|
||||
loading={loading}
|
||||
onError={setError}
|
||||
onInfo={setInfo}
|
||||
onChanged={refresh}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="floating-ips" className="pt-4">
|
||||
<FloatingIpsPanel
|
||||
ips={floatingIps}
|
||||
droplets={droplets}
|
||||
loading={loading}
|
||||
onChanged={refresh}
|
||||
onError={setError}
|
||||
onInfo={setInfo}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Firewalls panel ---------------------------------------------------
|
||||
|
||||
function FirewallsPanel({
|
||||
firewalls,
|
||||
loading,
|
||||
onChanged,
|
||||
onError,
|
||||
onInfo,
|
||||
}: {
|
||||
firewalls: Firewall[]
|
||||
loading: boolean
|
||||
onChanged: () => Promise<void>
|
||||
onError: (msg: string | null) => void
|
||||
onInfo: (msg: string | null) => void
|
||||
}) {
|
||||
const arcadia = useArcadiaClient()
|
||||
const [pendingDelete, setPendingDelete] = useState<Firewall | null>(null)
|
||||
|
||||
if (loading && firewalls.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="relative py-8">
|
||||
<LoadingOverlay active label="Loading firewalls…" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (firewalls.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<EmptyState
|
||||
icon={<Shield className="size-6" />}
|
||||
title="No firewalls."
|
||||
description="Create a firewall on your provider, or configure DigitalOcean access in arcadia's .env to see existing ones."
|
||||
className="py-8"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ul className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||
{firewalls.map((f) => (
|
||||
<Card key={String(f.id)}>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="size-4 text-muted-foreground" />
|
||||
<CardTitle className="text-base">{f.name}</CardTitle>
|
||||
{f.status ? <Badge variant="secondary">{f.status}</Badge> : null}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setPendingDelete(f)}
|
||||
data-action={`firewall-${f.id}-delete`}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="text-xs text-muted-foreground">
|
||||
Inbound rules: {f.inbound_rules?.length ?? 0} · Outbound rules:{" "}
|
||||
{f.outbound_rules?.length ?? 0} · Droplets attached:{" "}
|
||||
{f.droplet_ids?.length ?? 0}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<ConfirmDialog
|
||||
open={pendingDelete !== null}
|
||||
onOpenChange={(o) => !o && setPendingDelete(null)}
|
||||
title="Delete firewall?"
|
||||
description={
|
||||
pendingDelete
|
||||
? `${pendingDelete.name} will be removed. Attached droplets lose this rule set.`
|
||||
: ""
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
variant="danger"
|
||||
onConfirm={async () => {
|
||||
if (!pendingDelete) return
|
||||
try {
|
||||
await deleteFirewall(arcadia, pendingDelete.id)
|
||||
setPendingDelete(null)
|
||||
onInfo("Firewall deleted.")
|
||||
await onChanged()
|
||||
} catch (err) {
|
||||
onError(err instanceof ArcadiaError ? err.message : "Delete failed.")
|
||||
setPendingDelete(null)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// --- VPCs panel --------------------------------------------------------
|
||||
|
||||
function VpcsPanel({ vpcs, loading }: { vpcs: Vpc[]; loading: boolean }) {
|
||||
if (loading && vpcs.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="relative py-8">
|
||||
<LoadingOverlay active label="Loading VPCs…" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
if (vpcs.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<EmptyState
|
||||
icon={<Network className="size-6" />}
|
||||
title="No VPCs."
|
||||
description="Read-only view; create VPCs on your provider directly."
|
||||
className="py-8"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<ul className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||
{vpcs.map((v) => (
|
||||
<Card key={v.id}>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Network className="size-4 text-muted-foreground" />
|
||||
<CardTitle className="text-base">{v.name}</CardTitle>
|
||||
{v.default ? <Badge>default</Badge> : null}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="text-xs text-muted-foreground">
|
||||
<div>
|
||||
Region: <code className="font-mono">{v.region ?? "—"}</code>
|
||||
</div>
|
||||
<div>
|
||||
IP range: <code className="font-mono">{v.ip_range ?? "—"}</code>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Domains + DNS records panel ---------------------------------------
|
||||
|
||||
function DomainsPanel({
|
||||
domains,
|
||||
loading,
|
||||
onError,
|
||||
onInfo,
|
||||
onChanged,
|
||||
}: {
|
||||
domains: Domain[]
|
||||
loading: boolean
|
||||
onError: (msg: string | null) => void
|
||||
onInfo: (msg: string | null) => void
|
||||
onChanged: () => Promise<void>
|
||||
}) {
|
||||
const arcadia = useArcadiaClient()
|
||||
const [selectedName, setSelectedName] = useState<string>(() => domains[0]?.name ?? "")
|
||||
const [records, setRecords] = useState<DnsRecord[]>([])
|
||||
const [loadingRecords, setLoadingRecords] = useState(false)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [pendingDelete, setPendingDelete] = useState<DnsRecord | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedName && domains.length > 0) setSelectedName(domains[0].name)
|
||||
}, [domains, selectedName])
|
||||
|
||||
const loadRecords = useCallback(
|
||||
async (name: string) => {
|
||||
if (!name) {
|
||||
setRecords([])
|
||||
return
|
||||
}
|
||||
setLoadingRecords(true)
|
||||
try {
|
||||
setRecords(await listDnsRecords(arcadia, name))
|
||||
} catch (err) {
|
||||
onError(err instanceof ArcadiaError ? err.message : "Failed to load DNS records.")
|
||||
} finally {
|
||||
setLoadingRecords(false)
|
||||
}
|
||||
},
|
||||
[arcadia, onError],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
loadRecords(selectedName)
|
||||
}, [selectedName, loadRecords])
|
||||
|
||||
if (loading && domains.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="relative py-8">
|
||||
<LoadingOverlay active label="Loading domains…" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (domains.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<EmptyState
|
||||
icon={<Globe className="size-6" />}
|
||||
title="No domains."
|
||||
description="Add a domain on your provider; arcadia surfaces it here for record management."
|
||||
className="py-8"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row flex-wrap items-end gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="dns-domain" className="text-xs">
|
||||
Domain
|
||||
</Label>
|
||||
<Select value={selectedName} onValueChange={setSelectedName}>
|
||||
<SelectTrigger id="dns-domain" className="w-64" data-action="dns-domain-select">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{domains.map((d) => (
|
||||
<SelectItem key={d.name} value={d.name}>
|
||||
{d.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="ml-auto flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => loadRecords(selectedName)}
|
||||
disabled={loadingRecords}
|
||||
data-action="dns-refresh"
|
||||
>
|
||||
<RefreshCw className={`size-4 ${loadingRecords ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
disabled={!selectedName}
|
||||
data-action="dns-create"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
New record
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{records.length === 0 && !loadingRecords ? (
|
||||
<EmptyState
|
||||
icon={<Globe className="size-6" />}
|
||||
title="No records on this domain."
|
||||
className="py-8"
|
||||
/>
|
||||
) : (
|
||||
<ul className="divide-y border-y">
|
||||
{records.map((r) => (
|
||||
<li key={String(r.id)} className="flex items-center justify-between gap-3 px-3 py-2 text-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant="secondary" className="font-mono text-xs">
|
||||
{r.type}
|
||||
</Badge>
|
||||
<span className="font-mono text-xs">{r.name}</span>
|
||||
<span className="text-xs text-muted-foreground">→</span>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">
|
||||
{r.data}
|
||||
</code>
|
||||
{r.ttl ? (
|
||||
<span className="text-[11px] text-muted-foreground">TTL {r.ttl}s</span>
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setPendingDelete(r)}
|
||||
data-action={`dns-record-${r.id}-delete`}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<DnsCreateDialog
|
||||
open={createOpen}
|
||||
domainName={selectedName}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onCreated={async () => {
|
||||
setCreateOpen(false)
|
||||
onInfo("DNS record created.")
|
||||
await loadRecords(selectedName)
|
||||
await onChanged()
|
||||
}}
|
||||
onError={onError}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={pendingDelete !== null}
|
||||
onOpenChange={(o) => !o && setPendingDelete(null)}
|
||||
title="Delete DNS record?"
|
||||
description={
|
||||
pendingDelete
|
||||
? `${pendingDelete.type} ${pendingDelete.name} → ${pendingDelete.data}. This is destructive and may break traffic.`
|
||||
: ""
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
variant="danger"
|
||||
onConfirm={async () => {
|
||||
if (!pendingDelete) return
|
||||
try {
|
||||
await deleteDnsRecord(arcadia, selectedName, pendingDelete.id)
|
||||
setPendingDelete(null)
|
||||
onInfo("Record deleted.")
|
||||
await loadRecords(selectedName)
|
||||
} catch (err) {
|
||||
onError(err instanceof ArcadiaError ? err.message : "Delete failed.")
|
||||
setPendingDelete(null)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function DnsCreateDialog({
|
||||
open,
|
||||
domainName,
|
||||
onClose,
|
||||
onCreated,
|
||||
onError,
|
||||
}: {
|
||||
open: boolean
|
||||
domainName: string
|
||||
onClose: () => void
|
||||
onCreated: () => Promise<void>
|
||||
onError: (msg: string | null) => void
|
||||
}) {
|
||||
const arcadia = useArcadiaClient()
|
||||
const [type, setType] = useState("A")
|
||||
const [name, setName] = useState("@")
|
||||
const [data, setData] = useState("")
|
||||
const [ttl, setTtl] = useState("3600")
|
||||
const [priority, setPriority] = useState("")
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setType("A")
|
||||
setName("@")
|
||||
setData("")
|
||||
setTtl("3600")
|
||||
setPriority("")
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const submit = async () => {
|
||||
onError(null)
|
||||
setSaving(true)
|
||||
try {
|
||||
await createDnsRecord(arcadia, domainName, {
|
||||
type,
|
||||
name,
|
||||
data,
|
||||
ttl: ttl ? Number(ttl) : undefined,
|
||||
priority: priority ? Number(priority) : undefined,
|
||||
})
|
||||
await onCreated()
|
||||
} catch (err) {
|
||||
onError(err instanceof ArcadiaError ? err.message : "Create failed.")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New DNS record</DialogTitle>
|
||||
<DialogDescription>
|
||||
On <code className="font-mono">{domainName}</code>.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Type</Label>
|
||||
<Select value={type} onValueChange={setType}>
|
||||
<SelectTrigger data-action="dns-form-type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DNS_TYPES.map((t) => (
|
||||
<SelectItem key={t} value={t}>
|
||||
{t}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="dns-name">Name</Label>
|
||||
<Input
|
||||
id="dns-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="@ or sub"
|
||||
data-action="dns-form-name"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 flex flex-col gap-1.5">
|
||||
<Label htmlFor="dns-data">Data</Label>
|
||||
<Input
|
||||
id="dns-data"
|
||||
value={data}
|
||||
onChange={(e) => setData(e.target.value)}
|
||||
placeholder={
|
||||
type === "A"
|
||||
? "1.2.3.4"
|
||||
: type === "CNAME"
|
||||
? "target.example.com."
|
||||
: type === "TXT"
|
||||
? '"verification=..."'
|
||||
: "value"
|
||||
}
|
||||
className="font-mono"
|
||||
data-action="dns-form-data"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="dns-ttl">TTL (seconds)</Label>
|
||||
<Input
|
||||
id="dns-ttl"
|
||||
type="number"
|
||||
min={30}
|
||||
value={ttl}
|
||||
onChange={(e) => setTtl(e.target.value)}
|
||||
data-action="dns-form-ttl"
|
||||
/>
|
||||
</div>
|
||||
{type === "MX" || type === "SRV" ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="dns-priority">Priority</Label>
|
||||
<Input
|
||||
id="dns-priority"
|
||||
type="number"
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value)}
|
||||
data-action="dns-form-priority"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={saving || !data} data-action="dns-form-save">
|
||||
{saving ? <RefreshCw className="size-4 animate-spin" /> : <CheckCircle2 className="size-4" />}
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Floating IPs panel ------------------------------------------------
|
||||
|
||||
function FloatingIpsPanel({
|
||||
ips,
|
||||
droplets,
|
||||
loading,
|
||||
onChanged,
|
||||
onError,
|
||||
onInfo,
|
||||
}: {
|
||||
ips: FloatingIp[]
|
||||
droplets: Droplet[]
|
||||
loading: boolean
|
||||
onChanged: () => Promise<void>
|
||||
onError: (msg: string | null) => void
|
||||
onInfo: (msg: string | null) => void
|
||||
}) {
|
||||
const arcadia = useArcadiaClient()
|
||||
const [assigning, setAssigning] = useState<{ ip: string; dropletId: string } | null>(null)
|
||||
|
||||
if (loading && ips.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="relative py-8">
|
||||
<LoadingOverlay active label="Loading floating IPs…" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
if (ips.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<EmptyState
|
||||
icon={<Wifi className="size-6" />}
|
||||
title="No floating IPs."
|
||||
description="Reserve a floating IP on your provider to surface it here."
|
||||
className="py-8"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y border-y">
|
||||
{ips.map((ip) => {
|
||||
const region =
|
||||
typeof ip.region === "string" ? ip.region : ip.region?.slug ?? "—"
|
||||
return (
|
||||
<li key={ip.ip} className="flex items-center justify-between gap-3 px-3 py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Wifi className="size-4 text-muted-foreground" />
|
||||
<code className="font-mono text-sm">{ip.ip}</code>
|
||||
<span className="text-xs text-muted-foreground">{region}</span>
|
||||
{ip.droplet ? (
|
||||
<Badge variant="secondary">→ {ip.droplet.name ?? ip.droplet.id}</Badge>
|
||||
) : (
|
||||
<Badge>unassigned</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{ip.droplet ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await unassignFloatingIp(arcadia, ip.ip)
|
||||
onInfo("Floating IP unassigned.")
|
||||
await onChanged()
|
||||
} catch (err) {
|
||||
onError(
|
||||
err instanceof ArcadiaError ? err.message : "Unassign failed.",
|
||||
)
|
||||
}
|
||||
}}
|
||||
data-action={`fip-${ip.ip}-unassign`}
|
||||
>
|
||||
Unassign
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Select
|
||||
value={assigning?.ip === ip.ip ? assigning.dropletId : ""}
|
||||
onValueChange={(v) => setAssigning({ ip: ip.ip, dropletId: v })}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="h-8 w-44"
|
||||
data-action={`fip-${ip.ip}-droplet-select`}
|
||||
>
|
||||
<SelectValue placeholder="Pick droplet" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{droplets.length === 0 ? (
|
||||
<SelectItem value="__none" disabled>
|
||||
No droplets
|
||||
</SelectItem>
|
||||
) : (
|
||||
droplets.map((d) => (
|
||||
<SelectItem key={String(d.id)} value={String(d.id)}>
|
||||
{d.name}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={
|
||||
!assigning || assigning.ip !== ip.ip || !assigning.dropletId
|
||||
}
|
||||
onClick={async () => {
|
||||
if (!assigning || assigning.ip !== ip.ip) return
|
||||
try {
|
||||
await assignFloatingIp(arcadia, ip.ip, assigning.dropletId)
|
||||
setAssigning(null)
|
||||
onInfo("Floating IP assigned.")
|
||||
await onChanged()
|
||||
} catch (err) {
|
||||
onError(
|
||||
err instanceof ArcadiaError ? err.message : "Assign failed.",
|
||||
)
|
||||
}
|
||||
}}
|
||||
data-action={`fip-${ip.ip}-assign`}
|
||||
>
|
||||
Assign
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
885
app/routes/organizations.tsx
Normal file
885
app/routes/organizations.tsx
Normal file
@@ -0,0 +1,885 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import {
|
||||
Building,
|
||||
Crown,
|
||||
Mail,
|
||||
RefreshCw,
|
||||
Settings as SettingsIcon,
|
||||
Trash2,
|
||||
UserCog,
|
||||
UserPlus,
|
||||
Users as UsersIcon,
|
||||
} 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 {
|
||||
addRestrictedMember,
|
||||
changeMemberRole,
|
||||
inviteMember,
|
||||
listAllOrganizations,
|
||||
listMembers,
|
||||
removeMember,
|
||||
transferOwnership,
|
||||
updateOrganization,
|
||||
type OnOwnerRemoval,
|
||||
type OrgMembership,
|
||||
type OrgRole,
|
||||
type OrgStatus,
|
||||
type Organization,
|
||||
} from "~/lib/arcadia/organizations"
|
||||
import { pageTitle } from "~/lib/page-meta"
|
||||
import { useSession } from "~/lib/session"
|
||||
|
||||
export const meta = () => pageTitle("Organizations")
|
||||
|
||||
type MembersDialogState = { org: Organization } | null
|
||||
type SettingsDialogState = { org: Organization } | null
|
||||
|
||||
const ON_OWNER_REMOVAL_LABEL: Record<OnOwnerRemoval, string> = {
|
||||
delete: "Delete org",
|
||||
require_transfer: "Require transfer",
|
||||
freeze_until_new_owner: "Freeze until new owner",
|
||||
}
|
||||
|
||||
export default function OrganizationsRoute() {
|
||||
const session = useSession()
|
||||
const arcadia = useArcadiaClient()
|
||||
|
||||
const [orgs, setOrgs] = useState<Organization[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [info, setInfo] = useState<string | null>(null)
|
||||
const [search, setSearch] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState<"all" | OrgStatus>("all")
|
||||
|
||||
const [membersDialog, setMembersDialog] = useState<MembersDialogState>(null)
|
||||
const [settingsDialog, setSettingsDialog] = useState<SettingsDialogState>(null)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
try {
|
||||
setOrgs(await listAllOrganizations(arcadia))
|
||||
} catch (err) {
|
||||
setError(err instanceof ArcadiaError ? err.message : "Failed to load organizations.")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [arcadia])
|
||||
|
||||
useEffect(() => {
|
||||
if (session) refresh()
|
||||
}, [session, refresh])
|
||||
|
||||
const filtered = useMemo(
|
||||
() => (statusFilter === "all" ? orgs : orgs.filter((o) => o.status === statusFilter)),
|
||||
[orgs, statusFilter],
|
||||
)
|
||||
|
||||
const columns = useMemo<Column<Organization>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "name",
|
||||
header: "Organization",
|
||||
accessor: "name",
|
||||
sortable: true,
|
||||
cell: (o) => (
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">{o.name}</span>
|
||||
<code className="rounded bg-muted px-1 font-mono text-[10px] text-muted-foreground">
|
||||
{o.slug}
|
||||
</code>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
accessor: "status",
|
||||
sortable: true,
|
||||
cell: (o) => <BadgeCell label={o.status} tone={statusTone(o.status)} />,
|
||||
},
|
||||
{
|
||||
id: "on_owner_removal",
|
||||
header: "Owner-removal policy",
|
||||
accessor: "on_owner_removal",
|
||||
sortable: true,
|
||||
cell: (o) => (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{ON_OWNER_REMOVAL_LABEL[o.on_owner_removal] ?? o.on_owner_removal}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "updated_at",
|
||||
header: "Updated",
|
||||
accessor: "updated_at",
|
||||
sortable: true,
|
||||
cell: (o) => <DateCell value={o.updated_at} format="short" />,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
align: "right",
|
||||
cell: (o) => {
|
||||
const items: ActionItem[] = [
|
||||
{
|
||||
id: "members",
|
||||
label: "Manage members",
|
||||
icon: <UsersIcon className="size-4" />,
|
||||
dataAction: `org-${o.id}-members`,
|
||||
onSelect: () => setMembersDialog({ org: o }),
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
label: "Settings",
|
||||
icon: <SettingsIcon className="size-4" />,
|
||||
dataAction: `org-${o.id}-settings`,
|
||||
onSelect: () => setSettingsDialog({ org: o }),
|
||||
},
|
||||
]
|
||||
return <ActionsCell items={items} />
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const table = useTable<Organization>({
|
||||
data: filtered,
|
||||
columns,
|
||||
getRowId: (o) => o.id,
|
||||
initialPageSize: 25,
|
||||
initialSearch: search,
|
||||
})
|
||||
useEffect(() => {
|
||||
table.setSearch(search)
|
||||
}, [search, table])
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="flex flex-col gap-4">
|
||||
<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">Organizations</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
End-user workspaces inside this tenant. Each one is owned by a regular user; admins
|
||||
here can manage members, change ownership policy, or freeze a workspace.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={refresh}
|
||||
disabled={loading}
|
||||
data-action="organizations-refresh"
|
||||
>
|
||||
<RefreshCw className={`size-4 ${loading ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
</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 or slug"
|
||||
data-action="organizations-search"
|
||||
className="max-w-sm flex-1"
|
||||
/>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onValueChange={(v) => setStatusFilter(v as typeof statusFilter)}
|
||||
>
|
||||
<SelectTrigger className="w-44" data-action="organizations-status-filter">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All statuses</SelectItem>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="frozen">Frozen</SelectItem>
|
||||
<SelectItem value="pending_deletion">Pending deletion</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="ml-auto text-xs text-muted-foreground">
|
||||
{table.total} of {orgs.length}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="relative p-0">
|
||||
<LoadingOverlay
|
||||
active={loading && orgs.length === 0}
|
||||
label="Loading organizations…"
|
||||
/>
|
||||
{table.total === 0 && !loading ? (
|
||||
<EmptyState
|
||||
icon={<Building className="size-6" />}
|
||||
title={
|
||||
search || statusFilter !== "all"
|
||||
? "No organizations match those filters."
|
||||
: "No organizations yet."
|
||||
}
|
||||
description={
|
||||
search || statusFilter !== "all"
|
||||
? "Loosen the filter set."
|
||||
: "End-users create these from inside the app; nothing to do here yet."
|
||||
}
|
||||
className="py-12"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={table.pageRows}
|
||||
getRowId={(o) => o.id}
|
||||
sort={table.sort}
|
||||
onSortToggle={table.toggleSort}
|
||||
loading={loading && orgs.length > 0}
|
||||
stickyHeader
|
||||
/>
|
||||
<Pagination
|
||||
page={table.page}
|
||||
pageSize={table.pageSize}
|
||||
total={table.total}
|
||||
onPageChange={table.setPage}
|
||||
onPageSizeChange={table.setPageSize}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<MembersDialog
|
||||
state={membersDialog}
|
||||
onClose={() => setMembersDialog(null)}
|
||||
onInfo={setInfo}
|
||||
onError={setError}
|
||||
/>
|
||||
<SettingsDialog
|
||||
state={settingsDialog}
|
||||
onClose={() => setSettingsDialog(null)}
|
||||
onSaved={async (msg) => {
|
||||
setSettingsDialog(null)
|
||||
if (msg) setInfo(msg)
|
||||
await refresh()
|
||||
}}
|
||||
onError={setError}
|
||||
/>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
function statusTone(s: OrgStatus): BadgeTone {
|
||||
if (s === "active") return "success"
|
||||
if (s === "frozen") return "warning"
|
||||
if (s === "pending_deletion") return "danger"
|
||||
return "default"
|
||||
}
|
||||
|
||||
function roleBadgeVariant(r: OrgRole): "default" | "secondary" | "destructive" | "outline" {
|
||||
if (r === "owner") return "default"
|
||||
if (r === "admin") return "secondary"
|
||||
return "outline"
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Members dialog
|
||||
// ============================================================================
|
||||
|
||||
type InvitePane = "none" | "invite_existing" | "add_restricted"
|
||||
|
||||
function MembersDialog({
|
||||
state,
|
||||
onClose,
|
||||
onInfo,
|
||||
onError,
|
||||
}: {
|
||||
state: MembersDialogState
|
||||
onClose: () => void
|
||||
onInfo: (msg: string | null) => void
|
||||
onError: (msg: string | null) => void
|
||||
}) {
|
||||
const arcadia = useArcadiaClient()
|
||||
const open = state !== null
|
||||
const org = state?.org
|
||||
|
||||
const [members, setMembers] = useState<OrgMembership[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [pendingRemove, setPendingRemove] = useState<OrgMembership | null>(null)
|
||||
const [transferTarget, setTransferTarget] = useState<OrgMembership | null>(null)
|
||||
const [pane, setPane] = useState<InvitePane>("none")
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!org) return
|
||||
setLoading(true)
|
||||
try {
|
||||
setMembers(await listMembers(arcadia, org.id))
|
||||
} catch (err) {
|
||||
onError(err instanceof ArcadiaError ? err.message : "Failed to load members.")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [arcadia, org, onError])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) refresh()
|
||||
}, [open, refresh])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{org ? `Members — ${org.name}` : "Members"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{org
|
||||
? `Manage who can act inside ${org.name}. The owner can be changed via transfer; one active owner at a time.`
|
||||
: ""}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{pane === "none" ? (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPane("invite_existing")}
|
||||
data-action={`org-${org?.id}-invite-existing`}
|
||||
>
|
||||
<Mail className="size-4" />
|
||||
Invite by email
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setPane("add_restricted")}
|
||||
data-action={`org-${org?.id}-add-restricted`}
|
||||
>
|
||||
<UserPlus className="size-4" />
|
||||
Add restricted user
|
||||
</Button>
|
||||
</div>
|
||||
) : pane === "invite_existing" ? (
|
||||
<InviteByEmailForm
|
||||
orgId={org!.id}
|
||||
onCancel={() => setPane("none")}
|
||||
onSaved={async (msg) => {
|
||||
setPane("none")
|
||||
onInfo(msg)
|
||||
await refresh()
|
||||
}}
|
||||
onError={onError}
|
||||
/>
|
||||
) : (
|
||||
<AddRestrictedForm
|
||||
orgId={org!.id}
|
||||
onCancel={() => setPane("none")}
|
||||
onSaved={async (msg) => {
|
||||
setPane("none")
|
||||
onInfo(msg)
|
||||
await refresh()
|
||||
}}
|
||||
onError={onError}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
<LoadingOverlay active={loading && members.length === 0} label="Loading members…" />
|
||||
{members.length === 0 && !loading ? (
|
||||
<EmptyState
|
||||
icon={<UsersIcon className="size-6" />}
|
||||
title="No members yet."
|
||||
description="Invite someone or add a restricted sub-user to get started."
|
||||
className="py-8"
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-md border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/40 text-left text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-2 font-medium">User</th>
|
||||
<th className="px-3 py-2 font-medium">Role</th>
|
||||
<th className="px-3 py-2 font-medium">Status</th>
|
||||
<th className="px-3 py-2 font-medium">Joined</th>
|
||||
<th className="px-3 py-2 text-right font-medium" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{members.map((m) => (
|
||||
<tr key={m.id} className="border-t border-border">
|
||||
<td className="px-3 py-2 font-mono text-xs">{m.user_id.slice(0, 8)}…</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge variant={roleBadgeVariant(m.role)}>{m.role}</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge variant="secondary">{m.status}</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">
|
||||
{m.joined_at ? new Date(m.joined_at).toLocaleDateString() : "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<MemberRowActions
|
||||
member={m}
|
||||
orgId={org!.id}
|
||||
onTransfer={() => setTransferTarget(m)}
|
||||
onRemove={() => setPendingRemove(m)}
|
||||
onRoleChanged={async (msg) => {
|
||||
onInfo(msg)
|
||||
await refresh()
|
||||
}}
|
||||
onError={onError}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} data-action={`org-${org?.id}-members-close`}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
<ConfirmDialog
|
||||
open={pendingRemove !== null}
|
||||
onOpenChange={(o) => !o && setPendingRemove(null)}
|
||||
title="Remove member?"
|
||||
description={
|
||||
pendingRemove
|
||||
? pendingRemove.role === "owner"
|
||||
? "This member is the owner. Removal will follow the org's owner-removal policy."
|
||||
: "They will lose access to this organization."
|
||||
: ""
|
||||
}
|
||||
confirmLabel="Remove"
|
||||
variant="danger"
|
||||
onConfirm={async () => {
|
||||
if (!pendingRemove || !org) return
|
||||
try {
|
||||
await removeMember(arcadia, org.id, pendingRemove.user_id)
|
||||
setPendingRemove(null)
|
||||
onInfo("Member removed.")
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
onError(err instanceof ArcadiaError ? err.message : "Remove failed.")
|
||||
setPendingRemove(null)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={transferTarget !== null}
|
||||
onOpenChange={(o) => !o && setTransferTarget(null)}
|
||||
title="Transfer ownership?"
|
||||
description={
|
||||
transferTarget
|
||||
? `The current owner will be demoted to admin. ${transferTarget.user_id.slice(0, 8)}… will become owner.`
|
||||
: ""
|
||||
}
|
||||
confirmLabel="Transfer"
|
||||
variant="default"
|
||||
onConfirm={async () => {
|
||||
if (!transferTarget || !org) return
|
||||
try {
|
||||
await transferOwnership(arcadia, org.id, transferTarget.user_id)
|
||||
setTransferTarget(null)
|
||||
onInfo("Ownership transferred.")
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
onError(err instanceof ArcadiaError ? err.message : "Transfer failed.")
|
||||
setTransferTarget(null)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function MemberRowActions({
|
||||
member,
|
||||
orgId,
|
||||
onTransfer,
|
||||
onRemove,
|
||||
onRoleChanged,
|
||||
onError,
|
||||
}: {
|
||||
member: OrgMembership
|
||||
orgId: string
|
||||
onTransfer: () => void
|
||||
onRemove: () => void
|
||||
onRoleChanged: (msg: string) => Promise<void>
|
||||
onError: (msg: string | null) => void
|
||||
}) {
|
||||
const arcadia = useArcadiaClient()
|
||||
|
||||
const items: ActionItem[] = []
|
||||
|
||||
if (member.role !== "owner") {
|
||||
items.push({
|
||||
id: "promote-admin",
|
||||
label: member.role === "admin" ? "Demote to member" : "Promote to admin",
|
||||
icon: <UserCog className="size-4" />,
|
||||
dataAction: `org-${orgId}-member-${member.id}-role`,
|
||||
onSelect: async () => {
|
||||
const next = member.role === "admin" ? "member" : "admin"
|
||||
try {
|
||||
await changeMemberRole(arcadia, orgId, member.user_id, next)
|
||||
await onRoleChanged(`Role set to ${next}.`)
|
||||
} catch (err) {
|
||||
onError(err instanceof ArcadiaError ? err.message : "Role change failed.")
|
||||
}
|
||||
},
|
||||
})
|
||||
items.push({
|
||||
id: "transfer",
|
||||
label: "Transfer ownership to this user",
|
||||
icon: <Crown className="size-4" />,
|
||||
dataAction: `org-${orgId}-member-${member.id}-transfer`,
|
||||
onSelect: onTransfer,
|
||||
})
|
||||
}
|
||||
|
||||
items.push({
|
||||
id: "remove",
|
||||
label: "Remove",
|
||||
icon: <Trash2 className="size-4" />,
|
||||
destructive: true,
|
||||
dataAction: `org-${orgId}-member-${member.id}-remove`,
|
||||
onSelect: onRemove,
|
||||
})
|
||||
|
||||
return <ActionsCell items={items} />
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Invite-by-email and add-restricted forms
|
||||
// ============================================================================
|
||||
|
||||
function InviteByEmailForm({
|
||||
orgId,
|
||||
onCancel,
|
||||
onSaved,
|
||||
onError,
|
||||
}: {
|
||||
orgId: string
|
||||
onCancel: () => void
|
||||
onSaved: (msg: string) => Promise<void>
|
||||
onError: (msg: string | null) => void
|
||||
}) {
|
||||
const arcadia = useArcadiaClient()
|
||||
const [email, setEmail] = useState("")
|
||||
const [role, setRole] = useState<OrgRole>("member")
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-muted/20 p-3">
|
||||
<div className="grid gap-2 sm:grid-cols-[1fr_140px_auto_auto]">
|
||||
<Input
|
||||
placeholder="email@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<Select value={role} onValueChange={(v) => setRole(v as OrgRole)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="member">Member</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="sm" onClick={onCancel} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!email || saving}
|
||||
onClick={async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await inviteMember(arcadia, orgId, { email, role })
|
||||
await onSaved(
|
||||
res.type === "membership"
|
||||
? "Invited existing user."
|
||||
: "Email invitation sent.",
|
||||
)
|
||||
} catch (err) {
|
||||
onError(err instanceof ArcadiaError ? err.message : "Invite failed.")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Send invite
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
If an account with that email already exists in this tenant, an invited membership is
|
||||
created; otherwise an email invitation is sent and the user is materialized on accept.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AddRestrictedForm({
|
||||
orgId,
|
||||
onCancel,
|
||||
onSaved,
|
||||
onError,
|
||||
}: {
|
||||
orgId: string
|
||||
onCancel: () => void
|
||||
onSaved: (msg: string) => Promise<void>
|
||||
onError: (msg: string | null) => void
|
||||
}) {
|
||||
const arcadia = useArcadiaClient()
|
||||
const [email, setEmail] = useState("")
|
||||
const [firstName, setFirstName] = useState("")
|
||||
const [lastName, setLastName] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [role, setRole] = useState<OrgRole>("member")
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-muted/20 p-3">
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label htmlFor="r-email">Email</Label>
|
||||
<Input id="r-email" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label htmlFor="r-password">Initial password</Label>
|
||||
<Input
|
||||
id="r-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label htmlFor="r-first">First name</Label>
|
||||
<Input id="r-first" value={firstName} onChange={(e) => setFirstName(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label htmlFor="r-last">Last name</Label>
|
||||
<Input id="r-last" value={lastName} onChange={(e) => setLastName(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label htmlFor="r-role">Role</Label>
|
||||
<Select value={role} onValueChange={(v) => setRole(v as OrgRole)}>
|
||||
<SelectTrigger id="r-role">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="member">Member</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-end gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onCancel} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!email || !password || !firstName || !lastName || saving}
|
||||
onClick={async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await addRestrictedMember(arcadia, orgId, {
|
||||
email,
|
||||
password,
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
role,
|
||||
})
|
||||
await onSaved("Restricted user added.")
|
||||
} catch (err) {
|
||||
onError(err instanceof ArcadiaError ? err.message : "Add failed.")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Add user
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Restricted users exist only inside this org — they can never act in personal mode and have
|
||||
no plan of their own.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Settings dialog
|
||||
// ============================================================================
|
||||
|
||||
function SettingsDialog({
|
||||
state,
|
||||
onClose,
|
||||
onSaved,
|
||||
onError,
|
||||
}: {
|
||||
state: SettingsDialogState
|
||||
onClose: () => void
|
||||
onSaved: (msg?: string) => Promise<void>
|
||||
onError: (msg: string | null) => void
|
||||
}) {
|
||||
const arcadia = useArcadiaClient()
|
||||
const open = state !== null
|
||||
const org = state?.org
|
||||
|
||||
const [name, setName] = useState("")
|
||||
const [status, setStatus] = useState<OrgStatus>("active")
|
||||
const [onOwnerRemoval, setOnOwnerRemoval] = useState<OnOwnerRemoval>("require_transfer")
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (org) {
|
||||
setName(org.name)
|
||||
setStatus(org.status)
|
||||
setOnOwnerRemoval(org.on_owner_removal)
|
||||
}
|
||||
}, [org])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{org ? `Settings — ${org.name}` : "Settings"}</DialogTitle>
|
||||
<DialogDescription>Change name, status, or owner-removal policy.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label htmlFor="o-name">Name</Label>
|
||||
<Input id="o-name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label htmlFor="o-status">Status</Label>
|
||||
<Select value={status} onValueChange={(v) => setStatus(v as OrgStatus)}>
|
||||
<SelectTrigger id="o-status">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="frozen">Frozen</SelectItem>
|
||||
<SelectItem value="pending_deletion">Pending deletion</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label htmlFor="o-policy">Owner-removal policy</Label>
|
||||
<Select
|
||||
value={onOwnerRemoval}
|
||||
onValueChange={(v) => setOnOwnerRemoval(v as OnOwnerRemoval)}
|
||||
>
|
||||
<SelectTrigger id="o-policy">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="require_transfer">Require transfer (safest)</SelectItem>
|
||||
<SelectItem value="freeze_until_new_owner">Freeze until new owner</SelectItem>
|
||||
<SelectItem value="delete">Delete on owner removal</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Decides what happens when the owner's membership is removed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={saving}
|
||||
onClick={async () => {
|
||||
if (!org) return
|
||||
setSaving(true)
|
||||
try {
|
||||
await updateOrganization(arcadia, org.id, {
|
||||
name,
|
||||
status,
|
||||
on_owner_removal: onOwnerRemoval,
|
||||
})
|
||||
await onSaved("Organization updated.")
|
||||
} catch (err) {
|
||||
onError(err instanceof ArcadiaError ? err.message : "Save failed.")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
40
app/routes/plan.tsx
Normal file
40
app/routes/plan.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
// Tenant subscription + billing — placeholder. Real surface lists the
|
||||
// active plan, renewal date, invoices, and payment method for the
|
||||
// active tenant. Data source not wired yet.
|
||||
|
||||
import { CreditCard } from "lucide-react"
|
||||
import { AppShell } from "~/components/layout/app-shell"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card"
|
||||
|
||||
export default function PlanRoute() {
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<CreditCard className="size-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Plan</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your tenant's subscription, billing details, and invoice history.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Coming soon</CardTitle>
|
||||
<CardDescription>
|
||||
Billing is not yet wired to a payment provider on this deployment.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent />
|
||||
</Card>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { Check, Trash2 } from "lucide-react"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { Check, RefreshCw, Trash2 } from "lucide-react"
|
||||
|
||||
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
|
||||
import { AlertBanner } from "@crema/feedback-ui"
|
||||
|
||||
import { AppShell } from "~/components/layout/app-shell"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"
|
||||
import { Badge } from "~/components/ui/badge"
|
||||
import { Button } from "~/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
@@ -11,253 +15,441 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu"
|
||||
import { Input } from "~/components/ui/input"
|
||||
import { Textarea } from "~/components/ui/textarea"
|
||||
import { useAgents } from "~/lib/agents"
|
||||
import { pageTitle } from "~/lib/page-meta"
|
||||
import {
|
||||
DEFAULT_PROFILE,
|
||||
profileInitials,
|
||||
resetProfile,
|
||||
saveProfile,
|
||||
useProfile,
|
||||
type Profile,
|
||||
} from "~/lib/profile"
|
||||
import { getUser, updateUser, type User } from "~/lib/arcadia/users"
|
||||
import {
|
||||
fetchDigitalObjectAsBlobUrl,
|
||||
uploadFile,
|
||||
} from "~/lib/arcadia/digital-objects"
|
||||
import {
|
||||
getProfile,
|
||||
updateProfile as updateArcadiaProfile,
|
||||
pickAvatarUrl,
|
||||
type Profile as ArcadiaProfile,
|
||||
} from "~/lib/arcadia/profiles"
|
||||
import { updateSessionUser, useSession } from "~/lib/session"
|
||||
|
||||
export const meta = () => pageTitle("Profile")
|
||||
|
||||
export default function ProfileRoute() {
|
||||
const profile = useProfile()
|
||||
const agents = useAgents()
|
||||
const [draft, setDraft] = useState<Profile>(profile)
|
||||
const [savedAt, setSavedAt] = useState<number | null>(null)
|
||||
interface AccountDraft {
|
||||
first_name: string
|
||||
last_name: string
|
||||
email: string
|
||||
}
|
||||
|
||||
export default function ProfileRoute() {
|
||||
const session = useSession()
|
||||
const arcadia = useArcadiaClient()
|
||||
const profile = useProfile()
|
||||
|
||||
// Mirror of the resolved avatar URL — kept in localStorage so the
|
||||
// <AvatarImage> in the appbar can render before the profile fetch
|
||||
// resolves on next mount.
|
||||
const [prefs, setPrefs] = useState<Profile>(profile)
|
||||
useEffect(() => {
|
||||
setDraft(profile)
|
||||
setPrefs(profile)
|
||||
}, [profile])
|
||||
|
||||
const dirty = JSON.stringify(draft) !== JSON.stringify(profile)
|
||||
const initials = profileInitials(draft.name || DEFAULT_PROFILE.name)
|
||||
// Arcadia account.
|
||||
const [account, setAccount] = useState<User | null>(null)
|
||||
const [accountDraft, setAccountDraft] = useState<AccountDraft>({
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
email: "",
|
||||
})
|
||||
const [accountLoading, setAccountLoading] = useState(true)
|
||||
const [accountSaving, setAccountSaving] = useState(false)
|
||||
const [accountSavedAt, setAccountSavedAt] = useState<number | null>(null)
|
||||
const [accountError, setAccountError] = useState<string | null>(null)
|
||||
|
||||
// Server-side profile (avatar lives here — `prefs.avatarUrl` mirrors
|
||||
// the resolved URL so the existing <AvatarImage> bindings keep working).
|
||||
const [arcadiaProfile, setArcadiaProfile] = useState<ArcadiaProfile | null>(null)
|
||||
const [avatarUploading, setAvatarUploading] = useState(false)
|
||||
const [avatarError, setAvatarError] = useState<string | null>(null)
|
||||
|
||||
// Public-profile editable fields, server-backed via PATCH /api/v1/profile.
|
||||
const [profileDraft, setProfileDraft] = useState<{
|
||||
bio: string
|
||||
phone: string
|
||||
location: string
|
||||
timezone: string
|
||||
}>({ bio: "", phone: "", location: "", timezone: "" })
|
||||
const [profileSaving, setProfileSaving] = useState(false)
|
||||
const [profileSavedAt, setProfileSavedAt] = useState<number | null>(null)
|
||||
const [profileError, setProfileError] = useState<string | null>(null)
|
||||
|
||||
const profileDirty =
|
||||
!!arcadiaProfile &&
|
||||
(profileDraft.bio !== (arcadiaProfile.bio ?? "") ||
|
||||
profileDraft.phone !== (arcadiaProfile.phone ?? "") ||
|
||||
profileDraft.location !== (arcadiaProfile.location ?? "") ||
|
||||
profileDraft.timezone !== (arcadiaProfile.timezone ?? ""))
|
||||
|
||||
const loadAccount = useCallback(async () => {
|
||||
if (!session) return
|
||||
setAccountLoading(true)
|
||||
setAccountError(null)
|
||||
try {
|
||||
const [u, p] = await Promise.all([
|
||||
getUser(arcadia, session.userId),
|
||||
getProfile(arcadia).catch(() => null),
|
||||
])
|
||||
setAccount(u)
|
||||
setAccountDraft({
|
||||
first_name: u.first_name ?? "",
|
||||
last_name: u.last_name ?? "",
|
||||
email: u.email,
|
||||
})
|
||||
if (p) {
|
||||
setArcadiaProfile(p)
|
||||
setProfileDraft({
|
||||
bio: p.bio ?? "",
|
||||
phone: p.phone ?? "",
|
||||
location: p.location ?? "",
|
||||
timezone: p.timezone ?? "",
|
||||
})
|
||||
const url = pickAvatarUrl(p)
|
||||
if (url) {
|
||||
// Persist into localStorage so the appbar's useProfile() picks
|
||||
// it up on next render — without this, the appbar avatar reverts
|
||||
// to initials on every fresh browser session until the user
|
||||
// re-uploads.
|
||||
setPrefs((d) => {
|
||||
const next = { ...d, avatarUrl: url }
|
||||
saveProfile(next)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setAccountError(
|
||||
err instanceof ArcadiaError ? err.message : "Failed to load account.",
|
||||
)
|
||||
} finally {
|
||||
setAccountLoading(false)
|
||||
}
|
||||
}, [arcadia, session])
|
||||
|
||||
useEffect(() => {
|
||||
loadAccount()
|
||||
}, [loadAccount])
|
||||
|
||||
const accountDirty =
|
||||
!!account &&
|
||||
(accountDraft.first_name !== (account.first_name ?? "") ||
|
||||
accountDraft.last_name !== (account.last_name ?? "") ||
|
||||
accountDraft.email !== account.email)
|
||||
|
||||
const saveAccount = async () => {
|
||||
if (!account) return
|
||||
setAccountSaving(true)
|
||||
setAccountError(null)
|
||||
try {
|
||||
const updated = await updateUser(arcadia, account.id, {
|
||||
first_name: accountDraft.first_name || null,
|
||||
last_name: accountDraft.last_name || null,
|
||||
email: accountDraft.email,
|
||||
})
|
||||
setAccount(updated)
|
||||
updateSessionUser({ name: updated.full_name, email: updated.email })
|
||||
setAccountSavedAt(Date.now())
|
||||
} catch (err) {
|
||||
setAccountError(
|
||||
err instanceof ArcadiaError ? err.message : "Save failed.",
|
||||
)
|
||||
} finally {
|
||||
setAccountSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const saveArcadiaProfile = async () => {
|
||||
setProfileSaving(true)
|
||||
setProfileError(null)
|
||||
try {
|
||||
const updated = await updateArcadiaProfile(arcadia, {
|
||||
bio: profileDraft.bio || null,
|
||||
phone: profileDraft.phone || null,
|
||||
location: profileDraft.location || null,
|
||||
timezone: profileDraft.timezone || null,
|
||||
})
|
||||
setArcadiaProfile(updated)
|
||||
setProfileDraft({
|
||||
bio: updated.bio ?? "",
|
||||
phone: updated.phone ?? "",
|
||||
location: updated.location ?? "",
|
||||
timezone: updated.timezone ?? "",
|
||||
})
|
||||
setProfileSavedAt(Date.now())
|
||||
} catch (err) {
|
||||
setProfileError(
|
||||
err instanceof ArcadiaError
|
||||
? err.message
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: "Save failed.",
|
||||
)
|
||||
} finally {
|
||||
setProfileSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Local prefs handlers.
|
||||
const initials = profileInitials(
|
||||
[accountDraft.first_name, accountDraft.last_name].filter(Boolean).join(" ") ||
|
||||
account?.full_name ||
|
||||
session?.name ||
|
||||
"",
|
||||
)
|
||||
|
||||
const onPickAvatar = async (file: File | null) => {
|
||||
setAvatarError(null)
|
||||
|
||||
const onPickAvatar = (file: File | null) => {
|
||||
if (!file) {
|
||||
setDraft((d) => ({ ...d, avatarUrl: "" }))
|
||||
// Clear: detach the digital object on the server, then drop the
|
||||
// local cache. Keep the local cache cleared even if the server call
|
||||
// fails so the UI reflects the user's intent.
|
||||
setPrefs((d) => ({ ...d, avatarUrl: "" }))
|
||||
try {
|
||||
const updated = await updateArcadiaProfile(arcadia, {
|
||||
avatar_digital_object_id: null,
|
||||
})
|
||||
setArcadiaProfile(updated)
|
||||
savePrefsLocal({ ...prefs, avatarUrl: "" })
|
||||
} catch (err) {
|
||||
setAvatarError(
|
||||
err instanceof Error ? err.message : "Failed to clear avatar.",
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const result = reader.result
|
||||
if (typeof result === "string")
|
||||
setDraft((d) => ({ ...d, avatarUrl: result }))
|
||||
|
||||
if (!file.type.startsWith("image/")) {
|
||||
setAvatarError("Avatar must be an image (PNG, JPG, GIF, WebP).")
|
||||
return
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
// 8MB hard cap client-side; arcadia will enforce its own quota too.
|
||||
if (file.size > 8 * 1024 * 1024) {
|
||||
setAvatarError("Avatar is too large (max 8MB).")
|
||||
return
|
||||
}
|
||||
|
||||
const save = () => {
|
||||
saveProfile(draft)
|
||||
setSavedAt(Date.now())
|
||||
setAvatarUploading(true)
|
||||
try {
|
||||
const obj = await uploadFile(arcadia, file, { tags: ["avatar"] })
|
||||
const updated = await updateArcadiaProfile(arcadia, {
|
||||
avatar_digital_object_id: obj.id,
|
||||
})
|
||||
setArcadiaProfile(updated)
|
||||
const persistentUrl = pickAvatarUrl(updated)
|
||||
if (persistentUrl) {
|
||||
// Variant pipeline already finished — persist to localStorage.
|
||||
const next = { ...prefs, avatarUrl: persistentUrl }
|
||||
setPrefs(next)
|
||||
savePrefsLocal(next)
|
||||
} else {
|
||||
// Variants aren't ready yet (image-processing is async). Fetch
|
||||
// the raw object as a blob URL for immediate in-memory render.
|
||||
// Don't persist to localStorage — blob URLs don't survive a
|
||||
// reload, and ProfileBootstrap will pick up the persistent URL
|
||||
// on next mount once processing completes.
|
||||
const token =
|
||||
typeof window !== "undefined"
|
||||
? sessionStorage.getItem("arcadia_access_token")
|
||||
: null
|
||||
if (token) {
|
||||
const baseUrl =
|
||||
(import.meta.env.VITE_ARCADIA_URL as string | undefined) ??
|
||||
"http://localhost:4000"
|
||||
const tenantId =
|
||||
(import.meta.env.VITE_ARCADIA_TENANT as string | undefined) ??
|
||||
"default"
|
||||
try {
|
||||
const blobUrl = await fetchDigitalObjectAsBlobUrl(
|
||||
baseUrl,
|
||||
obj.id,
|
||||
token,
|
||||
tenantId,
|
||||
)
|
||||
// eslint-disable-next-line no-console
|
||||
console.info("[avatar] blob URL ready:", blobUrl)
|
||||
// Persist the blob URL so the appbar's useProfile() picks it
|
||||
// up via the storage event. Blob URLs don't survive a reload,
|
||||
// but ProfileBootstrap will refresh on next mount.
|
||||
const next = { ...prefs, avatarUrl: blobUrl }
|
||||
setPrefs(next)
|
||||
savePrefsLocal(next)
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("[avatar] blob fetch failed:", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setAvatarError(
|
||||
err instanceof Error ? err.message : "Avatar upload failed.",
|
||||
)
|
||||
} finally {
|
||||
setAvatarUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const defaultAgent =
|
||||
agents.find((a) => a.id === draft.defaultAgentId) ?? null
|
||||
// Mirror the avatar URL into localStorage so it survives reloads.
|
||||
const savePrefsLocal = (next: Profile) => {
|
||||
saveProfile(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell title="Profile">
|
||||
<AppShell>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>You</CardTitle>
|
||||
<CardTitle className="flex items-center gap-3">
|
||||
Account
|
||||
{account?.email_verified ? (
|
||||
<Badge variant="default">Verified</Badge>
|
||||
) : account ? (
|
||||
<Badge variant="secondary">Unverified</Badge>
|
||||
) : null}
|
||||
{account?.status && account.status !== "active" ? (
|
||||
<Badge variant="destructive">{account.status}</Badge>
|
||||
) : null}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Personal info shown across the app — appbar avatar, signatures, and
|
||||
anywhere the assistant references you.
|
||||
Your arcadia identity. Changes are saved to the platform and reflected
|
||||
anywhere your name or email appears.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-6">
|
||||
{accountError ? (
|
||||
<AlertBanner
|
||||
variant="error"
|
||||
dismissible
|
||||
onDismiss={() => setAccountError(null)}
|
||||
>
|
||||
{accountError}
|
||||
</AlertBanner>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<Avatar className="size-20 ring-2 ring-primary/30">
|
||||
{draft.avatarUrl ? (
|
||||
<AvatarImage src={draft.avatarUrl} alt={draft.name} />
|
||||
{prefs.avatarUrl ? (
|
||||
<AvatarImage
|
||||
key={prefs.avatarUrl}
|
||||
src={prefs.avatarUrl}
|
||||
alt={accountDraft.email}
|
||||
/>
|
||||
) : null}
|
||||
<AvatarFallback className="bg-primary text-lg font-semibold text-primary-foreground">
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="inline-flex w-fit cursor-pointer items-center gap-2 rounded-md border bg-background px-3 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground">
|
||||
<input
|
||||
data-action="profile-avatar-upload"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="sr-only"
|
||||
onChange={(e) => onPickAvatar(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
Upload avatar
|
||||
</label>
|
||||
{draft.avatarUrl && (
|
||||
<Button
|
||||
data-action="profile-avatar-remove"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onPickAvatar(null)}
|
||||
className="w-fit text-muted-foreground"
|
||||
>
|
||||
<Trash2 className="size-3.5" /> Remove
|
||||
</Button>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
PNG, JPG, or SVG. Stored locally as a data URL.
|
||||
<div className="flex flex-col gap-1 text-sm">
|
||||
<span className="font-medium">
|
||||
{account?.full_name || accountDraft.email || "—"}
|
||||
</span>
|
||||
{account ? (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Tenant <code className="font-mono">{account.tenant_id}</code> ·
|
||||
ID <code className="font-mono">{account.id}</code>
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Last sign-in{" "}
|
||||
{account.last_sign_in_at
|
||||
? new Date(account.last_sign_in_at).toLocaleString()
|
||||
: "—"}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field label="Name">
|
||||
<Field label="First name">
|
||||
<Input
|
||||
data-action="profile-name"
|
||||
value={draft.name}
|
||||
data-action="profile-first-name"
|
||||
value={accountDraft.first_name}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => ({ ...d, name: e.target.value }))
|
||||
setAccountDraft((d) => ({ ...d, first_name: e.target.value }))
|
||||
}
|
||||
autoComplete="name"
|
||||
autoComplete="given-name"
|
||||
disabled={accountLoading || accountSaving}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Email">
|
||||
<Field label="Last name">
|
||||
<Input
|
||||
data-action="profile-last-name"
|
||||
value={accountDraft.last_name}
|
||||
onChange={(e) =>
|
||||
setAccountDraft((d) => ({ ...d, last_name: e.target.value }))
|
||||
}
|
||||
autoComplete="family-name"
|
||||
disabled={accountLoading || accountSaving}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="Email"
|
||||
hint="Updating your email may require re-verification."
|
||||
>
|
||||
<Input
|
||||
data-action="profile-email"
|
||||
type="email"
|
||||
value={draft.email}
|
||||
value={accountDraft.email}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => ({ ...d, email: e.target.value }))
|
||||
setAccountDraft((d) => ({ ...d, email: e.target.value }))
|
||||
}
|
||||
autoComplete="email"
|
||||
disabled={accountLoading || accountSaving}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Title" hint="Your role at work.">
|
||||
<Input
|
||||
data-action="profile-title"
|
||||
value={draft.title}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => ({ ...d, title: e.target.value }))
|
||||
}
|
||||
placeholder="e.g. Product designer"
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="Default agent"
|
||||
hint="Used as the active persona on first load."
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
data-action="profile-default-agent"
|
||||
className="inline-flex h-9 items-center justify-between gap-2 rounded-md border bg-background px-3 text-sm hover:bg-accent hover:text-accent-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<span className="truncate">
|
||||
{defaultAgent ? (
|
||||
<>
|
||||
<span className="font-medium">{defaultAgent.name}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
— {defaultAgent.role}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
"Use first available"
|
||||
)}
|
||||
</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-64">
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
setDraft((d) => ({ ...d, defaultAgentId: "" }))
|
||||
}
|
||||
data-state={!draft.defaultAgentId ? "checked" : undefined}
|
||||
>
|
||||
First available
|
||||
</DropdownMenuItem>
|
||||
{agents.map((a) => (
|
||||
<DropdownMenuItem
|
||||
key={a.id}
|
||||
onClick={() =>
|
||||
setDraft((d) => ({ ...d, defaultAgentId: a.id }))
|
||||
}
|
||||
data-state={
|
||||
draft.defaultAgentId === a.id ? "checked" : undefined
|
||||
}
|
||||
className="flex flex-col items-start"
|
||||
>
|
||||
<span className="font-medium">{a.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{a.role}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field
|
||||
label="Bio"
|
||||
hint="A short blurb the assistant can reference (e.g. 'I work mostly in TypeScript')."
|
||||
>
|
||||
<Textarea
|
||||
data-action="profile-bio"
|
||||
value={draft.bio}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => ({ ...d, bio: e.target.value }))
|
||||
}
|
||||
rows={3}
|
||||
placeholder="Tell the assistant about you."
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Signature"
|
||||
hint="Appended automatically when you ask the assistant to draft an email or note."
|
||||
>
|
||||
<Textarea
|
||||
data-action="profile-signature"
|
||||
value={draft.signature}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => ({ ...d, signature: e.target.value }))
|
||||
}
|
||||
rows={3}
|
||||
placeholder={`Cheers,\n${draft.name || "Your name"}`}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
data-action="profile-save"
|
||||
onClick={save}
|
||||
disabled={!dirty}
|
||||
data-action="profile-account-save"
|
||||
onClick={saveAccount}
|
||||
disabled={!accountDirty || accountSaving || accountLoading}
|
||||
>
|
||||
Save
|
||||
{accountSaving ? (
|
||||
<RefreshCw className="size-4 animate-spin" />
|
||||
) : null}
|
||||
Save account
|
||||
</Button>
|
||||
<Button
|
||||
data-action="profile-revert"
|
||||
data-action="profile-account-revert"
|
||||
variant="ghost"
|
||||
onClick={() => setDraft(profile)}
|
||||
disabled={!dirty}
|
||||
onClick={() => {
|
||||
if (!account) return
|
||||
setAccountDraft({
|
||||
first_name: account.first_name ?? "",
|
||||
last_name: account.last_name ?? "",
|
||||
email: account.email,
|
||||
})
|
||||
}}
|
||||
disabled={!accountDirty || accountSaving}
|
||||
>
|
||||
Revert
|
||||
</Button>
|
||||
<Button
|
||||
data-action="profile-reset"
|
||||
data-action="profile-account-refresh"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
resetProfile()
|
||||
setSavedAt(Date.now())
|
||||
}}
|
||||
onClick={loadAccount}
|
||||
disabled={accountLoading}
|
||||
>
|
||||
Reset to defaults
|
||||
<RefreshCw
|
||||
className={accountLoading ? "size-4 animate-spin" : "size-4"}
|
||||
/>
|
||||
Refresh
|
||||
</Button>
|
||||
{savedAt && !dirty && (
|
||||
{accountSavedAt && !accountDirty && (
|
||||
<span className="inline-flex items-center gap-1 text-sm text-emerald-700 dark:text-emerald-400">
|
||||
<Check className="size-4" /> Saved.
|
||||
</span>
|
||||
@@ -265,6 +457,149 @@ export default function ProfileRoute() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Profile</CardTitle>
|
||||
<CardDescription>
|
||||
Public profile fields stored on arcadia. Visible to other members
|
||||
of this tenant.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{profileError ? (
|
||||
<AlertBanner variant="error">{profileError}</AlertBanner>
|
||||
) : null}
|
||||
<Field
|
||||
label="Bio"
|
||||
hint="A short blurb about you."
|
||||
>
|
||||
<Textarea
|
||||
data-action="profile-bio"
|
||||
value={profileDraft.bio}
|
||||
onChange={(e) =>
|
||||
setProfileDraft((d) => ({ ...d, bio: e.target.value }))
|
||||
}
|
||||
rows={3}
|
||||
placeholder="Tell others what you work on."
|
||||
/>
|
||||
</Field>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field label="Phone">
|
||||
<Input
|
||||
data-action="profile-phone"
|
||||
value={profileDraft.phone}
|
||||
onChange={(e) =>
|
||||
setProfileDraft((d) => ({ ...d, phone: e.target.value }))
|
||||
}
|
||||
placeholder="+61 …"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Location">
|
||||
<Input
|
||||
data-action="profile-location"
|
||||
value={profileDraft.location}
|
||||
onChange={(e) =>
|
||||
setProfileDraft((d) => ({ ...d, location: e.target.value }))
|
||||
}
|
||||
placeholder="Melbourne, AU"
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="Timezone"
|
||||
hint="IANA name (e.g. Australia/Melbourne)."
|
||||
>
|
||||
<Input
|
||||
data-action="profile-timezone"
|
||||
value={profileDraft.timezone}
|
||||
onChange={(e) =>
|
||||
setProfileDraft((d) => ({ ...d, timezone: e.target.value }))
|
||||
}
|
||||
placeholder="Australia/Melbourne"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
data-action="profile-arcadia-save"
|
||||
onClick={saveArcadiaProfile}
|
||||
disabled={!profileDirty || profileSaving}
|
||||
>
|
||||
{profileSaving ? "Saving…" : "Save profile"}
|
||||
</Button>
|
||||
<Button
|
||||
data-action="profile-arcadia-revert"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
if (!arcadiaProfile) return
|
||||
setProfileDraft({
|
||||
bio: arcadiaProfile.bio ?? "",
|
||||
phone: arcadiaProfile.phone ?? "",
|
||||
location: arcadiaProfile.location ?? "",
|
||||
timezone: arcadiaProfile.timezone ?? "",
|
||||
})
|
||||
}}
|
||||
disabled={!profileDirty || profileSaving}
|
||||
>
|
||||
Revert
|
||||
</Button>
|
||||
{profileSavedAt && !profileDirty ? (
|
||||
<span className="inline-flex items-center gap-1 text-sm text-emerald-700 dark:text-emerald-400">
|
||||
<Check className="size-4" /> Saved.
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Avatar</CardTitle>
|
||||
<CardDescription>
|
||||
Uploads land in your tenant's storage backend.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
{avatarError ? (
|
||||
<AlertBanner variant="error">{avatarError}</AlertBanner>
|
||||
) : null}
|
||||
<div className="flex items-center gap-3">
|
||||
<label
|
||||
aria-disabled={avatarUploading}
|
||||
className={[
|
||||
"inline-flex w-fit items-center gap-2 rounded-md border bg-background px-3 py-1.5 text-sm",
|
||||
avatarUploading
|
||||
? "cursor-not-allowed opacity-60"
|
||||
: "cursor-pointer hover:bg-accent hover:text-accent-foreground",
|
||||
].join(" ")}
|
||||
>
|
||||
<input
|
||||
data-action="profile-avatar-upload"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="sr-only"
|
||||
disabled={avatarUploading}
|
||||
onChange={(e) => onPickAvatar(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
{avatarUploading ? "Uploading…" : "Upload avatar"}
|
||||
</label>
|
||||
{prefs.avatarUrl && !avatarUploading && (
|
||||
<Button
|
||||
data-action="profile-avatar-remove"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onPickAvatar(null)}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<Trash2 className="size-3.5" /> Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
PNG, JPG, GIF, or WebP. Max 8MB.
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { Plus, Search, Trash2 } from "lucide-react"
|
||||
|
||||
import { AppShell } from "~/components/layout/app-shell"
|
||||
import { Button } from "~/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card"
|
||||
import { Input } from "~/components/ui/input"
|
||||
import {
|
||||
createResource,
|
||||
deleteResource,
|
||||
seedResourcesIfEmpty,
|
||||
updateResource,
|
||||
useResources,
|
||||
type Resource,
|
||||
} from "~/lib/resources"
|
||||
import { pageTitle } from "~/lib/page-meta"
|
||||
|
||||
export const meta = () => pageTitle("Resources")
|
||||
|
||||
const statuses: Resource["status"][] = ["active", "paused", "archived"]
|
||||
|
||||
export default function ResourcesRoute() {
|
||||
const items = useResources()
|
||||
const [query, setQuery] = useState("")
|
||||
const [draftName, setDraftName] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
seedResourcesIfEmpty()
|
||||
}, [])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
return q
|
||||
? items.filter(
|
||||
(r) =>
|
||||
r.name.toLowerCase().includes(q) ||
|
||||
r.owner.toLowerCase().includes(q) ||
|
||||
r.status.includes(q),
|
||||
)
|
||||
: items
|
||||
}, [items, query])
|
||||
|
||||
const create = () => {
|
||||
const name = draftName.trim()
|
||||
if (!name) return
|
||||
createResource({ name, owner: "You" })
|
||||
setDraftName("")
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell title="Resources">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Resources</CardTitle>
|
||||
<CardDescription>
|
||||
Example domain entity. CRUD goes through{" "}
|
||||
<code className="font-mono text-xs">~/lib/resources.ts</code> —
|
||||
swap that file's calls for{" "}
|
||||
<code className="font-mono text-xs">api.get/post/put/del</code>{" "}
|
||||
from <code className="font-mono text-xs">~/lib/api.ts</code> when
|
||||
you have a backend.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative flex-1 min-w-48">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
data-action="resources-search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search name, owner, status…"
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
data-action="resources-new-name"
|
||||
value={draftName}
|
||||
onChange={(e) => setDraftName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") create()
|
||||
}}
|
||||
placeholder="New resource name…"
|
||||
className="max-w-64"
|
||||
/>
|
||||
<Button
|
||||
data-action="resources-create"
|
||||
onClick={create}
|
||||
disabled={!draftName.trim()}
|
||||
>
|
||||
<Plus className="size-4" /> Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border bg-card/40">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50 text-xs uppercase tracking-wide text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium">Name</th>
|
||||
<th className="px-3 py-2 text-left font-medium">Owner</th>
|
||||
<th className="px-3 py-2 text-left font-medium">Status</th>
|
||||
<th className="px-3 py-2 text-left font-medium">Updated</th>
|
||||
<th className="w-10 px-3 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={5}
|
||||
className="px-3 py-8 text-center text-muted-foreground"
|
||||
>
|
||||
{items.length === 0
|
||||
? "No resources yet — add one above."
|
||||
: "No matches."}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filtered.map((r) => (
|
||||
<tr
|
||||
key={r.id}
|
||||
className="border-t transition-colors hover:bg-accent/30"
|
||||
>
|
||||
<td className="px-3 py-2 font-medium">{r.name}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">
|
||||
{r.owner}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<select
|
||||
data-action={`resources-status-${r.id}`}
|
||||
value={r.status}
|
||||
onChange={(e) =>
|
||||
updateResource(r.id, {
|
||||
status: e.target.value as Resource["status"],
|
||||
})
|
||||
}
|
||||
className="rounded-md border bg-background px-1.5 py-0.5 text-xs"
|
||||
>
|
||||
{statuses.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-muted-foreground tabular-nums">
|
||||
{new Date(r.updatedAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right">
|
||||
<Button
|
||||
data-action={`resources-delete-${r.id}`}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Delete"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Delete "${r.name}"?`))
|
||||
deleteResource(r.id)
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-4 text-destructive" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{items.length} total · {filtered.length} shown
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
856
app/routes/scheduled-tasks.tsx
Normal file
856
app/routes/scheduled-tasks.tsx
Normal file
@@ -0,0 +1,856 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
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 { useRegisterContext } from "@crema/aifirst-ui/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],
|
||||
)
|
||||
useRegisterContext("scheduled_tasks", summary)
|
||||
|
||||
const table = useTable<ScheduledTask>({
|
||||
data: tasks,
|
||||
columns,
|
||||
getRowId: (t) => t.id,
|
||||
initialPageSize: 25,
|
||||
initialSearch: search,
|
||||
})
|
||||
useEffect(() => {
|
||||
table.setSearch(search)
|
||||
}, [search, table])
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="flex flex-col gap-4">
|
||||
<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
|
||||
}, {})
|
||||
}
|
||||
893
app/routes/search.tsx
Normal file
893
app/routes/search.tsx
Normal file
@@ -0,0 +1,893 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import {
|
||||
CheckCircle2,
|
||||
Database,
|
||||
FileText,
|
||||
Plus,
|
||||
Power,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
} from "lucide-react"
|
||||
|
||||
import {
|
||||
ActionsCell,
|
||||
DataTable,
|
||||
Pagination,
|
||||
useTable,
|
||||
type ActionItem,
|
||||
type Column,
|
||||
} from "@crema/table-ui"
|
||||
import { SearchInput } from "@crema/search-ui"
|
||||
import {
|
||||
AlertBanner,
|
||||
ConfirmDialog,
|
||||
EmptyState,
|
||||
LoadingOverlay,
|
||||
} from "@crema/feedback-ui"
|
||||
import { KpiTile, formatCompact } from "@crema/dashboard-ui"
|
||||
|
||||
import { AppShell } from "~/components/layout/app-shell"
|
||||
import { Badge } from "~/components/ui/badge"
|
||||
import { Button } from "~/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
} 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 {
|
||||
searchAdmin,
|
||||
SearchAdminError,
|
||||
type CorpusSummary,
|
||||
type TenantSummary,
|
||||
} from "~/lib/search-admin"
|
||||
import { pageTitle } from "~/lib/page-meta"
|
||||
import { useSession } from "~/lib/session"
|
||||
import { useRegisterContext } from "@crema/aifirst-ui/context"
|
||||
|
||||
export const meta = () => pageTitle("Search")
|
||||
|
||||
type Row = CorpusSummary & { rowId: string }
|
||||
|
||||
type EditorState =
|
||||
| { kind: "new-tenant" }
|
||||
| { kind: "new-corpus"; tenant: string }
|
||||
| { kind: "edit-corpus"; tenant: string; corpus: string }
|
||||
| null
|
||||
|
||||
export default function SearchRoute() {
|
||||
const session = useSession()
|
||||
|
||||
const [tenants, setTenants] = useState<TenantSummary[]>([])
|
||||
const [corpora, setCorpora] = useState<Row[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [info, setInfo] = useState<string | null>(null)
|
||||
const [editor, setEditor] = useState<EditorState>(null)
|
||||
const [pendingDeleteTenant, setPendingDeleteTenant] = useState<string | null>(
|
||||
null,
|
||||
)
|
||||
const [pendingDeleteCorpus, setPendingDeleteCorpus] = useState<{
|
||||
tenant: string
|
||||
corpus: string
|
||||
} | null>(null)
|
||||
const [restartConfirm, setRestartConfirm] = useState(false)
|
||||
const [rebuilding, setRebuilding] = useState<string | null>(null)
|
||||
|
||||
const reportError = useCallback((err: unknown, fallback: string) => {
|
||||
setError(
|
||||
err instanceof SearchAdminError
|
||||
? `${err.status}: ${err.message}`
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: fallback,
|
||||
)
|
||||
}, [])
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const tRes = await searchAdmin.listTenants()
|
||||
setTenants(tRes.tenants)
|
||||
// Fan out per-tenant corpus lookups in parallel.
|
||||
const cByT = await Promise.all(
|
||||
tRes.tenants.map(async (t) => {
|
||||
try {
|
||||
const r = await searchAdmin.listCorpora(t.id)
|
||||
return r.corpora
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}),
|
||||
)
|
||||
const flat: Row[] = cByT.flat().map((c) => ({
|
||||
...c,
|
||||
rowId: `${c.tenant}/${c.corpus}`,
|
||||
}))
|
||||
setCorpora(flat)
|
||||
} catch (err) {
|
||||
reportError(err, "Failed to load search admin state.")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [reportError])
|
||||
|
||||
useEffect(() => {
|
||||
if (!session) return
|
||||
refresh()
|
||||
}, [session, refresh])
|
||||
|
||||
const totals = useMemo(() => {
|
||||
const indexed = corpora.filter((c) => c.indexed).length
|
||||
const docs = corpora.reduce((a, c) => a + (c.num_docs ?? 0), 0)
|
||||
return { indexed, docs }
|
||||
}, [corpora])
|
||||
|
||||
// Publish a snapshot to the assistant's admin context so the agent
|
||||
// can answer "what corpora exist?" / "is the docs corpus indexed?"
|
||||
// without having to call list_search_corpora.
|
||||
const adminSurface = useMemo(
|
||||
() => ({
|
||||
endpoint: searchAdmin.baseUrl,
|
||||
tenants: tenants.map((t) => ({ id: t.id, corpus_count: t.corpus_count })),
|
||||
corpora: corpora.map((c) => ({
|
||||
tenant: c.tenant,
|
||||
corpus: c.corpus,
|
||||
indexed: c.indexed,
|
||||
num_docs: c.num_docs,
|
||||
})),
|
||||
}),
|
||||
[tenants, corpora],
|
||||
)
|
||||
useRegisterContext("search", adminSurface)
|
||||
|
||||
const rebuild = useCallback(
|
||||
async (tenant: string, corpus: string) => {
|
||||
const id = `${tenant}/${corpus}`
|
||||
setRebuilding(id)
|
||||
setError(null)
|
||||
try {
|
||||
const out = await searchAdmin.rebuild(tenant, corpus)
|
||||
setInfo(
|
||||
`Rebuilt ${tenant}/${corpus} — ${out.chunk_count} chunks indexed.`,
|
||||
)
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
reportError(err, "Rebuild failed.")
|
||||
} finally {
|
||||
setRebuilding(null)
|
||||
}
|
||||
},
|
||||
[refresh, reportError],
|
||||
)
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="flex flex-col gap-4">
|
||||
<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">Search</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Manage arcadia-search tenants and corpora. Trigger rebuilds and
|
||||
restart the service after env changes.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={refresh}
|
||||
disabled={loading}
|
||||
data-action="search-refresh"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`size-4 ${loading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setRestartConfirm(true)}
|
||||
data-action="search-restart"
|
||||
>
|
||||
<Power className="size-4" />
|
||||
Restart service
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setEditor({ kind: "new-tenant" })}
|
||||
data-action="search-new-tenant"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
New tenant
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={tenants.length === 0}
|
||||
onClick={() =>
|
||||
setEditor({
|
||||
kind: "new-corpus",
|
||||
tenant: tenants[0]?.id ?? "",
|
||||
})
|
||||
}
|
||||
data-action="search-new-corpus"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
New corpus
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{!searchAdmin.hasToken ? (
|
||||
<AlertBanner variant="warning">
|
||||
VITE_ARCADIA_SEARCH_ADMIN_TOKEN is unset. The Search section will
|
||||
return 401 until the bearer token is configured. Endpoint:{" "}
|
||||
<code className="font-mono">{searchAdmin.baseUrl}</code>
|
||||
</AlertBanner>
|
||||
) : null}
|
||||
{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 flex-wrap items-end gap-3">
|
||||
<div className="grid grid-cols-3 gap-3 min-w-0">
|
||||
<KpiTile
|
||||
label="Tenants"
|
||||
value={formatCompact(tenants.length)}
|
||||
/>
|
||||
<KpiTile
|
||||
label="Corpora indexed"
|
||||
value={`${totals.indexed} / ${corpora.length}`}
|
||||
/>
|
||||
<KpiTile label="Docs" value={formatCompact(totals.docs)} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<TenantsCard
|
||||
tenants={tenants}
|
||||
onDelete={(id) => setPendingDeleteTenant(id)}
|
||||
/>
|
||||
|
||||
<CorporaCard
|
||||
corpora={corpora}
|
||||
loading={loading}
|
||||
rebuildingId={rebuilding}
|
||||
onRebuild={rebuild}
|
||||
onEdit={(t, c) => setEditor({ kind: "edit-corpus", tenant: t, corpus: c })}
|
||||
onDelete={(t, c) => setPendingDeleteCorpus({ tenant: t, corpus: c })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* New tenant */}
|
||||
<NewTenantDialog
|
||||
open={editor?.kind === "new-tenant"}
|
||||
onClose={() => setEditor(null)}
|
||||
onCreated={async (msg) => {
|
||||
setEditor(null)
|
||||
if (msg) setInfo(msg)
|
||||
await refresh()
|
||||
}}
|
||||
onError={(msg) => setError(msg)}
|
||||
/>
|
||||
|
||||
{/* New / edit corpus */}
|
||||
<CorpusEditor
|
||||
editor={
|
||||
editor?.kind === "new-corpus" || editor?.kind === "edit-corpus"
|
||||
? editor
|
||||
: null
|
||||
}
|
||||
tenants={tenants}
|
||||
onClose={() => setEditor(null)}
|
||||
onSaved={async (msg) => {
|
||||
setEditor(null)
|
||||
if (msg) setInfo(msg)
|
||||
await refresh()
|
||||
}}
|
||||
onError={(msg) => setError(msg)}
|
||||
/>
|
||||
|
||||
{/* Delete tenant */}
|
||||
<ConfirmDialog
|
||||
open={pendingDeleteTenant !== null}
|
||||
onOpenChange={(o) => !o && setPendingDeleteTenant(null)}
|
||||
title={`Delete tenant ${pendingDeleteTenant ?? ""}?`}
|
||||
description="Removes the tenant's config directory AND its entire index directory. This cannot be undone."
|
||||
confirmLabel="Delete tenant"
|
||||
variant="danger"
|
||||
onConfirm={async () => {
|
||||
if (!pendingDeleteTenant) return
|
||||
try {
|
||||
await searchAdmin.deleteTenant(pendingDeleteTenant)
|
||||
setInfo(`Tenant ${pendingDeleteTenant} deleted.`)
|
||||
setPendingDeleteTenant(null)
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
reportError(err, "Delete failed.")
|
||||
setPendingDeleteTenant(null)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Delete corpus */}
|
||||
<ConfirmDialog
|
||||
open={pendingDeleteCorpus !== null}
|
||||
onOpenChange={(o) => !o && setPendingDeleteCorpus(null)}
|
||||
title={
|
||||
pendingDeleteCorpus
|
||||
? `Delete ${pendingDeleteCorpus.tenant}/${pendingDeleteCorpus.corpus}?`
|
||||
: ""
|
||||
}
|
||||
description="Removes the corpus config and its index directory. The tenant is preserved."
|
||||
confirmLabel="Delete corpus"
|
||||
variant="danger"
|
||||
onConfirm={async () => {
|
||||
if (!pendingDeleteCorpus) return
|
||||
const { tenant, corpus } = pendingDeleteCorpus
|
||||
try {
|
||||
await searchAdmin.deleteCorpus(tenant, corpus)
|
||||
setInfo(`Deleted ${tenant}/${corpus}.`)
|
||||
setPendingDeleteCorpus(null)
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
reportError(err, "Delete failed.")
|
||||
setPendingDeleteCorpus(null)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Restart confirm */}
|
||||
<ConfirmDialog
|
||||
open={restartConfirm}
|
||||
onOpenChange={(o) => !o && setRestartConfirm(false)}
|
||||
title="Restart arcadia-search admin?"
|
||||
description="The sidecar will exit and systemd will bring it back up. Active rebuilds will be aborted."
|
||||
confirmLabel="Restart"
|
||||
variant="danger"
|
||||
onConfirm={async () => {
|
||||
setRestartConfirm(false)
|
||||
try {
|
||||
await searchAdmin.restart()
|
||||
setInfo("Restart requested.")
|
||||
} catch (err) {
|
||||
reportError(err, "Restart request failed.")
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Tenants card --------------------------------------------------------
|
||||
|
||||
function TenantsCard({
|
||||
tenants,
|
||||
onDelete,
|
||||
}: {
|
||||
tenants: TenantSummary[]
|
||||
onDelete: (id: string) => void
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-base font-semibold">Tenants</h2>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
{tenants.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No tenants yet."
|
||||
description="Create one to start adding corpora."
|
||||
className="py-8"
|
||||
/>
|
||||
) : (
|
||||
<ul className="flex flex-wrap gap-2">
|
||||
{tenants.map((t) => (
|
||||
<li
|
||||
key={t.id}
|
||||
className="flex items-center gap-2 rounded-md border bg-card px-3 py-1.5"
|
||||
>
|
||||
<code className="font-mono text-xs">{t.id}</code>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t.corpus_count} corpus{t.corpus_count === 1 ? "" : "es"}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => onDelete(t.id)}
|
||||
aria-label={`Delete tenant ${t.id}`}
|
||||
data-action={`tenant-${t.id}-delete`}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Corpora table -------------------------------------------------------
|
||||
|
||||
function CorporaCard({
|
||||
corpora,
|
||||
loading,
|
||||
rebuildingId,
|
||||
onRebuild,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
corpora: Row[]
|
||||
loading: boolean
|
||||
rebuildingId: string | null
|
||||
onRebuild: (tenant: string, corpus: string) => void
|
||||
onEdit: (tenant: string, corpus: string) => void
|
||||
onDelete: (tenant: string, corpus: string) => void
|
||||
}) {
|
||||
const [search, setSearch] = useState("")
|
||||
|
||||
const columns = useMemo<Column<Row>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "tenant",
|
||||
header: "Tenant",
|
||||
accessor: "tenant",
|
||||
sortable: true,
|
||||
cell: (r) => (
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">
|
||||
{r.tenant}
|
||||
</code>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "corpus",
|
||||
header: "Corpus",
|
||||
accessor: "corpus",
|
||||
sortable: true,
|
||||
cell: (r) => (
|
||||
<span className="flex items-center gap-2 font-medium">
|
||||
<Database className="size-4 text-muted-foreground" />
|
||||
{r.corpus}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "indexed",
|
||||
header: "Status",
|
||||
sortable: true,
|
||||
accessor: (r) => (r.indexed ? 1 : 0),
|
||||
cell: (r) =>
|
||||
r.indexed ? (
|
||||
<Badge variant="default" className="text-xs">
|
||||
Indexed
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Not built
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "docs",
|
||||
header: "Docs",
|
||||
sortable: true,
|
||||
accessor: (r) => r.num_docs ?? -1,
|
||||
cell: (r) =>
|
||||
r.num_docs != null ? (
|
||||
<span className="font-mono text-xs">
|
||||
{r.num_docs.toLocaleString()}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
align: "right",
|
||||
cell: (r) => {
|
||||
const id = `${r.tenant}/${r.corpus}`
|
||||
const isRebuilding = rebuildingId === id
|
||||
const items: ActionItem[] = [
|
||||
{
|
||||
id: "rebuild",
|
||||
label: isRebuilding ? "Rebuilding…" : "Rebuild",
|
||||
icon: (
|
||||
<RefreshCw
|
||||
className={`size-4 ${isRebuilding ? "animate-spin" : ""}`}
|
||||
/>
|
||||
),
|
||||
dataAction: `corpus-${r.tenant}-${r.corpus}-rebuild`,
|
||||
onSelect: () =>
|
||||
isRebuilding ? undefined : onRebuild(r.tenant, r.corpus),
|
||||
},
|
||||
{
|
||||
id: "edit",
|
||||
label: "Edit config",
|
||||
icon: <FileText className="size-4" />,
|
||||
dataAction: `corpus-${r.tenant}-${r.corpus}-edit`,
|
||||
onSelect: () => onEdit(r.tenant, r.corpus),
|
||||
},
|
||||
{
|
||||
id: "delete",
|
||||
label: "Delete",
|
||||
icon: <Trash2 className="size-4" />,
|
||||
destructive: true,
|
||||
dataAction: `corpus-${r.tenant}-${r.corpus}-delete`,
|
||||
onSelect: () => onDelete(r.tenant, r.corpus),
|
||||
},
|
||||
]
|
||||
return (
|
||||
<ActionsCell
|
||||
items={items}
|
||||
triggerDataAction={`corpus-${r.tenant}-${r.corpus}-actions`}
|
||||
/>
|
||||
)
|
||||
},
|
||||
},
|
||||
],
|
||||
[rebuildingId, onRebuild, onEdit, onDelete],
|
||||
)
|
||||
|
||||
const table = useTable<Row>({
|
||||
data: corpora,
|
||||
columns,
|
||||
getRowId: (r) => r.rowId,
|
||||
initialPageSize: 25,
|
||||
initialSearch: search,
|
||||
})
|
||||
useEffect(() => {
|
||||
table.setSearch(search)
|
||||
}, [search, table])
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center gap-3">
|
||||
<SearchInput
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
placeholder="Search by tenant or corpus"
|
||||
data-action="corpora-search"
|
||||
className="max-w-sm flex-1"
|
||||
/>
|
||||
<div className="ml-auto text-xs text-muted-foreground">
|
||||
{table.total} of {corpora.length}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="relative p-0">
|
||||
<LoadingOverlay
|
||||
active={loading && corpora.length === 0}
|
||||
label="Loading corpora…"
|
||||
/>
|
||||
{table.total === 0 && !loading ? (
|
||||
<EmptyState
|
||||
icon={<Database className="size-6" />}
|
||||
title={search ? "No matches." : "No corpora yet."}
|
||||
description={
|
||||
search ? "Try a different search." : "Create one above."
|
||||
}
|
||||
className="py-12"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={table.pageRows}
|
||||
getRowId={(r) => r.rowId}
|
||||
sort={table.sort}
|
||||
onSortToggle={table.toggleSort}
|
||||
loading={loading && corpora.length > 0}
|
||||
stickyHeader
|
||||
/>
|
||||
<Pagination
|
||||
page={table.page}
|
||||
pageSize={table.pageSize}
|
||||
total={table.total}
|
||||
onPageChange={table.setPage}
|
||||
onPageSizeChange={table.setPageSize}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Dialogs -------------------------------------------------------------
|
||||
|
||||
function NewTenantDialog({
|
||||
open,
|
||||
onClose,
|
||||
onCreated,
|
||||
onError,
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onCreated: (msg?: string) => Promise<void>
|
||||
onError: (msg: string) => void
|
||||
}) {
|
||||
const [id, setId] = useState("")
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setId("")
|
||||
}, [open])
|
||||
|
||||
const submit = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await searchAdmin.createTenant(id)
|
||||
await onCreated(`Tenant ${id} created.`)
|
||||
} catch (err) {
|
||||
onError(
|
||||
err instanceof SearchAdminError
|
||||
? `${err.status}: ${err.message}`
|
||||
: "Create failed.",
|
||||
)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New tenant</DialogTitle>
|
||||
<DialogDescription>
|
||||
Creates an empty config dir at{" "}
|
||||
<code className="font-mono text-xs">
|
||||
$INDEX_CONFIG_DIR/<id>/
|
||||
</code>
|
||||
. Add corpora separately. Names are alphanumeric, dash, or
|
||||
underscore.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="new-tenant-id">Tenant id</Label>
|
||||
<Input
|
||||
id="new-tenant-id"
|
||||
value={id}
|
||||
onChange={(e) => setId(e.target.value)}
|
||||
placeholder="acme"
|
||||
data-action="tenant-form-id"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={saving}
|
||||
data-action="tenant-form-cancel"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={submit}
|
||||
disabled={saving || !id}
|
||||
data-action="tenant-form-save"
|
||||
>
|
||||
{saving ? (
|
||||
<RefreshCw className="size-4 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="size-4" />
|
||||
)}
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
const CORPUS_CONFIG_TEMPLATE = `{
|
||||
"corpus": "docs",
|
||||
"sources": [
|
||||
{
|
||||
"type": "arcadia",
|
||||
"list_url": "/api/v1/files?tenant_id={tenant}",
|
||||
"item_url": "/api/v1/files/{id}/content",
|
||||
"title_field": "name",
|
||||
"id_field": "id",
|
||||
"mtime_field": "updated_at",
|
||||
"tags": ["uploaded"]
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
function CorpusEditor({
|
||||
editor,
|
||||
tenants,
|
||||
onClose,
|
||||
onSaved,
|
||||
onError,
|
||||
}: {
|
||||
editor:
|
||||
| { kind: "new-corpus"; tenant: string }
|
||||
| { kind: "edit-corpus"; tenant: string; corpus: string }
|
||||
| null
|
||||
tenants: TenantSummary[]
|
||||
onClose: () => void
|
||||
onSaved: (msg?: string) => Promise<void>
|
||||
onError: (msg: string) => void
|
||||
}) {
|
||||
const [tenant, setTenant] = useState("")
|
||||
const [text, setText] = useState("")
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const isEdit = editor?.kind === "edit-corpus"
|
||||
const headerCorpus = isEdit ? editor.corpus : ""
|
||||
|
||||
// Hydrate on open: load existing config for edit, template for new.
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
setTenant(editor.tenant)
|
||||
if (editor.kind === "edit-corpus") {
|
||||
setLoading(true)
|
||||
searchAdmin
|
||||
.getCorpus(editor.tenant, editor.corpus)
|
||||
.then((res) => {
|
||||
setText(JSON.stringify(res.config, null, 2))
|
||||
})
|
||||
.catch((err) => {
|
||||
onError(
|
||||
err instanceof SearchAdminError
|
||||
? `${err.status}: ${err.message}`
|
||||
: "Load failed.",
|
||||
)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
} else {
|
||||
setText(CORPUS_CONFIG_TEMPLATE)
|
||||
}
|
||||
}, [editor, onError])
|
||||
|
||||
if (!editor) return null
|
||||
|
||||
const submit = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const parsed = JSON.parse(text)
|
||||
if (typeof parsed !== "object" || parsed === null) {
|
||||
throw new Error("config must be a JSON object")
|
||||
}
|
||||
if (editor.kind === "new-corpus") {
|
||||
const corpus = parsed.corpus
|
||||
if (typeof corpus !== "string" || !corpus) {
|
||||
throw new Error('config must have a string "corpus" field')
|
||||
}
|
||||
await searchAdmin.createCorpus(tenant, parsed)
|
||||
await onSaved(`Created ${tenant}/${corpus}.`)
|
||||
} else {
|
||||
await searchAdmin.updateCorpus(editor.tenant, editor.corpus, parsed)
|
||||
await onSaved(`Updated ${editor.tenant}/${editor.corpus}.`)
|
||||
}
|
||||
} catch (err) {
|
||||
onError(
|
||||
err instanceof SearchAdminError
|
||||
? `${err.status}: ${err.message}`
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: "Save failed.",
|
||||
)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEdit ? `Edit ${editor.tenant}/${headerCorpus}` : "New corpus"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
JSON config matching arcadia-search's IndexerConfig schema. The{" "}
|
||||
<code className="font-mono text-xs">tenant</code> field is set
|
||||
from the URL — your value is overwritten.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{!isEdit ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="corpus-tenant">Tenant</Label>
|
||||
<Select value={tenant} onValueChange={setTenant}>
|
||||
<SelectTrigger
|
||||
id="corpus-tenant"
|
||||
data-action="corpus-form-tenant"
|
||||
>
|
||||
<SelectValue placeholder="Pick a tenant" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tenants.map((t) => (
|
||||
<SelectItem key={t.id} value={t.id}>
|
||||
{t.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="corpus-config">Config JSON</Label>
|
||||
<Textarea
|
||||
id="corpus-config"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
rows={20}
|
||||
className="font-mono text-xs"
|
||||
spellCheck={false}
|
||||
disabled={loading}
|
||||
data-action="corpus-form-config"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={saving}
|
||||
data-action="corpus-form-cancel"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={submit}
|
||||
disabled={saving || loading || !tenant}
|
||||
data-action="corpus-form-save"
|
||||
>
|
||||
{saving ? (
|
||||
<RefreshCw className="size-4 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="size-4" />
|
||||
)}
|
||||
{isEdit ? "Update" : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
1067
app/routes/secrets.tsx
Normal file
1067
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 {
|
||||
Check,
|
||||
X,
|
||||
Loader2,
|
||||
Cpu,
|
||||
Palette,
|
||||
User as UserIcon,
|
||||
@@ -12,7 +9,17 @@ import {
|
||||
Trash2,
|
||||
} from "lucide-react"
|
||||
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 { probeProxy, type LLMProxyProvider } from "~/lib/arcadia/llm-proxy"
|
||||
import { LlmConfigurationsPanel } from "~/components/settings/llm-configurations-panel"
|
||||
import { AppShell } from "~/components/layout/app-shell"
|
||||
import { Button } from "~/components/ui/button"
|
||||
import {
|
||||
@@ -22,15 +29,6 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} 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 {
|
||||
loadActiveAgentId,
|
||||
newAgentId,
|
||||
@@ -71,51 +69,92 @@ const sections: {
|
||||
{ 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() {
|
||||
const settings = useLLMSettings()
|
||||
const [draft, setDraft] = useState<LLMSettings>(settings)
|
||||
const [savedAt, setSavedAt] = useState<number | null>(null)
|
||||
const [test, setTest] = useState<TestState>({ kind: "idle" })
|
||||
const arcadia = useArcadiaClient()
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(settings)
|
||||
}, [settings])
|
||||
|
||||
const runTest = async () => {
|
||||
setTest({ kind: "running" })
|
||||
const ac = new AbortController()
|
||||
const timeout = setTimeout(() => ac.abort(), 4000)
|
||||
const testConnection = async (
|
||||
s: LLMProvidersSettings,
|
||||
): Promise<{ ok: boolean; message: string }> => {
|
||||
try {
|
||||
const rows = await listModels({ baseURL: draft.baseURL, signal: ac.signal })
|
||||
setTest({ kind: "ok", count: rows.length })
|
||||
} catch (e) {
|
||||
setTest({
|
||||
kind: "fail",
|
||||
reason: e instanceof Error ? e.message : String(e),
|
||||
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
|
||||
|
||||
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,
|
||||
})
|
||||
|
||||
// Proxy mode: round-trip a 1-token chat to verify auth → secret
|
||||
// resolution → upstream dispatch end-to-end. Maps the contract's
|
||||
// specific error codes to user-facing messages.
|
||||
if (s.mode === "proxy") {
|
||||
return probeProxy(arcadia, {
|
||||
provider: s.providerId as LLMProxyProvider,
|
||||
model: s.model || (s.providerId === "anthropic" ? "claude-opus-4-7" : "gpt-4o-mini"),
|
||||
secretName: s.secretName || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
// 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"
|
||||
: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1")
|
||||
// Resolve key for the probe (lmstudio doesn't need one).
|
||||
let apiKey: string | undefined
|
||||
if (s.providerId !== "lmstudio" && s.secretName) {
|
||||
try {
|
||||
const res = await arcadia.GET<{ data: { value: string } }>(
|
||||
`/api/v1/secrets/${encodeURIComponent(s.secretName)}`,
|
||||
)
|
||||
apiKey = res.data.value
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
if (/404|not[_ ]found/i.test(msg)) {
|
||||
return {
|
||||
ok: false,
|
||||
message: `No vault secret named "${s.secretName}". Create it under /secrets first (paste the API key as the Value), then enter the secret's name here.`,
|
||||
}
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
const ac = new AbortController()
|
||||
const t = setTimeout(() => ac.abort(), 5000)
|
||||
try {
|
||||
const rows = await listModels({ baseURL, apiKey, signal: ac.signal })
|
||||
return { ok: true, message: `Connected. ${rows.length} model(s) reachable.` }
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
clearTimeout(t)
|
||||
}
|
||||
}
|
||||
|
||||
const dirty =
|
||||
draft.baseURL !== settings.baseURL ||
|
||||
draft.contextTokens !== settings.contextTokens ||
|
||||
draft.responseBudget !== settings.responseBudget
|
||||
|
||||
const save = () => {
|
||||
saveLLMSettings(draft)
|
||||
setSavedAt(Date.now())
|
||||
// Anthropic doesn't expose a /models list; we just confirm adapter built.
|
||||
return { ok: true, message: `Adapter ready (${adapter.label ?? adapter.id}).` }
|
||||
} catch (e) {
|
||||
return { ok: false, message: e instanceof Error ? e.message : String(e) }
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
setDraft(DEFAULT_SETTINGS)
|
||||
}
|
||||
|
||||
const [section, setSection] = useState<SectionId>(() => {
|
||||
@@ -131,11 +170,11 @@ export default function SettingsRoute() {
|
||||
}, [section])
|
||||
|
||||
return (
|
||||
<AppShell title="Settings">
|
||||
<div className="grid gap-6 md:grid-cols-[14rem_1fr]">
|
||||
<AppShell>
|
||||
<div className="grid gap-6 pt-10 md:grid-cols-[14rem_1fr] md:pt-0">
|
||||
<nav
|
||||
aria-label="Settings sections"
|
||||
className="flex flex-row gap-1 overflow-x-auto md:flex-col md:gap-0.5"
|
||||
className="flex flex-row flex-wrap gap-1 md:flex-col md:flex-nowrap md:gap-0.5"
|
||||
>
|
||||
{sections.map((s) => {
|
||||
const Icon = s.icon
|
||||
@@ -173,151 +212,35 @@ export default function SettingsRoute() {
|
||||
|
||||
<div className="min-w-0">
|
||||
{section === "llm" && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>LLM</CardTitle>
|
||||
<CardDescription>
|
||||
Configure the local model endpoint and context budgets used
|
||||
by the Assistant.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-5">
|
||||
<Field
|
||||
label="Base URL"
|
||||
hint="OpenAI-compatible endpoint. LM Studio defaults to http://localhost:1234/v1."
|
||||
>
|
||||
<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>
|
||||
<div className="flex flex-col gap-4">
|
||||
<LlmConfigurationsPanel />
|
||||
|
||||
<Field
|
||||
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,
|
||||
}))
|
||||
}
|
||||
<details className="rounded-md border bg-muted/20 px-3 py-2 text-sm">
|
||||
<summary className="cursor-pointer text-muted-foreground">
|
||||
Advanced: tweak the active session settings (transport, system prompt,
|
||||
context budget) directly
|
||||
</summary>
|
||||
<div className="pt-3">
|
||||
<LLMProvidersSettingsCard
|
||||
onTest={testConnection}
|
||||
hideTransportToggle={false}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<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">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
data-action="settings-save"
|
||||
onClick={save}
|
||||
disabled={!dirty}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
data-action="settings-test"
|
||||
variant="outline"
|
||||
onClick={runTest}
|
||||
disabled={test.kind === "running"}
|
||||
>
|
||||
{test.kind === "running" ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : test.kind === "ok" ? (
|
||||
<Check className="size-4 text-emerald-600" />
|
||||
) : test.kind === "fail" ? (
|
||||
<X className="size-4 text-destructive" />
|
||||
) : null}
|
||||
Test connection
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => resetProviderSettings()}
|
||||
data-action="settings-reset"
|
||||
variant="outline"
|
||||
onClick={reset}
|
||||
>
|
||||
Reset to defaults
|
||||
</Button>
|
||||
{savedAt && !dirty && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Saved.
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Need to manage stored keys? See <a href="/secrets" className="underline">Secrets</a>.
|
||||
</span>
|
||||
)}
|
||||
{test.kind === "ok" && (
|
||||
<span className="text-sm text-emerald-700 dark:text-emerald-400">
|
||||
{test.count} model{test.count === 1 ? "" : "s"} available.
|
||||
</span>
|
||||
)}
|
||||
{test.kind === "fail" && (
|
||||
<span
|
||||
className="text-sm text-destructive"
|
||||
title={test.reason}
|
||||
>
|
||||
Failed: {test.reason.slice(0, 60)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{section === "agents" && <AgentsPanel />}
|
||||
|
||||
44
app/routes/signup.tsx
Normal file
44
app/routes/signup.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useEffect } from "react"
|
||||
import { useNavigate } from "react-router"
|
||||
|
||||
import { SignupForm } from "@crema/arcadia-auth-ui"
|
||||
import { useBrand } from "~/lib/identity"
|
||||
import { pageTitle } from "~/lib/page-meta"
|
||||
import { persistFromArcadiaLogin, useSession } from "~/lib/session"
|
||||
import { AuthBrand, AuthShell } from "~/components/auth/auth-shell"
|
||||
|
||||
export const meta = () => pageTitle("Create account")
|
||||
|
||||
export default function SignupRoute() {
|
||||
const navigate = useNavigate()
|
||||
const session = useSession()
|
||||
const brand = useBrand()
|
||||
|
||||
useEffect(() => {
|
||||
if (session) navigate("/", { replace: true })
|
||||
}, [session, navigate])
|
||||
|
||||
return (
|
||||
<AuthShell>
|
||||
<SignupForm
|
||||
brand={<AuthBrand />}
|
||||
heading={`Join ${brand.name}`}
|
||||
onSignin={() => navigate("/login")}
|
||||
onSuccess={async ({ tokens, user, emailVerificationSent }) => {
|
||||
if (tokens) {
|
||||
persistFromArcadiaLogin(tokens, user)
|
||||
navigate("/", { replace: true })
|
||||
return
|
||||
}
|
||||
// No tokens returned — verification email gating. Bounce to login.
|
||||
navigate(
|
||||
emailVerificationSent
|
||||
? "/login?verify=sent"
|
||||
: "/login",
|
||||
{ replace: true },
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
655
app/routes/sso.tsx
Normal file
655
app/routes/sso.tsx
Normal file
@@ -0,0 +1,655 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import {
|
||||
CheckCircle2,
|
||||
KeyRound,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
X,
|
||||
} 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 { 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 { Switch } from "~/components/ui/switch"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"
|
||||
import { Textarea } from "~/components/ui/textarea"
|
||||
import {
|
||||
createIdentityProvider,
|
||||
deleteIdentityProvider,
|
||||
destroySamlSession,
|
||||
listIdentityProviders,
|
||||
listSamlSessions,
|
||||
updateIdentityProvider,
|
||||
type IdentityProvider,
|
||||
type IdentityProviderInput,
|
||||
type SamlSession,
|
||||
} from "~/lib/arcadia/sso"
|
||||
import { pageTitle } from "~/lib/page-meta"
|
||||
import { useSession } from "~/lib/session"
|
||||
import { useRegisterContext } from "@crema/aifirst-ui/context"
|
||||
|
||||
export const meta = () => pageTitle("SSO")
|
||||
|
||||
type Editor =
|
||||
| { kind: "create" }
|
||||
| { kind: "edit"; idp: IdentityProvider }
|
||||
| null
|
||||
|
||||
export default function SsoRoute() {
|
||||
const session = useSession()
|
||||
const arcadia = useArcadiaClient()
|
||||
|
||||
const [idps, setIdps] = useState<IdentityProvider[]>([])
|
||||
const [sessions, setSessions] = useState<SamlSession[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [info, setInfo] = useState<string | null>(null)
|
||||
const [editor, setEditor] = useState<Editor>(null)
|
||||
const [pendingDelete, setPendingDelete] = useState<IdentityProvider | null>(null)
|
||||
const [pendingSessionDestroy, setPendingSessionDestroy] = useState<SamlSession | null>(null)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
try {
|
||||
const [i, s] = await Promise.all([
|
||||
listIdentityProviders(arcadia).catch(() => [] as IdentityProvider[]),
|
||||
listSamlSessions(arcadia).catch(() => [] as SamlSession[]),
|
||||
])
|
||||
setIdps(i)
|
||||
setSessions(s)
|
||||
} catch (err) {
|
||||
setError(err instanceof ArcadiaError ? err.message : "Failed to load SSO data.")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [arcadia])
|
||||
|
||||
useEffect(() => {
|
||||
if (session) refresh()
|
||||
}, [session, refresh])
|
||||
|
||||
useRegisterContext("sso", {
|
||||
identity_providers: idps.length,
|
||||
enabled_idps: idps.filter((i) => i.enabled).length,
|
||||
active_sessions: sessions.length,
|
||||
})
|
||||
|
||||
const idpColumns = useMemo<Column<IdentityProvider>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "name",
|
||||
header: "Name",
|
||||
accessor: "name",
|
||||
sortable: true,
|
||||
cell: (i) => (
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">{i.name}</span>
|
||||
<code className="font-mono text-[10px] text-muted-foreground">{i.entity_id}</code>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "enabled",
|
||||
header: "Enabled",
|
||||
accessor: "enabled",
|
||||
sortable: true,
|
||||
cell: (i) => (
|
||||
<BadgeCell label={i.enabled ? "enabled" : "disabled"} tone={i.enabled ? "success" : "default"} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "cert",
|
||||
header: "Certificate",
|
||||
cell: (i) =>
|
||||
i.has_certificate ? (
|
||||
<Badge variant="secondary" className="font-mono text-[10px]">
|
||||
<ShieldCheck className="mr-1 size-3" /> set
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="font-mono text-[10px]">
|
||||
missing
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "sso_url",
|
||||
header: "SSO URL",
|
||||
cell: (i) => (
|
||||
<code className="font-mono text-xs text-muted-foreground">{i.sso_url}</code>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "updated",
|
||||
header: "Updated",
|
||||
accessor: "updated_at",
|
||||
sortable: true,
|
||||
cell: (i) => <DateCell value={i.updated_at} format="short" />,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
align: "right",
|
||||
cell: (i) => {
|
||||
const items: ActionItem[] = [
|
||||
{
|
||||
id: "edit",
|
||||
label: "Edit",
|
||||
dataAction: `idp-${i.id}-edit`,
|
||||
onSelect: () => setEditor({ kind: "edit", idp: i }),
|
||||
},
|
||||
{
|
||||
id: i.enabled ? "disable" : "enable",
|
||||
label: i.enabled ? "Disable" : "Enable",
|
||||
dataAction: `idp-${i.id}-toggle`,
|
||||
onSelect: async () => {
|
||||
try {
|
||||
await updateIdentityProvider(arcadia, i.id, { enabled: !i.enabled })
|
||||
setInfo(`${i.name} ${i.enabled ? "disabled" : "enabled"}.`)
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
setError(err instanceof ArcadiaError ? err.message : "Toggle failed.")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "delete",
|
||||
label: "Delete",
|
||||
icon: <Trash2 className="size-4" />,
|
||||
destructive: true,
|
||||
dataAction: `idp-${i.id}-delete`,
|
||||
onSelect: () => setPendingDelete(i),
|
||||
},
|
||||
]
|
||||
return <ActionsCell items={items} triggerDataAction={`idp-${i.id}-actions`} />
|
||||
},
|
||||
},
|
||||
],
|
||||
[arcadia, refresh],
|
||||
)
|
||||
|
||||
const idpTable = useTable<IdentityProvider>({
|
||||
data: idps,
|
||||
columns: idpColumns,
|
||||
getRowId: (i) => i.id,
|
||||
initialPageSize: 25,
|
||||
})
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="flex flex-col gap-4">
|
||||
<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">Single sign-on</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
SAML identity providers configured for the current tenant, plus the active SAML
|
||||
session pool.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={refresh} disabled={loading} data-action="sso-refresh">
|
||||
<RefreshCw className={`size-4 ${loading ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setEditor({ kind: "create" })} data-action="sso-create">
|
||||
<Plus className="size-4" />
|
||||
New IdP
|
||||
</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}
|
||||
|
||||
<Tabs defaultValue="idps">
|
||||
<TabsList>
|
||||
<TabsTrigger value="idps" data-action="sso-tab-idps">
|
||||
Identity providers ({idps.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="sessions" data-action="sso-tab-sessions">
|
||||
Active sessions ({sessions.length})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="idps" className="pt-4">
|
||||
<Card>
|
||||
<CardContent className="relative p-0">
|
||||
<LoadingOverlay active={loading && idps.length === 0} label="Loading IdPs…" />
|
||||
{idpTable.total === 0 && !loading ? (
|
||||
<EmptyState
|
||||
icon={<KeyRound className="size-6" />}
|
||||
title="No identity providers."
|
||||
description="Connect a SAML IdP (Okta, Azure AD, Google Workspace, etc.) to enable SSO for this tenant."
|
||||
className="py-12"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<DataTable
|
||||
columns={idpColumns}
|
||||
rows={idpTable.pageRows}
|
||||
getRowId={(i) => i.id}
|
||||
sort={idpTable.sort}
|
||||
onSortToggle={idpTable.toggleSort}
|
||||
loading={loading && idps.length > 0}
|
||||
stickyHeader
|
||||
/>
|
||||
<Pagination
|
||||
page={idpTable.page}
|
||||
pageSize={idpTable.pageSize}
|
||||
total={idpTable.total}
|
||||
onPageChange={idpTable.setPage}
|
||||
onPageSizeChange={idpTable.setPageSize}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="sessions" className="pt-4">
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{sessions.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No active SAML sessions."
|
||||
description="Sessions appear here once users authenticate via the IdP."
|
||||
className="py-12"
|
||||
/>
|
||||
) : (
|
||||
<ul className="divide-y border-y">
|
||||
{sessions.map((s) => (
|
||||
<li
|
||||
key={s.id}
|
||||
className="flex items-center 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">
|
||||
<code className="font-mono text-xs">{s.name_id ?? s.user_id}</code>
|
||||
{s.expires_at && new Date(s.expires_at).getTime() < Date.now() ? (
|
||||
<Badge variant="destructive">expired</Badge>
|
||||
) : (
|
||||
<Badge>active</Badge>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
session_index: {s.session_index ?? "—"} · idp:{" "}
|
||||
{s.idp_id.slice(0, 8)}… · started{" "}
|
||||
{new Date(s.inserted_at).toLocaleString()}
|
||||
{s.expires_at
|
||||
? ` · expires ${new Date(s.expires_at).toLocaleString()}`
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setPendingSessionDestroy(s)}
|
||||
data-action={`sso-session-${s.id}-destroy`}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
Destroy
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={pendingDelete !== null}
|
||||
onOpenChange={(o) => !o && setPendingDelete(null)}
|
||||
title="Delete identity provider?"
|
||||
description={
|
||||
pendingDelete
|
||||
? `${pendingDelete.name} will be removed. Existing SAML sessions remain valid until they expire.`
|
||||
: ""
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
variant="danger"
|
||||
onConfirm={async () => {
|
||||
if (!pendingDelete) return
|
||||
try {
|
||||
await deleteIdentityProvider(arcadia, pendingDelete.id)
|
||||
setPendingDelete(null)
|
||||
setInfo("Identity provider deleted.")
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
setError(err instanceof ArcadiaError ? err.message : "Delete failed.")
|
||||
setPendingDelete(null)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={pendingSessionDestroy !== null}
|
||||
onOpenChange={(o) => !o && setPendingSessionDestroy(null)}
|
||||
title="Destroy SAML session?"
|
||||
description={
|
||||
pendingSessionDestroy
|
||||
? `Session for ${pendingSessionDestroy.name_id ?? pendingSessionDestroy.user_id} will be revoked.`
|
||||
: ""
|
||||
}
|
||||
confirmLabel="Destroy"
|
||||
variant="danger"
|
||||
onConfirm={async () => {
|
||||
if (!pendingSessionDestroy) return
|
||||
try {
|
||||
await destroySamlSession(arcadia, pendingSessionDestroy.id)
|
||||
setPendingSessionDestroy(null)
|
||||
setInfo("Session destroyed.")
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
setError(err instanceof ArcadiaError ? err.message : "Destroy failed.")
|
||||
setPendingSessionDestroy(null)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<IdpEditorDialog
|
||||
state={editor}
|
||||
onClose={() => setEditor(null)}
|
||||
onSaved={async (msg) => {
|
||||
setEditor(null)
|
||||
if (msg) setInfo(msg)
|
||||
await refresh()
|
||||
}}
|
||||
onError={setError}
|
||||
/>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
function IdpEditorDialog({
|
||||
state,
|
||||
onClose,
|
||||
onSaved,
|
||||
onError,
|
||||
}: {
|
||||
state: Editor
|
||||
onClose: () => void
|
||||
onSaved: (msg?: string) => Promise<void>
|
||||
onError: (msg: string | null) => void
|
||||
}) {
|
||||
const arcadia = useArcadiaClient()
|
||||
const open = state !== null
|
||||
const isEdit = state?.kind === "edit"
|
||||
const initial = isEdit ? state.idp : null
|
||||
|
||||
const [name, setName] = useState("")
|
||||
const [entityId, setEntityId] = useState("")
|
||||
const [ssoUrl, setSsoUrl] = useState("")
|
||||
const [sloUrl, setSloUrl] = useState("")
|
||||
const [metadataUrl, setMetadataUrl] = useState("")
|
||||
const [callbackUrl, setCallbackUrl] = useState("")
|
||||
const [signRequests, setSignRequests] = useState(false)
|
||||
const [enabled, setEnabled] = useState(true)
|
||||
const [certificate, setCertificate] = useState("")
|
||||
const [attrJson, setAttrJson] = useState("{}")
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
if (initial) {
|
||||
setName(initial.name)
|
||||
setEntityId(initial.entity_id)
|
||||
setSsoUrl(initial.sso_url)
|
||||
setSloUrl(initial.slo_url ?? "")
|
||||
setMetadataUrl(initial.metadata_url ?? "")
|
||||
setCallbackUrl(initial.callback_url ?? "")
|
||||
setSignRequests(initial.sign_requests)
|
||||
setEnabled(initial.enabled)
|
||||
setCertificate("") // never pre-fill
|
||||
setAttrJson(JSON.stringify(initial.attribute_mapping ?? {}, null, 2))
|
||||
} else {
|
||||
setName("")
|
||||
setEntityId("")
|
||||
setSsoUrl("")
|
||||
setSloUrl("")
|
||||
setMetadataUrl("")
|
||||
setCallbackUrl("")
|
||||
setSignRequests(false)
|
||||
setEnabled(true)
|
||||
setCertificate("")
|
||||
setAttrJson('{\n "email": "email",\n "first_name": "givenName",\n "last_name": "surname"\n}')
|
||||
}
|
||||
}, [open, initial])
|
||||
|
||||
const submit = async () => {
|
||||
onError(null)
|
||||
setSaving(true)
|
||||
try {
|
||||
let attribute_mapping: Record<string, string> = {}
|
||||
try {
|
||||
attribute_mapping = attrJson.trim() === "" ? {} : JSON.parse(attrJson)
|
||||
} catch {
|
||||
throw new Error("Attribute mapping must be valid JSON (key→value strings).")
|
||||
}
|
||||
const input: IdentityProviderInput = {
|
||||
name,
|
||||
entity_id: entityId,
|
||||
sso_url: ssoUrl,
|
||||
slo_url: sloUrl || null,
|
||||
metadata_url: metadataUrl || null,
|
||||
callback_url: callbackUrl || null,
|
||||
sign_requests: signRequests,
|
||||
enabled,
|
||||
attribute_mapping,
|
||||
}
|
||||
if (certificate.trim()) input.certificate = certificate
|
||||
|
||||
if (isEdit && initial) {
|
||||
await updateIdentityProvider(arcadia, initial.id, input)
|
||||
await onSaved("Identity provider updated.")
|
||||
} else {
|
||||
await createIdentityProvider(arcadia, input)
|
||||
await onSaved("Identity provider 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 ${initial?.name}` : "New identity provider"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit
|
||||
? "Leave the certificate field blank to keep the existing one."
|
||||
: "Paste values from the IdP metadata XML, or supply the metadata URL and let arcadia fetch the rest."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="col-span-2 flex flex-col gap-1.5">
|
||||
<Label htmlFor="idp-name">Name</Label>
|
||||
<Input
|
||||
id="idp-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Okta — Production"
|
||||
data-action="idp-form-name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="idp-entity">Entity ID</Label>
|
||||
<Input
|
||||
id="idp-entity"
|
||||
value={entityId}
|
||||
onChange={(e) => setEntityId(e.target.value)}
|
||||
placeholder="https://idp.example.com/saml"
|
||||
className="font-mono text-xs"
|
||||
data-action="idp-form-entity"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="idp-sso">SSO URL</Label>
|
||||
<Input
|
||||
id="idp-sso"
|
||||
value={ssoUrl}
|
||||
onChange={(e) => setSsoUrl(e.target.value)}
|
||||
placeholder="https://idp.example.com/saml/sso"
|
||||
className="font-mono text-xs"
|
||||
data-action="idp-form-sso"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="idp-slo">SLO URL (optional)</Label>
|
||||
<Input
|
||||
id="idp-slo"
|
||||
value={sloUrl}
|
||||
onChange={(e) => setSloUrl(e.target.value)}
|
||||
placeholder="https://idp.example.com/saml/slo"
|
||||
className="font-mono text-xs"
|
||||
data-action="idp-form-slo"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="idp-metadata">Metadata URL (optional)</Label>
|
||||
<Input
|
||||
id="idp-metadata"
|
||||
value={metadataUrl}
|
||||
onChange={(e) => setMetadataUrl(e.target.value)}
|
||||
placeholder="https://idp.example.com/metadata.xml"
|
||||
className="font-mono text-xs"
|
||||
data-action="idp-form-metadata"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2 flex flex-col gap-1.5">
|
||||
<Label htmlFor="idp-callback">Callback URL (SP ACS, optional override)</Label>
|
||||
<Input
|
||||
id="idp-callback"
|
||||
value={callbackUrl}
|
||||
onChange={(e) => setCallbackUrl(e.target.value)}
|
||||
placeholder="https://your-arcadia-app/api/v1/auth/saml/callback"
|
||||
className="font-mono text-xs"
|
||||
data-action="idp-form-callback"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2 flex flex-col gap-1.5">
|
||||
<Label htmlFor="idp-cert">
|
||||
Certificate (PEM){" "}
|
||||
<span className="font-normal text-muted-foreground">
|
||||
{isEdit ? (initial?.has_certificate ? " · current cert kept if blank" : " · required") : " · required"}
|
||||
</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="idp-cert"
|
||||
value={certificate}
|
||||
onChange={(e) => setCertificate(e.target.value)}
|
||||
rows={6}
|
||||
placeholder="-----BEGIN CERTIFICATE-----..."
|
||||
className="font-mono text-[11px]"
|
||||
spellCheck={false}
|
||||
data-action="idp-form-certificate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2 flex flex-col gap-1.5">
|
||||
<Label htmlFor="idp-attrs">Attribute mapping (JSON: arcadia field → SAML attribute)</Label>
|
||||
<Textarea
|
||||
id="idp-attrs"
|
||||
value={attrJson}
|
||||
onChange={(e) => setAttrJson(e.target.value)}
|
||||
rows={5}
|
||||
className="font-mono text-xs"
|
||||
spellCheck={false}
|
||||
data-action="idp-form-attrs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-md border px-3 py-2">
|
||||
<Label className="text-sm">Sign requests</Label>
|
||||
<Switch
|
||||
checked={signRequests}
|
||||
onCheckedChange={setSignRequests}
|
||||
data-action="idp-form-sign-requests"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-md border px-3 py-2">
|
||||
<Label className="text-sm">Enabled</Label>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onCheckedChange={setEnabled}
|
||||
data-action="idp-form-enabled"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={submit}
|
||||
disabled={saving || !name || !entityId || !ssoUrl}
|
||||
data-action="idp-form-save"
|
||||
>
|
||||
{saving ? <RefreshCw className="size-4 animate-spin" /> : <CheckCircle2 className="size-4" />}
|
||||
{isEdit ? "Save" : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
1027
app/routes/status-page.tsx
Normal file
1027
app/routes/status-page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
843
app/routes/storage.tsx
Normal file
843
app/routes/storage.tsx
Normal file
@@ -0,0 +1,843 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
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 { useRegisterContext } from "@crema/aifirst-ui/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],
|
||||
)
|
||||
useRegisterContext("storage", summary)
|
||||
|
||||
const table = useTable<StorageConfig>({
|
||||
data: configs,
|
||||
columns,
|
||||
getRowId: (c) => c.id,
|
||||
initialPageSize: 25,
|
||||
initialSearch: search,
|
||||
})
|
||||
useEffect(() => {
|
||||
table.setSearch(search)
|
||||
}, [search, table])
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="flex flex-col gap-4">
|
||||
<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]}`
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { Link } from "react-router"
|
||||
import { useCallback, useEffect, useMemo, useState, type FormEvent } from "react"
|
||||
import { Pause, Play, Plus, RefreshCw } from "lucide-react"
|
||||
|
||||
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
|
||||
@@ -18,6 +17,7 @@ import { SearchInput } from "@crema/search-ui"
|
||||
import { AlertBanner, ConfirmDialog, EmptyState, LoadingOverlay } from "@crema/feedback-ui"
|
||||
|
||||
import { AppShell } from "~/components/layout/app-shell"
|
||||
import { PageHeader } from "~/components/layout/page-header"
|
||||
import { Button } from "~/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
@@ -26,17 +26,28 @@ import {
|
||||
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 {
|
||||
activateTenant,
|
||||
deactivateTenant,
|
||||
listTenants,
|
||||
provisionTenant,
|
||||
suspendTenant,
|
||||
type Tenant,
|
||||
type TenantStatus,
|
||||
} from "~/lib/arcadia/tenants"
|
||||
import { pageTitle } from "~/lib/page-meta"
|
||||
import { useSession } from "~/lib/session"
|
||||
import { useRegisterAdminContext } from "~/lib/admin-context"
|
||||
import { useRegisterContext } from "@crema/aifirst-ui/context"
|
||||
|
||||
export const meta = () => pageTitle("Tenants")
|
||||
|
||||
@@ -54,6 +65,7 @@ export default function TenantsRoute() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [pending, setPending] = useState<PendingAction>(null)
|
||||
const [search, setSearch] = useState("")
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setError(null)
|
||||
@@ -160,7 +172,7 @@ export default function TenantsRoute() {
|
||||
}),
|
||||
[tenants],
|
||||
)
|
||||
useRegisterAdminContext("tenants", tenantSummary)
|
||||
useRegisterContext("tenants", tenantSummary)
|
||||
|
||||
const table = useTable<Tenant>({
|
||||
data: tenants,
|
||||
@@ -174,39 +186,13 @@ export default function TenantsRoute() {
|
||||
table.setSearch(search)
|
||||
}, [search, table])
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<AppShell title="Tenants">
|
||||
<div className="p-8">
|
||||
<Card className="max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>Sign in required</CardTitle>
|
||||
<CardDescription>
|
||||
Tenant administration requires an admin session.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild>
|
||||
<Link to="/login?next=/tenants">Sign in</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell title="Tenants">
|
||||
<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">Tenants</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Multi-tenant workspaces on this arcadia deployment.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<AppShell>
|
||||
<PageHeader
|
||||
title="Tenants"
|
||||
description="Multi-tenant workspaces on this arcadia deployment."
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -217,12 +203,17 @@ export default function TenantsRoute() {
|
||||
<RefreshCw className={`size-4 ${loading ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button size="sm" disabled data-action="tenants-create">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
data-action="tenants-create"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
New tenant
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
|
||||
@@ -276,8 +267,16 @@ export default function TenantsRoute() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<TenantCreateDialog
|
||||
open={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onCreated={async () => {
|
||||
setCreateOpen(false)
|
||||
await refresh()
|
||||
}}
|
||||
onError={setError}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={pending?.kind === "suspend"}
|
||||
onOpenChange={(o) => !o && setPending(null)}
|
||||
@@ -356,3 +355,218 @@ function rowActions(
|
||||
})
|
||||
return items
|
||||
}
|
||||
|
||||
function formatArcadiaError(err: unknown, fallback: string): string {
|
||||
if (!(err instanceof ArcadiaError)) return fallback
|
||||
// 422 validation errors carry per-field reasons in `details`. Shape from
|
||||
// Ecto's FallbackController is typically `{ field: ["msg1", "msg2"] }` or
|
||||
// nested `{ tenant: { slug: ["has already been taken"] } }`. Flatten so
|
||||
// the user sees what to fix instead of a generic "validation failed".
|
||||
if (err.isValidation && err.details) {
|
||||
const lines: string[] = []
|
||||
const walk = (obj: unknown, prefix: string) => {
|
||||
if (Array.isArray(obj)) {
|
||||
lines.push(`${prefix}: ${obj.join(", ")}`)
|
||||
} else if (obj && typeof obj === "object") {
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
walk(v, prefix ? `${prefix}.${k}` : k)
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(err.details, "")
|
||||
if (lines.length) return `${err.message} — ${lines.join("; ")}`
|
||||
}
|
||||
return err.message
|
||||
}
|
||||
|
||||
function slugify(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
}
|
||||
|
||||
function TenantCreateDialog({
|
||||
open,
|
||||
onClose,
|
||||
onCreated,
|
||||
onError,
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onCreated: () => Promise<void> | void
|
||||
onError: (msg: string) => void
|
||||
}) {
|
||||
const arcadia = useArcadiaClient()
|
||||
const [name, setName] = useState("")
|
||||
const [slug, setSlug] = useState("")
|
||||
const [slugDirty, setSlugDirty] = useState(false)
|
||||
const [firstName, setFirstName] = useState("")
|
||||
const [lastName, setLastName] = useState("")
|
||||
const [email, setEmail] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setName("")
|
||||
setSlug("")
|
||||
setSlugDirty(false)
|
||||
setFirstName("")
|
||||
setLastName("")
|
||||
setEmail("")
|
||||
setPassword("")
|
||||
setSubmitting(false)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const slugInvalid = slug.length > 0 && !/^[a-z0-9-]+$/.test(slug)
|
||||
const canSubmit =
|
||||
!submitting &&
|
||||
name.trim().length > 0 &&
|
||||
slug.length > 0 &&
|
||||
!slugInvalid &&
|
||||
firstName.trim().length > 0 &&
|
||||
lastName.trim().length > 0 &&
|
||||
email.trim().length > 0 &&
|
||||
password.length >= 8
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!canSubmit) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await provisionTenant(arcadia, {
|
||||
tenant: { name: name.trim(), slug },
|
||||
admin_user: {
|
||||
email: email.trim(),
|
||||
password,
|
||||
first_name: firstName.trim(),
|
||||
last_name: lastName.trim(),
|
||||
},
|
||||
})
|
||||
await onCreated()
|
||||
} catch (err) {
|
||||
onError(formatArcadiaError(err, "Failed to create tenant."))
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>New tenant</DialogTitle>
|
||||
<DialogDescription>
|
||||
Provisions the tenant with default roles, quotas, and an initial admin user.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tenant-name">Tenant name</Label>
|
||||
<Input
|
||||
id="tenant-name"
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value)
|
||||
if (!slugDirty) setSlug(slugify(e.target.value))
|
||||
}}
|
||||
placeholder="Acme Corp"
|
||||
autoFocus
|
||||
data-action="tenants-create-name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tenant-slug">Slug</Label>
|
||||
<Input
|
||||
id="tenant-slug"
|
||||
value={slug}
|
||||
onChange={(e) => {
|
||||
setSlugDirty(true)
|
||||
setSlug(e.target.value)
|
||||
}}
|
||||
placeholder="acme"
|
||||
data-action="tenants-create-slug"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{slugInvalid
|
||||
? "Lowercase letters, digits, and hyphens only."
|
||||
: "Lowercase letters, digits, and hyphens. Used in URLs and the X-Tenant-ID header."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tenant-admin-first-name">Admin first name</Label>
|
||||
<Input
|
||||
id="tenant-admin-first-name"
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
placeholder="Jane"
|
||||
data-action="tenants-create-admin-first-name"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tenant-admin-last-name">Admin last name</Label>
|
||||
<Input
|
||||
id="tenant-admin-last-name"
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
placeholder="Doe"
|
||||
data-action="tenants-create-admin-last-name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tenant-admin-email">Admin email</Label>
|
||||
<Input
|
||||
id="tenant-admin-email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="admin@acme.com"
|
||||
data-action="tenants-create-admin-email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tenant-admin-password">Admin password</Label>
|
||||
<Input
|
||||
id="tenant-admin-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="At least 8 characters"
|
||||
data-action="tenants-create-admin-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={submitting}
|
||||
data-action="tenants-create-cancel"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
data-action="tenants-create-submit"
|
||||
>
|
||||
{submitting ? "Creating…" : "Create tenant"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
1499
app/routes/users.tsx
Normal file
1499
app/routes/users.tsx
Normal file
File diff suppressed because it is too large
Load Diff
900
app/routes/webhooks.tsx
Normal file
900
app/routes/webhooks.tsx
Normal file
@@ -0,0 +1,900 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
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 { useRegisterContext } from "@crema/aifirst-ui/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],
|
||||
)
|
||||
useRegisterContext("webhooks", summary)
|
||||
|
||||
const table = useTable<Webhook>({
|
||||
data: webhooks,
|
||||
columns,
|
||||
getRowId: (w) => w.id,
|
||||
initialPageSize: 25,
|
||||
initialSearch: search,
|
||||
})
|
||||
useEffect(() => {
|
||||
table.setSearch(search)
|
||||
}, [search, table])
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="flex flex-col gap-4">
|
||||
<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
|
||||
}, {})
|
||||
}
|
||||
372
app/themes/console.css
Normal file
372
app/themes/console.css
Normal file
@@ -0,0 +1,372 @@
|
||||
/* ============================================================================
|
||||
* Theme: console
|
||||
*
|
||||
* Mission-control operator surface for the /ai route. Phosphor-amber on a
|
||||
* deep-ink ground; everything system-y is monospace, the agent's prose
|
||||
* lifts into a literary serif. The whole thing is intentionally sparse and
|
||||
* dense at once — vertical rules, hairline borders, turn numbers in the
|
||||
* gutter, a vim-style modeline at the foot of the page.
|
||||
*
|
||||
* Scoped to [data-theme="console"]. Do not import unscoped — would clash
|
||||
* with skyrise / vibespace tokens.
|
||||
* ============================================================================ */
|
||||
|
||||
/* Fonts (JetBrains Mono + Newsreader) are loaded by app.css's top-level
|
||||
* @import url() — keep this file free of @import statements so it can sit
|
||||
* inside the bundle without violating "@import before any rule". */
|
||||
|
||||
[data-theme="console"] {
|
||||
/* ── Palette ─────────────────────────────────────────────────────────── */
|
||||
--console-ink: oklch(0.13 0.02 240); /* page ground */
|
||||
--console-deck: oklch(0.16 0.02 240); /* primary surface */
|
||||
--console-deck-2: oklch(0.20 0.02 240); /* raised surface */
|
||||
--console-rule: oklch(0.30 0.04 240); /* hairline */
|
||||
--console-rule-soft: oklch(0.22 0.02 240);
|
||||
--console-text: oklch(0.93 0.01 80); /* warm off-white */
|
||||
--console-text-2: oklch(0.78 0.02 80);
|
||||
--console-muted: oklch(0.55 0.02 80);
|
||||
--console-muted-2: oklch(0.42 0.02 80);
|
||||
|
||||
--console-amber: oklch(0.81 0.16 65); /* phosphor — operator */
|
||||
--console-amber-deep: oklch(0.65 0.16 55);
|
||||
--console-cyan: oklch(0.78 0.12 205); /* cool — agent */
|
||||
--console-cyan-deep: oklch(0.55 0.10 205);
|
||||
--console-rose: oklch(0.66 0.21 12); /* destructive */
|
||||
--console-mint: oklch(0.78 0.14 155); /* ok / success */
|
||||
|
||||
/* ── Typography ──────────────────────────────────────────────────────── */
|
||||
--console-font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace;
|
||||
--console-font-serif: "Newsreader", "Iowan Old Style", Georgia, serif;
|
||||
|
||||
/* Override theme tokens consumed by AppShell + lib components, so even
|
||||
* pieces we don't restyle inherit the console palette. */
|
||||
--background: var(--console-ink);
|
||||
--foreground: var(--console-text);
|
||||
--card: var(--console-deck);
|
||||
--card-foreground: var(--console-text);
|
||||
--popover: var(--console-deck-2);
|
||||
--popover-foreground: var(--console-text);
|
||||
--primary: var(--console-amber);
|
||||
--primary-foreground: var(--console-ink);
|
||||
--secondary: var(--console-deck-2);
|
||||
--secondary-foreground: var(--console-text);
|
||||
--muted: var(--console-deck-2);
|
||||
--muted-foreground: var(--console-muted);
|
||||
--accent: var(--console-deck-2);
|
||||
--accent-foreground: var(--console-text);
|
||||
--destructive: var(--console-rose);
|
||||
--destructive-foreground: var(--console-text);
|
||||
--border: var(--console-rule-soft);
|
||||
--input: var(--console-rule);
|
||||
--ring: var(--console-amber);
|
||||
|
||||
--font-sans: var(--console-font-mono);
|
||||
--font-heading: var(--console-font-mono);
|
||||
--font-ai-prose: var(--console-font-serif);
|
||||
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* ── Page atmosphere ───────────────────────────────────────────────────── */
|
||||
[data-theme="console"] {
|
||||
background:
|
||||
/* faint scanline */
|
||||
repeating-linear-gradient(
|
||||
to bottom,
|
||||
rgba(255, 255, 255, 0) 0,
|
||||
rgba(255, 255, 255, 0) 2px,
|
||||
rgba(255, 255, 255, 0.012) 2px,
|
||||
rgba(255, 255, 255, 0.012) 3px
|
||||
),
|
||||
/* corner phosphor bloom */
|
||||
radial-gradient(
|
||||
1200px 800px at 100% 0%,
|
||||
oklch(0.81 0.16 65 / 0.05),
|
||||
transparent 60%
|
||||
),
|
||||
radial-gradient(
|
||||
900px 700px at 0% 100%,
|
||||
oklch(0.55 0.10 205 / 0.04),
|
||||
transparent 60%
|
||||
),
|
||||
var(--console-ink);
|
||||
}
|
||||
|
||||
/* Wrapper must be a positioning context so the grain overlay (and any
|
||||
* other absolutely-positioned atmosphere) doesn't escape to the viewport. */
|
||||
[data-theme="console"] {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
/* Grain — single SVG turbulence, low opacity. Doesn't ship as an asset. */
|
||||
[data-theme="console"]::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
opacity: 0.035;
|
||||
z-index: 1;
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='200' height='200'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.6 0'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");
|
||||
mix-blend-mode: overlay;
|
||||
}
|
||||
|
||||
/* ── Console primitives ────────────────────────────────────────────────── */
|
||||
|
||||
[data-theme="console"] .console-mono {
|
||||
font-family: var(--console-font-mono);
|
||||
font-feature-settings: "ss01", "ss02", "calt", "cv01";
|
||||
}
|
||||
[data-theme="console"] .console-serif {
|
||||
font-family: var(--console-font-serif);
|
||||
font-feature-settings: "ss01", "kern";
|
||||
}
|
||||
|
||||
/* Turn-number gutter label */
|
||||
[data-theme="console"] .console-turn-num {
|
||||
font-family: var(--console-font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
color: var(--console-muted-2);
|
||||
}
|
||||
|
||||
/* Hairline rule */
|
||||
[data-theme="console"] .console-rule {
|
||||
border-color: var(--console-rule-soft);
|
||||
}
|
||||
|
||||
/* Operator badge — sodium amber */
|
||||
[data-theme="console"] .console-pill-amber {
|
||||
background: oklch(0.81 0.16 65 / 0.10);
|
||||
color: var(--console-amber);
|
||||
border: 1px solid oklch(0.81 0.16 65 / 0.30);
|
||||
}
|
||||
[data-theme="console"] .console-pill-cyan {
|
||||
background: oklch(0.78 0.12 205 / 0.10);
|
||||
color: var(--console-cyan);
|
||||
border: 1px solid oklch(0.78 0.12 205 / 0.30);
|
||||
}
|
||||
[data-theme="console"] .console-pill-mint {
|
||||
background: oklch(0.78 0.14 155 / 0.10);
|
||||
color: var(--console-mint);
|
||||
border: 1px solid oklch(0.78 0.14 155 / 0.30);
|
||||
}
|
||||
[data-theme="console"] .console-pill-rose {
|
||||
background: oklch(0.66 0.21 12 / 0.12);
|
||||
color: var(--console-rose);
|
||||
border: 1px solid oklch(0.66 0.21 12 / 0.30);
|
||||
}
|
||||
|
||||
/* Vertical rule that runs the length of the transcript */
|
||||
[data-theme="console"] .console-spine {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
var(--console-rule) 8%,
|
||||
var(--console-rule) 92%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* Composer prompt cursor */
|
||||
@keyframes consoleBlink {
|
||||
0%, 49% { opacity: 1; }
|
||||
50%, 100% { opacity: 0; }
|
||||
}
|
||||
[data-theme="console"] .console-cursor {
|
||||
display: inline-block;
|
||||
width: 0.5ch;
|
||||
height: 1.1em;
|
||||
background: var(--console-amber);
|
||||
vertical-align: text-bottom;
|
||||
margin-left: 1px;
|
||||
animation: consoleBlink 1.05s step-end infinite;
|
||||
}
|
||||
|
||||
/* Empty-state oversize text — letter-spacing tracking is the whole point */
|
||||
[data-theme="console"] .console-empty-headline {
|
||||
font-family: var(--console-font-mono);
|
||||
font-size: clamp(1.5rem, 3.2vw, 2.5rem);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1;
|
||||
color: var(--console-text);
|
||||
}
|
||||
|
||||
[data-theme="console"] .console-empty-headline em {
|
||||
font-family: var(--console-font-serif);
|
||||
font-style: italic;
|
||||
font-weight: 500;
|
||||
color: var(--console-amber);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* Stagger the empty-state lines on first paint. */
|
||||
@keyframes consoleEmptyIn {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
[data-theme="console"] .console-empty-line {
|
||||
opacity: 0;
|
||||
animation: consoleEmptyIn 600ms cubic-bezier(0.2, 0.8, 0.2, 1) forwards;
|
||||
}
|
||||
[data-theme="console"] .console-empty-line:nth-child(1) { animation-delay: 0ms; }
|
||||
[data-theme="console"] .console-empty-line:nth-child(2) { animation-delay: 90ms; }
|
||||
[data-theme="console"] .console-empty-line:nth-child(3) { animation-delay: 180ms; }
|
||||
[data-theme="console"] .console-empty-line:nth-child(4) { animation-delay: 280ms; }
|
||||
|
||||
/* Modeline (status bar at the foot) */
|
||||
[data-theme="console"] .console-modeline {
|
||||
font-family: var(--console-font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--console-muted);
|
||||
border-top: 1px solid var(--console-rule-soft);
|
||||
background: linear-gradient(to bottom, transparent, oklch(0.13 0.02 240 / 0.6));
|
||||
}
|
||||
[data-theme="console"] .console-modeline-key {
|
||||
color: var(--console-muted-2);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
margin-right: 0.4ch;
|
||||
}
|
||||
[data-theme="console"] .console-modeline-val {
|
||||
color: var(--console-text-2);
|
||||
}
|
||||
|
||||
/* Operator-row — monospace, tight, with an accent column */
|
||||
[data-theme="console"] .console-op-line {
|
||||
font-family: var(--console-font-mono);
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
color: var(--console-text);
|
||||
}
|
||||
[data-theme="console"] .console-op-prompt {
|
||||
color: var(--console-amber);
|
||||
font-weight: 600;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Agent prose — set in serif, larger leading */
|
||||
[data-theme="console"] .console-agent-prose {
|
||||
font-family: var(--console-font-serif);
|
||||
font-size: 17px;
|
||||
line-height: 1.55;
|
||||
color: var(--console-text);
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
[data-theme="console"] .console-agent-prose em {
|
||||
color: var(--console-cyan);
|
||||
font-style: italic;
|
||||
}
|
||||
[data-theme="console"] .console-agent-prose code {
|
||||
font-family: var(--console-font-mono);
|
||||
font-size: 0.86em;
|
||||
background: var(--console-deck-2);
|
||||
border: 1px solid var(--console-rule-soft);
|
||||
border-radius: 2px;
|
||||
padding: 0.05em 0.4em;
|
||||
color: var(--console-amber);
|
||||
}
|
||||
[data-theme="console"] .console-agent-prose strong {
|
||||
font-weight: 600;
|
||||
color: var(--console-text);
|
||||
}
|
||||
|
||||
/* Signature line under each agent turn */
|
||||
[data-theme="console"] .console-sig {
|
||||
font-family: var(--console-font-mono);
|
||||
font-size: 10.5px;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--console-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
[data-theme="console"] .console-sig-name {
|
||||
color: var(--console-cyan);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* Streaming activity indicator — a single phosphor block that pulses */
|
||||
@keyframes consolePulse {
|
||||
0%, 100% { opacity: 0.35; transform: scaleX(0.6); }
|
||||
50% { opacity: 1; transform: scaleX(1); }
|
||||
}
|
||||
[data-theme="console"] .console-streaming-bar {
|
||||
display: inline-block;
|
||||
width: 18px;
|
||||
height: 8px;
|
||||
background: var(--console-amber);
|
||||
vertical-align: middle;
|
||||
transform-origin: left center;
|
||||
animation: consolePulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Composer chrome */
|
||||
[data-theme="console"] .console-composer {
|
||||
background: var(--console-deck);
|
||||
border: 1px solid var(--console-rule);
|
||||
border-radius: 2px;
|
||||
position: relative;
|
||||
}
|
||||
[data-theme="console"] .console-composer:focus-within {
|
||||
border-color: var(--console-amber);
|
||||
box-shadow: 0 0 0 1px oklch(0.81 0.16 65 / 0.30);
|
||||
}
|
||||
[data-theme="console"] .console-composer textarea {
|
||||
font-family: var(--console-font-mono) !important;
|
||||
font-size: 14px !important;
|
||||
line-height: 1.55 !important;
|
||||
color: var(--console-text) !important;
|
||||
}
|
||||
[data-theme="console"] .console-composer textarea::placeholder {
|
||||
color: var(--console-muted-2);
|
||||
}
|
||||
|
||||
/* Header strip — session card. Solid background so messages scrolling past
|
||||
* don't bleed through the sticky bar. */
|
||||
[data-theme="console"] .console-header {
|
||||
border-bottom: 1px solid var(--console-rule-soft);
|
||||
background: var(--console-ink);
|
||||
-webkit-backdrop-filter: blur(12px) saturate(140%);
|
||||
backdrop-filter: blur(12px) saturate(140%);
|
||||
}
|
||||
@supports ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
|
||||
[data-theme="console"] .console-header {
|
||||
background: oklch(0.13 0.02 240 / 0.82);
|
||||
}
|
||||
}
|
||||
[data-theme="console"] .console-session-id {
|
||||
font-family: var(--console-font-mono);
|
||||
font-size: clamp(1.5rem, 3vw, 2.25rem);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--console-text);
|
||||
}
|
||||
[data-theme="console"] .console-session-id span {
|
||||
color: var(--console-amber);
|
||||
}
|
||||
[data-theme="console"] .console-meta-key {
|
||||
font-family: var(--console-font-mono);
|
||||
font-size: 9.5px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--console-muted-2);
|
||||
}
|
||||
[data-theme="console"] .console-meta-val {
|
||||
font-family: var(--console-font-mono);
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
color: var(--console-text);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* Tool-call wrapper — keep lib's internals, restyle the frame */
|
||||
[data-theme="console"] [data-slot="tool-call-card"] {
|
||||
background: var(--console-deck) !important;
|
||||
border: 1px solid var(--console-rule) !important;
|
||||
border-radius: 2px !important;
|
||||
font-family: var(--console-font-mono) !important;
|
||||
}
|
||||
158
docs/LLM_PROXY_CONTRACT.md
Normal file
158
docs/LLM_PROXY_CONTRACT.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# LLM Proxy Contract
|
||||
|
||||
> **Status: implemented.** Backend lives in `arcadia-app` at `apps/arcadia_core/lib/arcadia/ai/llm_proxy*` (see commit `75669f1`). This document remains the contract that `lib-llm-providers-ui` and `app/lib/arcadia/llm-proxy.ts` expect from arcadia — keep it in sync if either side changes.
|
||||
|
||||
## 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.
|
||||
173
docs/RAG.md
Normal file
173
docs/RAG.md
Normal file
@@ -0,0 +1,173 @@
|
||||
# Retrieval-Augmented Generation in arcadia-admin
|
||||
|
||||
This app exposes **two** lexical RAG surfaces to the assistant. They
|
||||
share a contract (`search` + `read`) but live at different layers and
|
||||
serve different content. The agent picks between them based on tool
|
||||
descriptions; the operator chooses which to deploy based on corpus
|
||||
shape.
|
||||
|
||||
---
|
||||
|
||||
## At a glance
|
||||
|
||||
| | Browser RAG | Server RAG |
|
||||
|---|---|---|
|
||||
| Lib / service | `@crema/lexical-rag-ui` | `arcadia-search` (Rust) |
|
||||
| Engine | MiniSearch (BM25, JS) | Tantivy (BM25, Rust) |
|
||||
| Where it runs | In the user's browser | Sibling of arcadia-app |
|
||||
| Index storage | Static JSON, fetched once | mmap'd disk, ~30–80MB resident |
|
||||
| Practical corpus size | ~5–10MB / ~50–100k chunks | GB-scale, no hard cap |
|
||||
| Update cadence | Static — rebuilt at app build time | Live — cron, webhook, or admin trigger |
|
||||
| Auth | None (bundled with the app) | JWT (via arcadia's Guardian) |
|
||||
| Tool the agent calls | `search_docs(query, limit)` | `search_kb(query, corpus, limit?, tags?)` + `read_chunk(chunk_id, corpus)` |
|
||||
| Source content lives in | `arcadia-admin/public/docs-index.json` | `arcadia-search`'s data dir, ingested from disk or arcadia API |
|
||||
| What's it best for | Static reference docs that ship with the app | Tenant-uploaded files, audit log search, anything that grows |
|
||||
|
||||
---
|
||||
|
||||
## When the agent picks each
|
||||
|
||||
The system prompt in `app/routes/assistant.tsx::buildAdminPreface` tells
|
||||
the model:
|
||||
|
||||
> Two retrieval surfaces exist for documentation/knowledge:
|
||||
> `search_docs` (browser-side, BM25 over the bundled arcadia docs —
|
||||
> fast, always available, small corpus) and `search_kb` (server-side,
|
||||
> BM25 over arcadia-search — same docs as `corpus=docs` for parity,
|
||||
> plus larger and additional corpora as the operator adds them). For
|
||||
> questions about the bundled arcadia docs either is fine; prefer
|
||||
> `search_kb` when you want richer hits or when the user is asking
|
||||
> about content that wouldn't be in the bundled docs (uploaded files,
|
||||
> tenant-specific knowledge). When `search_kb` returns a `chunk_id`
|
||||
> you want to expand, call `read_chunk(chunk_id, corpus)`.
|
||||
|
||||
In practice DeepSeek + V3 picks `search_kb` for anything that mentions
|
||||
"the kb" or sounds dynamic, and `search_docs` for quick lookups against
|
||||
the bundled docs. Neither pick is wrong for content that exists in
|
||||
both.
|
||||
|
||||
---
|
||||
|
||||
## Browser RAG (`@crema/lexical-rag-ui`)
|
||||
|
||||
**What it is.** A small React + MiniSearch wrapper. The lib provides
|
||||
`RAGProvider`, `useRAG`, and a headless `createRAGClient(indexUrl)`.
|
||||
The index is a single JSON file built offline by
|
||||
`scripts/build-docs-index.mjs` and shipped in the app's `public/`.
|
||||
|
||||
**How it's wired here.**
|
||||
|
||||
- Build script: `arcadia-admin/scripts/build-docs-index.mjs` reads
|
||||
markdown from `../reference/arcadia-app/`, chunks at H1–H3,
|
||||
produces `public/docs-index.json`. Runs on `npm run build:docs`
|
||||
(and as the `prebuild` step before `npm run build`).
|
||||
- Tool wrapper: `app/lib/admin-tools.ts` constructs a singleton
|
||||
`createRAGClient("/docs-index.json")` and exposes it as the
|
||||
`search_docs` tool. The tool returns hits with the legacy
|
||||
`category` field collapsed back from `tags[0]` so the agent's
|
||||
prior expectations stay stable.
|
||||
- Storage: just the static JSON. No state, no auth, no indexer
|
||||
process.
|
||||
|
||||
**Limits.** Practical ceiling is ~5–10MB index. Past that, first-load
|
||||
parse and browser memory get painful (200MB+ heap on a 50MB index;
|
||||
mobile breaks). Updates require a build step + redeploy.
|
||||
|
||||
**Why it exists.** Static reference content that ships with the app —
|
||||
arcadia's own docs, in this case. Always available even if the search
|
||||
service is down. Zero infrastructure.
|
||||
|
||||
For the lib itself see `lib-lexical-rag-ui/README.md`.
|
||||
|
||||
---
|
||||
|
||||
## Server RAG (`arcadia-search`)
|
||||
|
||||
**What it is.** A standalone Rust HTTP service (Tantivy + axum). Single
|
||||
static binary, ~30–80MB resident. Per-tenant per-corpus indexes on
|
||||
disk. JWT auth, HMAC webhook intake, atomic rebuild swap, systemd
|
||||
timer cron.
|
||||
|
||||
**How it's wired here.**
|
||||
|
||||
- Tools: `app/lib/admin-tools.ts` exposes `search_kb` and
|
||||
`read_chunk`. The fetch URL is `KB_BASE_URL` (default
|
||||
`http://127.0.0.1:7800`, override via `window.__ARCADIA_SEARCH_URL`
|
||||
or `VITE_ARCADIA_SEARCH_URL`). The bearer token is the user's
|
||||
arcadia JWT from `sessionStorage["arcadia_access_token"]`, with a
|
||||
`"dev"` fallback when no login.
|
||||
- Reindex button: `app/routes/ai.tsx::reindexKB` calls
|
||||
`POST /index/:corpus/build` and toasts the result. Lives in the
|
||||
AI page's empty state next to the block-preview button.
|
||||
- System prompt: see the snippet in `assistant.tsx::buildAdminPreface`
|
||||
above.
|
||||
|
||||
**Storage.** `<INDEX_DIR>/<tenant>/<corpus>/current/` per index;
|
||||
`previous-<stamp>/` for the last few rebuilds (rollback). Sources can
|
||||
be on-disk markdown, or pulled from arcadia's `/api/v1/digital_objects`
|
||||
API (see `arcadia-search/MULTI_TENANT.md` and `ARCADIA_INTEGRATION.md`).
|
||||
|
||||
**Update cadence.** Three triggers, layered so each compensates for
|
||||
the others' failure modes:
|
||||
- **Cron** (systemd timer, hourly default) — always-on safety net.
|
||||
- **Admin button** — one-click rebuild from the AI page.
|
||||
- **Webhook** — arcadia POSTs `/events/changed` on file create/delete;
|
||||
search debounces (2-min default) and rebuilds.
|
||||
|
||||
**Why it exists.** Anything that doesn't fit the browser ceiling:
|
||||
tenant-uploaded files, audit-log-ish content, multi-tenant knowledge
|
||||
bases, anything that grows over time.
|
||||
|
||||
For the service see `arcadia-search/README.md`.
|
||||
For multi-tenant config see `arcadia-search/MULTI_TENANT.md`.
|
||||
For the upstream arcadia integration story (file content fetch,
|
||||
text extraction, webhook signature, service tokens) see
|
||||
`arcadia-search/ARCADIA_INTEGRATION.md`.
|
||||
|
||||
---
|
||||
|
||||
## How they coexist
|
||||
|
||||
The default deploy runs **both**:
|
||||
|
||||
- `search_docs` indexes the same arcadia-app docs the parity corpus
|
||||
on `arcadia-search` indexes. Same content, two engines.
|
||||
- This is intentional — it means the assistant always has *something*
|
||||
to search, even if `arcadia-search` is down or unreachable. The
|
||||
failure mode is "no `search_kb`, but `search_docs` still works."
|
||||
- It also gives a permanent A/B regression test: query both, compare
|
||||
hits, catch relevance regressions in either engine.
|
||||
|
||||
---
|
||||
|
||||
## Picking ONE for a new corpus
|
||||
|
||||
Use this checklist when adding new content:
|
||||
|
||||
| Question | Answer → use |
|
||||
|---|---|
|
||||
| Is the corpus < 5MB and basically static? | Browser |
|
||||
| Does it need to update without a redeploy? | Server |
|
||||
| Is it per-tenant content (uploaded files, tenant-specific KB)? | Server |
|
||||
| Are you OK shipping it in the JS bundle? | Browser |
|
||||
| Does it need agentic `read_chunk` follow-up? | Server (browser doesn't expose `read` over the tool surface) |
|
||||
| Does it need to work offline / with no backend? | Browser |
|
||||
| Is it growing > 10MB? | Server |
|
||||
|
||||
Most "knowledge base" content lives in the server side. The browser
|
||||
side is reserved for the always-bundled reference material that ships
|
||||
with the app.
|
||||
|
||||
---
|
||||
|
||||
## What lives where (cheat sheet)
|
||||
|
||||
| Want to | Look at |
|
||||
|---|---|
|
||||
| Add a doc to the bundled browser RAG | `arcadia-admin/scripts/build-docs-index.mjs` (extend `SOURCES`) |
|
||||
| Add a tool to the agent | `arcadia-admin/app/lib/admin-tools.ts` |
|
||||
| Change the LLM's tool-picking guidance | `arcadia-admin/app/routes/assistant.tsx::buildAdminPreface` |
|
||||
| Add a corpus to arcadia-search | `arcadia-search/deploy/<tenant>/<corpus>.config.json` + new systemd timer |
|
||||
| Add a tenant to arcadia-search | `arcadia-search/MULTI_TENANT.md` |
|
||||
| Wire an arcadia file → search ingest | `arcadia-search/ARCADIA_INTEGRATION.md` (needs upstream changes first) |
|
||||
| Reindex the server-side corpus right now | "reindex kb (docs)" button on `/ai` empty state, or `POST /index/docs/build` |
|
||||
7
package-lock.json
generated
7
package-lock.json
generated
@@ -15,6 +15,7 @@
|
||||
"d3-geo": "^3.1.1",
|
||||
"isbot": "^5.1.36",
|
||||
"lucide-react": "^1.8.0",
|
||||
"minisearch": "^7.2.0",
|
||||
"motion": "^12.38.0",
|
||||
"openapi-fetch": "^0.17.0",
|
||||
"phoenix": "^1.8.5",
|
||||
@@ -7264,6 +7265,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/minisearch": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/minisearch/-/minisearch-7.2.0.tgz",
|
||||
"integrity": "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/morgan": {
|
||||
"version": "1.10.1",
|
||||
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz",
|
||||
|
||||
@@ -3,13 +3,16 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"prebuild": "npm run build:docs",
|
||||
"build": "react-router build",
|
||||
"dev": "react-router dev",
|
||||
"start": "react-router-serve ./build/server/index.js",
|
||||
"typecheck": "react-router typegen && tsc",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"sync-libs": "node scripts/sync-libs.mjs"
|
||||
"sync-libs": "node scripts/sync-libs.mjs",
|
||||
"build:docs": "node scripts/build-docs-index.mjs",
|
||||
"mint:search-token": "node scripts/mint-search-token.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.4.0",
|
||||
@@ -21,6 +24,7 @@
|
||||
"d3-geo": "^3.1.1",
|
||||
"isbot": "^5.1.36",
|
||||
"lucide-react": "^1.8.0",
|
||||
"minisearch": "^7.2.0",
|
||||
"motion": "^12.38.0",
|
||||
"openapi-fetch": "^0.17.0",
|
||||
"phoenix": "^1.8.5",
|
||||
|
||||
50
scripts/build-docs-index.mjs
Normal file
50
scripts/build-docs-index.mjs
Normal file
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env node
|
||||
// Build the /docs-index.json bundle consumed by @crema/lexical-rag-ui at
|
||||
// runtime. Thin wrapper — all engine logic lives in the lib's builder.ts;
|
||||
// this file owns the per-app config (which arcadia-app docs to index and
|
||||
// how to tag them).
|
||||
//
|
||||
// Run: npm run build:docs
|
||||
//
|
||||
// Allowlist is intentional. Excluded files are aspirational/stale and
|
||||
// would poison answers (TODO lists, design docs for unshipped features,
|
||||
// sub-app READMEs that aren't part of arcadia-core). To add a file,
|
||||
// append to SOURCES below — don't auto-discover.
|
||||
|
||||
import { resolve, dirname } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
import MiniSearch from "minisearch"
|
||||
import { buildIndex } from "../../lib-lexical-rag-ui/src/builder.mjs"
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..")
|
||||
const ARCADIA = resolve(ROOT, "../reference/arcadia-app")
|
||||
const OUT = resolve(ROOT, "public/docs-index.json")
|
||||
|
||||
const SOURCES = [
|
||||
// Arcadia platform docs (resolved against ARCADIA = ../reference/arcadia-app).
|
||||
{ path: "README.md", tags: ["core"] },
|
||||
{ path: "docs/ARCADIA.md", tags: ["core"] },
|
||||
{ path: "docs/MODULAR_MONOLITH.md", tags: ["core"] },
|
||||
{ path: "apps/arcadia_core/README.md", tags: ["core"] },
|
||||
{ path: "DEPLOY.md", tags: ["ops"] },
|
||||
{ path: "DEV_DEPLOY.md", tags: ["ops"] },
|
||||
{ path: "DEV_SETUP.md", tags: ["ops"] },
|
||||
|
||||
// RAG ecosystem docs — pulled from sibling repos via per-source
|
||||
// rootDir override. Lets the assistant answer "how do I add a
|
||||
// tenant to arcadia-search" or "what does the browser RAG do"
|
||||
// without leaving the chat.
|
||||
{ rootDir: "../../arcadia-search", path: "README.md", tags: ["rag", "search-service"] },
|
||||
{ rootDir: "../../arcadia-search", path: "MULTI_TENANT.md", tags: ["rag", "search-service"] },
|
||||
{ rootDir: "../../arcadia-search", path: "ARCADIA_INTEGRATION.md", tags: ["rag", "integration"] },
|
||||
{ rootDir: "../../lib-lexical-rag-ui", path: "README.md", tags: ["rag", "browser"] },
|
||||
{ rootDir: "../../arcadia-admin", path: "docs/RAG.md", tags: ["rag", "overview"] },
|
||||
]
|
||||
|
||||
buildIndex({
|
||||
miniSearch: MiniSearch,
|
||||
rootDir: ARCADIA,
|
||||
outPath: OUT,
|
||||
sources: SOURCES,
|
||||
})
|
||||
84
scripts/mint-search-token.mjs
Executable file
84
scripts/mint-search-token.mjs
Executable file
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env node
|
||||
// Mint an HMAC-signed JWT for arcadia-search and (optionally) write it
|
||||
// into .env.local as VITE_ARCADIA_SEARCH_TOKEN.
|
||||
//
|
||||
// arcadia-search expects:
|
||||
// - HS512 by default when only JWT_HMAC_SECRET is set on the server
|
||||
// (no PEM). HS256/HS384 work too if JWT_ALGORITHM is overridden.
|
||||
// - a `tenant_id` claim (configurable server-side via JWT_TENANT_CLAIM).
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/mint-search-token.mjs # uses defaults, writes .env.local
|
||||
// node scripts/mint-search-token.mjs --print # print only, don't write
|
||||
// node scripts/mint-search-token.mjs --tenant=foo --days=30
|
||||
// JWT_HMAC_SECRET=xyz node scripts/mint-search-token.mjs
|
||||
|
||||
import { createHmac } from "node:crypto"
|
||||
import { readFileSync, writeFileSync, existsSync } from "node:fs"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { dirname, resolve } from "node:path"
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url))
|
||||
const PROJECT_ROOT = resolve(HERE, "..")
|
||||
const ENV_LOCAL = resolve(PROJECT_ROOT, ".env.local")
|
||||
|
||||
function arg(name, fallback) {
|
||||
const prefix = `--${name}=`
|
||||
const hit = process.argv.find((a) => a.startsWith(prefix))
|
||||
return hit ? hit.slice(prefix.length) : fallback
|
||||
}
|
||||
|
||||
const flag = (name) => process.argv.includes(`--${name}`)
|
||||
|
||||
const SECRET = process.env.JWT_HMAC_SECRET ?? "test-secret-change-me"
|
||||
const TENANT = arg("tenant", process.env.VITE_ARCADIA_TENANT ?? "platform-admin")
|
||||
const SUBJECT = arg("sub", "arcadia-admin")
|
||||
const ALGORITHM = (arg("alg", "HS512")).toUpperCase()
|
||||
const DAYS = Number(arg("days", "365"))
|
||||
const PRINT_ONLY = flag("print")
|
||||
|
||||
const HMAC_ALG = { HS256: "sha256", HS384: "sha384", HS512: "sha512" }[ALGORITHM]
|
||||
if (!HMAC_ALG) {
|
||||
console.error(`Unsupported algorithm "${ALGORITHM}". Use HS256, HS384, or HS512.`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const b64 = (v) =>
|
||||
Buffer.from(typeof v === "string" ? v : JSON.stringify(v)).toString("base64url")
|
||||
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const header = b64({ alg: ALGORITHM, typ: "JWT" })
|
||||
const payload = b64({
|
||||
sub: SUBJECT,
|
||||
tenant_id: TENANT,
|
||||
iat: now,
|
||||
exp: now + DAYS * 86400,
|
||||
})
|
||||
const signature = createHmac(HMAC_ALG, SECRET)
|
||||
.update(`${header}.${payload}`)
|
||||
.digest("base64url")
|
||||
const token = `${header}.${payload}.${signature}`
|
||||
|
||||
if (PRINT_ONLY) {
|
||||
console.log(token)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
// Upsert VITE_ARCADIA_SEARCH_TOKEN in .env.local without disturbing other keys.
|
||||
const KEY = "VITE_ARCADIA_SEARCH_TOKEN"
|
||||
let existing = ""
|
||||
if (existsSync(ENV_LOCAL)) existing = readFileSync(ENV_LOCAL, "utf8")
|
||||
const line = `${KEY}=${token}`
|
||||
const next = new RegExp(`^${KEY}=.*$`, "m").test(existing)
|
||||
? existing.replace(new RegExp(`^${KEY}=.*$`, "m"), line)
|
||||
: (existing.endsWith("\n") || existing === "" ? existing : existing + "\n") + line + "\n"
|
||||
writeFileSync(ENV_LOCAL, next)
|
||||
|
||||
console.log(`Wrote ${KEY} to ${ENV_LOCAL}`)
|
||||
console.log(` alg: ${ALGORITHM}`)
|
||||
console.log(` tenant: ${TENANT}`)
|
||||
console.log(` sub: ${SUBJECT}`)
|
||||
console.log(` expires: ${new Date((now + DAYS * 86400) * 1000).toISOString()}`)
|
||||
console.log(` secret: ${SECRET === "test-secret-change-me" ? "(default — override with JWT_HMAC_SECRET=)" : "(from JWT_HMAC_SECRET)"}`)
|
||||
console.log(``)
|
||||
console.log(`Restart 'npm run dev' so Vite picks up the new env var.`)
|
||||
@@ -28,6 +28,8 @@
|
||||
"@crema/action-bus/*": ["../lib-action-bus/src/*"],
|
||||
"@crema/arcadia-client": ["../lib-arcadia-client/src/index.tsx"],
|
||||
"@crema/arcadia-client/*": ["../lib-arcadia-client/src/*"],
|
||||
"@crema/integration-registry-client": ["../lib-integration-registry-client/src/index.tsx"],
|
||||
"@crema/integration-registry-client/*": ["../lib-integration-registry-client/src/*"],
|
||||
"@crema/arcadia-auth-ui": ["../lib-arcadia-auth-ui/src/index.tsx"],
|
||||
"@crema/arcadia-auth-ui/*": ["../lib-arcadia-auth-ui/src/*"],
|
||||
"@crema/table-ui": ["../lib-table-ui/src/index.tsx"],
|
||||
@@ -40,6 +42,30 @@
|
||||
"@crema/auth-ui/*": ["../lib-auth-ui/src/*"],
|
||||
"@crema/agent-ui": ["../lib-agent-ui/src/index.tsx"],
|
||||
"@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/file-ui": ["../lib-file-ui/src/index.tsx"],
|
||||
"@crema/file-ui/*": ["../lib-file-ui/src/*"],
|
||||
"@crema/card-ui": ["../lib-card-ui/src/index.tsx"],
|
||||
"@crema/card-ui/*": ["../lib-card-ui/src/*"],
|
||||
"@crema/dashboard-ui": ["../lib-dashboard-ui/src/index.tsx"],
|
||||
"@crema/dashboard-ui/*": ["../lib-dashboard-ui/src/*"],
|
||||
"@crema/chart-ui": ["../lib-chart-ui/src/index.tsx"],
|
||||
"@crema/chart-ui/*": ["../lib-chart-ui/src/*"],
|
||||
"@crema/map-ui": ["../lib-map-ui/src/index.tsx"],
|
||||
"@crema/map-ui/*": ["../lib-map-ui/src/*"],
|
||||
"@crema/status-ui": ["../lib-status-ui/src/index.tsx"],
|
||||
"@crema/status-ui/*": ["../lib-status-ui/src/*"],
|
||||
"@crema/data-ui": ["../lib-data-ui/src/index.tsx"],
|
||||
"@crema/data-ui/*": ["../lib-data-ui/src/*"],
|
||||
"@crema/code-ui": ["../lib-code-ui/src/index.tsx"],
|
||||
"@crema/code-ui/*": ["../lib-code-ui/src/*"],
|
||||
"@crema/diagram-ui": ["../lib-diagram-ui/src/index.tsx"],
|
||||
"@crema/diagram-ui/*": ["../lib-diagram-ui/src/*"],
|
||||
"@crema/onboarding-ui": ["../lib-onboarding-ui/src/index.tsx"],
|
||||
"@crema/onboarding-ui/*": ["../lib-onboarding-ui/src/*"],
|
||||
"@crema/lexical-rag-ui": ["../lib-lexical-rag-ui/src/index.tsx"],
|
||||
"@crema/lexical-rag-ui/*": ["../lib-lexical-rag-ui/src/*"],
|
||||
"// CREMA:PATHS": [""],
|
||||
"react": ["./node_modules/@types/react"],
|
||||
"react/*": ["./node_modules/@types/react/*"],
|
||||
@@ -47,6 +73,7 @@
|
||||
"react-dom/*": ["./node_modules/@types/react-dom/*"],
|
||||
"clsx": ["./node_modules/clsx"],
|
||||
"tailwind-merge": ["./node_modules/tailwind-merge"],
|
||||
"minisearch": ["./node_modules/minisearch"],
|
||||
"lucide-react": ["./node_modules/lucide-react"],
|
||||
"openapi-fetch": ["./node_modules/openapi-fetch"],
|
||||
"openapi-fetch/*": ["./node_modules/openapi-fetch/*"],
|
||||
|
||||
124
vite.config.ts
124
vite.config.ts
@@ -62,9 +62,51 @@ const searchUiSrc = fileURLToPath(
|
||||
const arcadiaClientSrc = fileURLToPath(
|
||||
new URL("../lib-arcadia-client/src", import.meta.url),
|
||||
)
|
||||
const integrationRegistryClientSrc = fileURLToPath(
|
||||
new URL("../lib-integration-registry-client/src", import.meta.url),
|
||||
)
|
||||
const arcadiaAuthUiSrc = fileURLToPath(
|
||||
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),
|
||||
)
|
||||
const fileUiSrc = fileURLToPath(
|
||||
new URL("../lib-file-ui/src", import.meta.url),
|
||||
)
|
||||
const cardUiSrc = fileURLToPath(
|
||||
new URL("../lib-card-ui/src", import.meta.url),
|
||||
)
|
||||
const dashboardUiSrc = fileURLToPath(
|
||||
new URL("../lib-dashboard-ui/src", import.meta.url),
|
||||
)
|
||||
const chartUiSrc = fileURLToPath(
|
||||
new URL("../lib-chart-ui/src", import.meta.url),
|
||||
)
|
||||
const statusUiSrc = fileURLToPath(
|
||||
new URL("../lib-status-ui/src", import.meta.url),
|
||||
)
|
||||
const actionBusSrc = fileURLToPath(
|
||||
new URL("../lib-action-bus/src", import.meta.url),
|
||||
)
|
||||
const agentUiSrc = fileURLToPath(
|
||||
new URL("../lib-agent-ui/src", import.meta.url),
|
||||
)
|
||||
const aifirstUiSrc = fileURLToPath(
|
||||
new URL("../lib-aifirst-ui/src", import.meta.url),
|
||||
)
|
||||
const lexicalRagUiSrc = fileURLToPath(
|
||||
new URL("../lib-lexical-rag-ui/src", import.meta.url),
|
||||
)
|
||||
const notificationUiSrc = fileURLToPath(
|
||||
new URL("../lib-notification-ui/src", import.meta.url),
|
||||
)
|
||||
const onboardingUiSrc = fileURLToPath(
|
||||
new URL("../lib-onboarding-ui/src", import.meta.url),
|
||||
)
|
||||
|
||||
// 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
|
||||
@@ -83,6 +125,9 @@ const aliasedDeps = [
|
||||
"@tiptap/extension-link",
|
||||
"@tiptap/extension-placeholder",
|
||||
"@tiptap/extension-image",
|
||||
"minisearch",
|
||||
"react-markdown",
|
||||
"remark-gfm",
|
||||
]
|
||||
const sharedDepAliases = Object.fromEntries(
|
||||
aliasedDeps.map((name) => [name, resolvePath(nodeModules, name)]),
|
||||
@@ -97,29 +142,64 @@ const dedupeDeps = [
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), reactRouter(), tsconfigPaths()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@crema/content-ui": `${contentUiSrc}/index.ts`,
|
||||
"@crema/content-editor-ui": `${contentEditorUiSrc}/index.ts`,
|
||||
"@crema/content-media-ui": `${contentMediaUiSrc}/index.tsx`,
|
||||
"@crema/color-ui": `${colorUiSrc}/index.tsx`,
|
||||
"@crema/typography-ui": `${typographyUiSrc}/index.tsx`,
|
||||
"@crema/data-ui": `${dataUiSrc}/index.tsx`,
|
||||
"@crema/layout-ui": `${layoutUiSrc}/index.tsx`,
|
||||
"@crema/map-ui": `${mapUiSrc}/index.tsx`,
|
||||
"@crema/form-ui": `${formUiSrc}/index.tsx`,
|
||||
"@crema/feedback-ui": `${feedbackUiSrc}/index.tsx`,
|
||||
"@crema/diagram-ui": `${diagramUiSrc}/index.tsx`,
|
||||
"@crema/chat-ui": `${chatUiSrc}/index.tsx`,
|
||||
"@crema/calendar-ui": `${calendarUiSrc}/index.tsx`,
|
||||
"@crema/code-ui": `${codeUiSrc}/index.tsx`,
|
||||
"@crema/ai-ui": `${aiUiSrc}/index.tsx`,
|
||||
"@crema/auth-ui": `${authUiSrc}/index.tsx`,
|
||||
"@crema/table-ui": `${tableUiSrc}/index.tsx`,
|
||||
"@crema/search-ui": `${searchUiSrc}/index.tsx`,
|
||||
"@crema/arcadia-client": `${arcadiaClientSrc}/index.tsx`,
|
||||
"@crema/arcadia-auth-ui": `${arcadiaAuthUiSrc}/index.tsx`,
|
||||
...sharedDepAliases,
|
||||
// Array form so we can express both the bare-specifier alias
|
||||
// (`@crema/agent-ui` -> src/index.tsx) and a subpath prefix alias
|
||||
// (`@crema/agent-ui/chat` -> src/chat) for libs whose sibling-lib
|
||||
// code reaches into deeper modules. Prefix entries with regex `find`
|
||||
// are matched first.
|
||||
alias: [
|
||||
// Subpath prefixes — longest first so they win before the bare match.
|
||||
{ find: /^@crema\/agent-ui\//, replacement: `${agentUiSrc}/` },
|
||||
{ find: /^@crema\/aifirst-ui\//, replacement: `${aifirstUiSrc}/` },
|
||||
{ find: /^@crema\/notification-ui\//, replacement: `${notificationUiSrc}/` },
|
||||
{ find: /^@crema\/onboarding-ui\//, replacement: `${onboardingUiSrc}/` },
|
||||
{ find: /^@crema\/lexical-rag-ui\//, replacement: `${lexicalRagUiSrc}/` },
|
||||
{ find: /^@crema\/action-bus\//, replacement: `${actionBusSrc}/` },
|
||||
|
||||
// Bare-specifier exact matches.
|
||||
{ find: "@crema/content-ui", replacement: `${contentUiSrc}/index.ts` },
|
||||
{ find: "@crema/content-editor-ui", replacement: `${contentEditorUiSrc}/index.ts` },
|
||||
{ find: "@crema/content-media-ui", replacement: `${contentMediaUiSrc}/index.tsx` },
|
||||
{ find: "@crema/color-ui", replacement: `${colorUiSrc}/index.tsx` },
|
||||
{ find: "@crema/typography-ui", replacement: `${typographyUiSrc}/index.tsx` },
|
||||
{ find: "@crema/data-ui", replacement: `${dataUiSrc}/index.tsx` },
|
||||
{ find: "@crema/layout-ui", replacement: `${layoutUiSrc}/index.tsx` },
|
||||
{ find: "@crema/map-ui", replacement: `${mapUiSrc}/index.tsx` },
|
||||
{ find: "@crema/form-ui", replacement: `${formUiSrc}/index.tsx` },
|
||||
{ find: "@crema/feedback-ui", replacement: `${feedbackUiSrc}/index.tsx` },
|
||||
{ find: "@crema/diagram-ui", replacement: `${diagramUiSrc}/index.tsx` },
|
||||
{ find: "@crema/chat-ui", replacement: `${chatUiSrc}/index.tsx` },
|
||||
{ find: "@crema/calendar-ui", replacement: `${calendarUiSrc}/index.tsx` },
|
||||
{ find: "@crema/code-ui", replacement: `${codeUiSrc}/index.tsx` },
|
||||
{ find: "@crema/ai-ui", replacement: `${aiUiSrc}/index.tsx` },
|
||||
{ find: "@crema/auth-ui", replacement: `${authUiSrc}/index.tsx` },
|
||||
{ find: "@crema/table-ui", replacement: `${tableUiSrc}/index.tsx` },
|
||||
{ find: "@crema/search-ui", replacement: `${searchUiSrc}/index.tsx` },
|
||||
{ find: "@crema/arcadia-client", replacement: `${arcadiaClientSrc}/index.tsx` },
|
||||
{
|
||||
find: "@crema/integration-registry-client",
|
||||
replacement: `${integrationRegistryClientSrc}/index.tsx`,
|
||||
},
|
||||
{ find: "@crema/arcadia-auth-ui", replacement: `${arcadiaAuthUiSrc}/index.tsx` },
|
||||
{ find: "@crema/llm-ui", replacement: `${llmUiSrc}/index.tsx` },
|
||||
{ find: "@crema/llm-providers-ui", replacement: `${llmProvidersUiSrc}/index.tsx` },
|
||||
{ find: "@crema/file-ui", replacement: `${fileUiSrc}/index.tsx` },
|
||||
{ find: "@crema/card-ui", replacement: `${cardUiSrc}/index.tsx` },
|
||||
{ find: "@crema/dashboard-ui", replacement: `${dashboardUiSrc}/index.tsx` },
|
||||
{ find: "@crema/chart-ui", replacement: `${chartUiSrc}/index.tsx` },
|
||||
{ find: "@crema/status-ui", replacement: `${statusUiSrc}/index.tsx` },
|
||||
{ find: "@crema/action-bus", replacement: `${actionBusSrc}/index.tsx` },
|
||||
{ find: "@crema/agent-ui", replacement: `${agentUiSrc}/index.tsx` },
|
||||
{ find: "@crema/aifirst-ui", replacement: `${aifirstUiSrc}/index.tsx` },
|
||||
{ find: "@crema/lexical-rag-ui", replacement: `${lexicalRagUiSrc}/index.tsx` },
|
||||
{ find: "@crema/notification-ui", replacement: `${notificationUiSrc}/index.tsx` },
|
||||
{ find: "@crema/onboarding-ui", replacement: `${onboardingUiSrc}/index.tsx` },
|
||||
|
||||
...Object.entries(sharedDepAliases).map(([find, replacement]) => ({
|
||||
find,
|
||||
replacement,
|
||||
})),
|
||||
],
|
||||
dedupe: dedupeDeps,
|
||||
},
|
||||
// Pre-bundle deps that sibling libs reach for. Without this, Vite
|
||||
|
||||
Reference in New Issue
Block a user