43 Commits

Author SHA1 Message Date
6ab9b730f5 Merge pull request 'Operator Integrations page + capability framework (P3 UI)' (#1) from feat/integration-registry into main 2026-06-09 13:15:15 +00:00
jules
4b817b85ff Wire operator Integrations page + capability-gating framework
Completes the arcadia-admin operator surface for the integration registry and
the capability/route-guard framework it depends on.

- Integration registry: route + Data-group nav entry + `platform.integrations`
  capability; the in-app client now delegates to the shared
  `@crema/integration-registry-client` lib (vite alias + tsconfig); the
  operator Integrations page (committed earlier) is now reachable.
- Capability gating: capabilities map + route-guard + jwt helpers + the
  apps/plan/entitlements routes and supporting tenants/session changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 23:09:24 +10:00
jules
06490865d3 Add operator Integrations page (integration registry console)
The operator surface for the integration registry: manage platform/pooled
external-API credentials across every scope and inspect cross-tenant usage
(metadata only — secrets are write-only). Talks to arcadia-llm-gateway's
/api/v1/integrations* endpoints via a gateway-pointed ArcadiaClient.

- gateway.ts: second ArcadiaClient at VITE_LLM_GATEWAY_URL, reusing the
  arcadia-app JWT (the gateway validates it via the shared Guardian secret;
  CORS already allows *.sky-ai.com + localhost — no proxy).
- lib/arcadia/integrations.ts: operator API client (any-scope create, scope
  filter, cross-tenant usage). Pure functions over an injected client —
  extraction-ready to share with arcadia-console.
- routes/integrations.tsx: scope filter + per-card scope badge, create
  platform/pooled credentials, credentials/usage, Test (surfaces the
  expiry/budget gate), enable toggle, delete.

The route/nav/capability wiring (routes.ts, app-shell, capabilities.ts) lands
with the in-flight capability framework, not here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 21:14:13 +10:00
jules
a299900021 organizations: admin surface for tenant orgs
New /organizations route under Tenancy. Lists every org in the current
tenant (via GET /api/v1/admin/organizations), with per-row Manage
members and Settings dialogs.

- Members dialog: invite by email, add restricted sub-user, change role,
  transfer ownership, remove member (owner removal honors the org's
  on_owner_removal policy server-side)
- Settings dialog: edit name, status (active/frozen/pending_deletion),
  and on_owner_removal policy
- app/lib/arcadia/organizations.ts: typed client for the new endpoints
- Nav entry added under Tenancy group

Tenant admins bypass per-org membership checks via the backend's
OrganizationContext plug, so the per-org REST endpoints work for any
org in the tenant without an explicit /admin/* surface.
2026-05-15 19:50:48 +10:00
jules
a74550d73f shell+ai: pristine-style nav groups, mobile fixes for /ai
Sidenav (app-shell.tsx):
- Each NavGroup now carries an icon (Building2 / Database / Plug /
  MessageSquare / Eye / Sparkles) rendered on the LEFT of the group
  header, with the chevron moved to the RIGHT. Header typography
  switched to caption + uppercase + tracking-wider muted, matching
  pristine-ui's main-branch app-shell. Same change applied to the
  mobile sheet's group headers.

/ai mobile fixes (ai.tsx):
- Composer container honors iOS safe-area inset
  (pb-[max(0.75rem,env(safe-area-inset-bottom))]) so the input clears
  the home indicator and stays above the soft keyboard.
- Composer toolbar wraps on narrow viewports (flex-wrap + gap-y-1)
  so the agent / model / reasoning / voice chips don't clip.
- Empty-state card uses px-4 sm:px-8 instead of hard px-8.
- MessageRow's 56px turn-number gutter collapses below sm: prose
  flows full-width on phone, two-column layout returns at sm+.

/ai desktop centering:
- Console wrapper opts out of AppShell's [&>*:first-child]:lg:pr-72
  (the page-header clearance for the floating top-right pill) via
  lg:!pr-0. The /ai surface has no top-right page-header controls,
  so the inherited padding was shifting the chat column ~144px left
  of the visible viewport center.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:08:36 +10:00
jules
a286b9cdce aifirst: lift context/agents/tools runtime to lib-aifirst-ui
The mechanism (context surface registry, persona storage + hooks, tool
parser/dispatcher) is now generic and lives in @crema/aifirst-ui/{context,
agents,tools}. This template keeps only the arcadia-shaped configuration:

- agents.ts — owns DEFAULT_AGENTS + legacy/retired migration sets, calls
  configureAgents() at module load, re-exports the runtime
- admin-tools.ts — keeps the 19 arcadia tool definitions, binds the
  runtime via createToolRuntime(TOOLS), re-exports the bound functions
- admin-context.ts — deleted; 18 routes now import directly from
  @crema/aifirst-ui/context

Routes that import from ~/lib/agents and ~/lib/admin-tools are unchanged
(wrapper modules preserve the existing import surface).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 15:18:48 +10:00
jules
c968ac0735 avatar: render immediately + survive reload
The variant pipeline is async, so right after upload all four URLs in
profile.avatar_urls are still null. The first wiring attempt called
pickAvatarUrl() which returned null, and nothing visible changed even
though the upload + PATCH succeeded.

Fixes:
- pickAvatarUrl: use the actual backend keys (small/medium/large/
  original — there's no "thumbnail").
- After upload, when no variant URL is ready, fetch the raw object
  via /api/v1/digital_objects/:id/content as a blob URL for immediate
  display. Persist that URL to localStorage so the appbar's
  useProfile() picks it up via the storage event.
- ProfileBootstrap: detect stale blob: URLs cached from previous
  sessions, clear them, and refetch a fresh blob URL when variants
  still aren't ready. Eventually the persistent variant URLs land
  and overwrite.
- Force-remount AvatarImage via key={src} in the profile page and
  appbar — base-ui's Avatar.Image keeps internal load state that
  doesn't always reset on src change.
- Diagnostic logs in fetchDigitalObjectAsBlobUrl + the upload flow
  to make next debug round one step easier (kept; cheap).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 10:33:14 +10:00
jules
2ab183596c profile: bootstrap avatar URL on app boot
The appbar's <Avatar> reads avatarUrl from the localStorage profile
mirror. Without a global fetcher, that mirror only got populated when
the user navigated to /profile, so a fresh browser session showed
initials in the appbar until then.

- ProfileBootstrap component runs in root.tsx alongside
  LlmConfigBootstrap. On mount and on session change, fetches the
  arcadia profile and caches the resolved avatar URL.
- profile.tsx loadAccount now also persists the URL into localStorage
  on initial fetch (was in-memory only) so it survives reloads.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 09:56:40 +10:00
jules
ffe3fc0473 profile: drop unused title/signature/defaultAgentId fields
These were aspirational placeholders — stored to localStorage but never
read by anything. Removed from the form, types, and persistence layer.
Local profile is now just the avatar URL mirror, which the appbar reads
before the server profile fetch resolves on mount.

Preferences card renamed to "Avatar" since that's all that's left.
Re-add server-backed if/when something actually consumes them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 09:54:24 +10:00
jules
f6a92118da profile: wire bio/phone/location/timezone to arcadia
Adds a "Profile" card backed by /api/v1/profile (PATCH) for the four
public-profile fields arcadia already had columns for. Bio moved out of
local prefs (the server one supersedes); local prefs keeps only title,
signature, defaultAgentId, and the avatar URL mirror.

Save/revert mirror the existing Account card's pattern. The new fields
get arcadia validation + audit logging for free.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 09:49:34 +10:00
jules
c2730e3c77 profile: real avatar upload + storage form fix
- Add a digital-objects client (uploadFile: open session → PUT to
  presigned URL → complete) and a profile client (getProfile,
  updateProfile, pickAvatarUrl variant resolver).
- Wire profile.tsx avatar upload to use the real flow: validate
  image+size, upload to digital_objects tagged "avatar", PATCH
  /api/v1/profile with avatar_digital_object_id, mirror the resolved
  URL into local prefs so the existing <AvatarImage> binding keeps
  working. Show Uploading… state and an inline error banner. Clear
  detaches via avatar_digital_object_id: null.
- Fix the storage form sending the wrong field name for the local
  backend — arcadia's StorageConfig changeset requires `base_path`,
  not `path`. The 422 was silent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 08:02:52 +10:00
jules
725540617b shell: collapsible nav groups + mobile-friendly settings
- Reorganize sidenav into collapsible groups (Tenancy, Data,
  Integrations, Communications, Observability, AI & Search) with
  Overview/Settings pinned at top/bottom. Group open/close persists in
  localStorage; the group containing the active route auto-opens.
  Icon-only collapsed rail flattens to a single icon column. Sub-items
  inside groups drop their per-item icons and indent under the header.
- Fix mobile sheet scroll — the nav couldn't reach items past viewport
  height. SheetContent is now flex-col h-svh, header shrink-0, nav
  flex-1 min-h-0 overflow-y-auto.
- Settings page mobile fixes: section nav wraps instead of horizontal
  scroll, top padding clears the floating actions pill, LLM config
  card header and rows stack on narrow widths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 07:39:02 +10:00
jules
5b0281574e llm: auto-load active config from arcadia on app boot
Adds <LlmConfigBootstrap /> in root.tsx that, when localStorage has no
active LLM settings, fetches enabled configurations from arcadia and
seeds the active settings (provider/model/baseURL/secretName + reasoning
effort) from the preferred row. Idempotent and silent on auth failure;
retries on session change.

Selection: prefer metadata.default === true, otherwise first enabled row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 19:41:48 +10:00
jules
d1469059d8 assistant: teach the agent about Search admin
Bring the LLM agent's prompts and tools current with the new /search
section and arcadia-search admin sidecar:

- New tools in admin-tools.ts:
  - list_search_corpora: enumerate tenants + corpora with build status,
    so the agent can pick a real corpus instead of guessing.
  - rebuild_search_corpus(tenant, corpus): isWrite=true, surfaces a
    confirm card. Use after uploads or when results look stale.
- search_kb description updated: names docs / operator-tools / files
  explicitly, and points at list_search_corpora when unsure.
- ARCADIA_KNOWLEDGE: adds search-corpus terminology, /search route,
  and a one-liner pointer to the three new tools.
- assistant.tsx UI_CONTROL_PREFACE: nav-search added, full Search
  page action catalog (search-refresh / -restart / -new-tenant /
  -new-corpus, corpora-search, per-row corpus-{t}-{c}-{rebuild,edit,
  delete,actions}, tenant-{id}-delete, dialog form fields). Recipe
  for the manual rebuild path, plus a note steering the agent to
  the rebuild_search_corpus tool by default.
- search.tsx publishes a "search" surface to admin-context with
  tenants + corpora summary, so the agent gets live state without
  needing a tool call when /search is mounted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 19:17:12 +10:00
jules
eb7bc62d14 search: add Search section calling arcadia-search admin sidecar
New /search route manages tenants and corpora on the arcadia-search
box via its privileged /admin/* surface (default :7801) — KPI tiles,
flat tenant×corpus table with Rebuild / Edit config / Delete
actions, New tenant / New corpus dialogs, and a Restart service
button. New app/lib/search-admin.ts wraps the bearer-token fetch.

Configured by VITE_ARCADIA_SEARCH_ADMIN_URL +
VITE_ARCADIA_SEARCH_ADMIN_TOKEN; the route renders a warning banner
when the token is unset. Token ships in the client bundle — fine for
this internal tool, called out in CLAUDE.md and the source comments.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 16:26:34 +10:00
jules
20c592dfa7 admin: completeness + UI consistency pass
Arcadia wiring:
- home: real Overview dashboard (tenants/users/audit/health probe) replacing the inherited Vibespace welcome tiles; skeleton loaders, refresh button, registers admin context
- profile: split into Account (synced via getUser/updateUser of session user) and local Preferences; updateSessionUser keeps the appbar in sync after edits
- session: drop unused signIn mock, add updateSessionUser, refresh tests
- profile schema: drop redundant Profile.name/email (session is the source of truth)
- routes: delete orphaned resources route + lib

Auth flows that previously 404'd:
- /signup, /login/forgot, /login/reset, /login/2fa wired via @crema/arcadia-auth-ui
- shared AuthShell + AuthBrand wrapper

Assistant tools (admin-tools.ts):
- +10 tools: deactivate_tenant, set_user_status, delete_user, list_memberships, list_roles, revoke_api_key, create_user, update_user, assign_role, remove_role
- list_memberships gains user_id filter for "tenants this user belongs to" queries
- search_kb / read_chunk: new token resolution (window override → VITE_ARCADIA_SEARCH_TOKEN service token → operator session JWT → "dev"); on 401/403 emit a tailored hint based on which token was used

UI consistency:
- new PageHeader component
- AppShell.title was unrendered — dropped; first-child padding on #main-content keeps the floating actions pill from colliding with header content
- removed dead "Sign in required" fallback cards from 14 routes (AppShell already redirects)
- stripped p-6 from outer wrappers across 14 routes (was double-padding under AppShell's own p-6)
- migrated home + tenants to PageHeader

arcadia-search ergonomics:
- scripts/mint-search-token.mjs + `npm run mint:search-token` mints HS512 JWT with required tenant_id claim, upserts VITE_ARCADIA_SEARCH_TOKEN into .env.local
- README/.env document the new VITE_ARCADIA_SEARCH_URL / VITE_ARCADIA_SEARCH_TOKEN knobs
- .env.local now gitignored

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 15:37:31 +10:00
jules
444516e900 docs: RAG.md — combined story for the two RAG surfaces
The browser RAG (@crema/lexical-rag-ui) and the server RAG
(arcadia-search) coexist in this app, and the agent picks between
them via tool descriptions. The two libs each have their own README,
but neither covers the picking decision or how they relate. This doc
fills that gap.

- At-a-glance table: lib, engine, runtime location, corpus size,
  update cadence, auth, agent tool, what it's best for.
- The system-prompt snippet that drives tool picking, with notes on
  observed DeepSeek V3 behavior.
- Per-surface deep dive (build path, tools, storage, limits, why it
  exists).
- Why both run by default (always-on fallback, A/B regression).
- Decision checklist for adding new corpora.
- "What lives where" cheat sheet pointing at the right file in the
  right repo for common tasks.

Cross-references arcadia-search's README.md, MULTI_TENANT.md, and
ARCADIA_INTEGRATION.md so a reader landing here can navigate to the
right rabbit hole.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 14:15:09 +10:00
jules
628691d2df ai: shake-out fixes from first end-to-end search_kb run
Three problems surfaced when driving search_kb / read_chunk through a
real DeepSeek chat for the first time:

1. vite.config.ts: minisearch wasn't in `aliasedDeps`, so when
   @crema/lexical-rag-ui imports it, Vite couldn't resolve the bare
   specifier from the sibling lib's location. Added it next to the
   other shared deps. tsconfig paths alone is not enough — the Vite
   alias is what propagates resolution to sibling-lib code.

2. ai.tsx reindexKB: was using `toast.show?.()` which doesn't exist on
   useToast()'s API. Optional chaining silently no-op'd, so the button
   click ran the fetch but produced zero UI feedback (success or
   failure). Switched to the actual API: toast.info / toast.success /
   toast.error. Added a console.error in the catch arm so the
   underlying exception is visible in DevTools when something does go
   wrong.

3. ai.tsx MAX_TOOL_ITERATIONS: cap was 3, which is too tight for
   agentic search→read→search loops on real questions. Bumped to 6.
   More importantly, when the cap IS reached, the runner now
   synthesises tool-error messages for each pending tool_call and
   continues the chat — instead of silently dropping them, which left
   the conversation with an assistant.tool_calls turn but no matching
   tool messages. DeepSeek (and the OpenAI spec) reject that
   conversation with 400 ("insufficient tool messages following
   tool_calls"), poisoning the thread. Now the model gets a clean
   "max iterations reached" signal and produces a final answer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 22:18:18 +10:00
jules
f5189305c7 ai: wire arcadia-search backend (search_kb + read_chunk + reindex button)
Adds the agent-facing surface for the new Tantivy lexical search service
(arcadia-search). Sits alongside the existing search_docs (browser
MiniSearch) — agent picks based on tool description.

- admin-tools.ts: new search_kb(query, corpus, limit?, tags?) and
  read_chunk(chunk_id, corpus) tools. KB_BASE_URL honors
  window.__ARCADIA_SEARCH_URL runtime override + VITE_ARCADIA_SEARCH_URL
  build env, defaults to localhost:7800. Token resolved per-call from
  sessionStorage.arcadia_access_token (matching lib-arcadia-client's
  storage convention) with "dev" fallback for unauthenticated dev.
- assistant.tsx: system-prompt section telling the agent when to pick
  search_docs (browser, bundled) vs search_kb (server, dynamic +
  expandable via read_chunk).
- ai.tsx: reindexKB() helper + "reindex kb (docs)" button on the empty
  state, next to the existing block-preview button. Toasts on
  start/success/failure. Wired with data-action="kb-reindex-docs" so
  the agent can also trigger via the command bus.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:41:13 +10:00
jules
49a9b019fc ai: migrate docs-search to @crema/lexical-rag-ui
Replace the in-app docs-search.ts and build-docs-index.mjs with the
new sibling lib (@crema/lexical-rag-ui). Wire-up only — same index
shape, same tool response shape, same MiniSearch config, so the agent
sees no behavior change.

- tsconfig + app.css: wire the lib; alias minisearch to consumer's
  node_modules so sibling-lib resolution works.
- admin-tools.ts: createRAGClient("/docs-index.json"), keep search_docs
  tool's response shape unchanged (collapse tags[] back to category).
- ai.tsx: define DocHit locally — it's the tool-response shape, no
  longer the lib's internal type.
- scripts/build-docs-index.mjs: thin wrapper that injects MiniSearch
  and calls buildIndex. Per-app sources list and tags live here.
- package.json: add minisearch dep + build:docs script + prebuild hook.
- .gitignore: don't commit the generated /public/docs-index.json.

Delete: app/lib/docs-search.ts (was untracked; its logic moved to lib).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:34:55 +10:00
jules
9cbe921db7 ai: rich-output blocks via lazy-fetched typed-fence protocol
Assistant replies can now emit typed fenced blocks that render as
@crema/*-ui components inline at their position in the reply.

- message-body.tsx: segmented rendering — alternating prose chunks and
  block dispatch (was: all blocks appended at end). Renderers for kpi,
  table, chart-bar/-line/-donut/-spark, code, diff, flowchart, orgchart,
  steps, checklist, welcome, hint, plus the legacy card kinds.
- block-schemas.ts: single source of truth — BLOCK_INDEX (one-line
  purpose per kind, always in prompt) + SCHEMAS (full JSON shape +
  example, fetched on demand).
- admin-tools.ts: new get_block_schema(kind) tool the model calls once
  per kind per thread to fetch the exact schema. Keeps the always-on
  prompt small (~110 tokens vs ~400 inline).
- assistant.tsx: replaces the inline schema dump with the generated
  thin index.
- ai.tsx: empty-state preview button injects a synthetic assistant
  message exercising every block, for renderer/theme smoke-testing.
- console.css + ai.tsx: shrink ATLAS headline so it doesn't slip under
  the composer with the added preview button.
- tsconfig.json + app.css: wire lib-data-ui, lib-code-ui, lib-diagram-ui,
  lib-onboarding-ui as siblings.

Adding a new block kind = add the lib paths, add a renderer case, add
a schema entry. No prompt edits required.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 22:47:36 +10:00
jules
cdb96499be fix(deepseek): drop /v1 from probe URL
Matches the arcadia-app providers map update — direct-mode "Test
connection" was probing https://api.deepseek.com/v1/models which 404s
on DeepSeek's new endpoint. Now probes /models at the host root.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 20:33:07 +10:00
jules
50afbd7686 login: always render in dark mode regardless of stored preference
The login page is the operator's entry point — it should look the
same every time, not flip between light and dark depending on what
the previous session left in localStorage.

Adds the `dark` class to the login wrapper div instead of
documentElement, so:
- Skyrise's .dark tokens cascade into all descendants (CSS vars defined
  under .dark apply to the subtree).
- After sign-in and navigation, the user's saved light/dark preference
  takes back over for the rest of the app.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 20:22:33 +10:00
jules
169acf3cdd fix(ai): import useMemo (missing since hand-off note refactor)
The handoffNote useMemo I added in the agent-history work referenced
useMemo without importing it. Vite was happy at compile time (lazy
binding) but the page crashes at first paint with "Can't find
variable: useMemo".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 20:18:37 +10:00
jules
c640721c8e ai: composer chip inherits active config's reasoning default
Pulls the reasoning storage out of ai.tsx and into the shared
llm-configs.ts helpers so Settings → LLM and the /ai composer
coordinate via one localStorage key (crema.ai.reasoning):

- loadActiveReasoning / saveActiveReasoning: read/write helpers.
- subscribeActiveReasoning: dispatches a CustomEvent on writes
  (same-tab) plus a storage-event listener (cross-tab), so the
  chip updates live when the operator stars a different config in
  another tab or in the settings panel.

Wiring:
- Settings panel onMakeActive() now also calls
  saveActiveReasoning(c.reasoning_effort ?? "off"). Starring a
  config seeds the chip with that config's default.
- /ai chip useEffect subscribes to changes; a star in Settings
  while /ai is open flips the chip in real time.
- resetAndClear no longer wipes reasoningEffort. Clearing the
  conversation shouldn't silently undo the operator's stated
  intent for thinking-mode (which is bound to their active config,
  not to the conversation).

Net behaviour:
- Star a config with reasoning_effort=medium → chip on /ai shows
  THINK MEDIUM next time you visit (or immediately if /ai is open).
- Cycle the chip while on /ai → just an override for the current
  conversation, not back-propagated to the saved config.
- Edit the config in Settings to change its default → propagates to
  the chip on next star (intentional — direct edits don't auto-
  re-activate).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 20:18:06 +10:00
jules
c379ebc37a ai: per-config reasoning_effort + composer THINK chip
Two layers for thinking-mode control:

1. Per-config default (Settings → LLM)
   New "Reasoning effort" Select in the Add/Edit dialog with
   off/low/medium/high/max + a budget hint per option (~2k, ~8k,
   ~24k, ~64k thinking tokens). Saved row meta line surfaces the
   level inline so it's visible without opening the editor.

2. Per-message override (composer chip)
   New ReasoningChip next to the model picker. Click cycles through
   the same five levels. Hidden chrome when off (muted "think" pill);
   sodium-amber active style with the level label when set.

   Persisted to crema.ai.reasoning so a refresh keeps the operator's
   intent, wiped together with the conversation on Clear.

When sending, withReasoning() merges reasoning_effort into the request
body as a top-level field. The proxy forwards it untouched to OpenAI /
DeepSeek (native field) and translates to Anthropic's thinking block
server-side.

reasoningEffortRef sidesteps a useCallback ordering issue —
regenerateLast/continueLast are declared before the state hook, so
they read the ref instead of a stale closure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 20:15:13 +10:00
jules
20494d1620 ai: persist agent-history + per-message attribution to localStorage
Reloading the page mid-conversation used to keep the messages (via
LIVE_KEY) but lose the two agent-tracking maps, so:
  - row signatures snapped back to whoever was currently active
  - the next turn after reload didn't include the PRIOR HAND-OFF
    block, even though the transcript clearly had multiple personas

Both maps are now stored alongside the live snapshot:
  AGENTS_KEY        crema.ai.agent-history   set of Agent
  MSG_AGENTS_KEY    crema.ai.message-agents  index -> Agent

Stored as JSON arrays since Maps don't serialize. Hydrated on mount
via useState lazy initializer, persisted on every change via two
useEffects, cleared in lockstep with LIVE_KEY when the operator
hits Clear conversation.

Reload mid-thread now reads identically to the pre-reload state:
- atlas» turns 1-3 stay attributed to atlas
- pythia» turn 4 stays attributed to pythia
- next turn after reload still carries the hand-off note

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 20:04:55 +10:00
jules
e4ed05b815 ai: agent hand-off awareness across personas in one conversation
Two related fixes for the "switching agent mid-thread loses context"
issue:

1. LLM-context fix
   The system prompt now includes a PRIOR HAND-OFF block whenever the
   current conversation has been touched by more than one agent. It
   lists the prior personas (name + role) and tells the new agent:
   "Earlier turns were produced by other personas. Read them as
   context, but answer in your own voice as the current persona."
   Without this, switching from Atlas (Operator) to Pythia (Researcher)
   left Pythia answering as if she'd produced Atlas's prior turns.

   Tracked via two-trigger useEffect:
   - On agent change with messages already in the thread, the prior
     agent gets locked into history.
   - On stream finish, the current active agent gets added (it just
     produced a turn).
   Cleared with the conversation.

2. UI-attribution fix
   Each assistant turn now records which agent produced it
   (messageAgents map: index -> Agent). The row signature in
   MessageRow now reads that stamped agent rather than always echoing
   the currently-active one. Switching agents mid-thread no longer
   retroactively re-attributes prior responses.

Both maps are wiped by Clear conversation alongside the live snapshot
and initialLive ref, so a fresh thread starts truly fresh.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 20:03:08 +10:00
jules
a770faf6eb fix(ai): clear conversation actually clears
useChat exposes reset() which calls setMessages(opts.initialMessages),
and the AI page passes initialLive.current as initialMessages — a ref
captured once on mount from localStorage.

resetAndClear was calling reset() then clearLive(). Sequence problem:

  reset()       → setMessages(initialLive.current)  // populated old array
  clearLive()   → localStorage.removeItem(LIVE_KEY) // does nothing to memory

The ref still held the original messages, so reset re-seeded them and
the conversation appeared to "come back" the moment you typed anything
(or sometimes immediately, depending on render timing).

Fix: blank the ref, clear localStorage, and call setMessages([])
directly. reset() is no longer needed at this call site so it's been
dropped from the useChat destructure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 19:59:32 +10:00
jules
2a68389121 button: support Radix-style asChild via base-ui render prop bridge
The project uses @base-ui/react, whose Button has no asChild prop —
just a `render` prop that takes a React element to merge into. About
14 call sites across the routes still use the Radix-shaped
`<Button asChild><Link to="…">…</Link></Button>` pattern, which until
now was producing nested-button DOM violations and asChild leaking as
a DOM attribute.

Bridges asChild → render inside the Button wrapper:

  <Button asChild><Link to="/login">Sign in</Link></Button>

…now renders as a single <a class="…btn classes…">Sign in</a> instead
of <button asChild><a>…</a></button>.

No call-site changes required; consumers keep the Radix ergonomic and
get correct DOM under the hood.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 19:57:53 +10:00
jules
7eb5093071 fix(ai): unbreak model dropdown — base-ui Trigger doesn't take asChild
The composer's model picker had <DropdownMenuTrigger asChild><button>...
which is the Radix Slot pattern. Project uses @base-ui/react where
Menu.Trigger has no asChild prop and renders its own <button>, so the
result was nested-button-inside-button (DOM-nesting violation) plus
asChild leaking as an unknown DOM attribute (React warning).

Dropped the inner <button> and put className/data-action straight on
the Trigger. Visual output identical, no more console errors.

This pattern is used by ~14 other routes (Button asChild + Link),
mostly behind sign-in-required states. They're broken too but rarely
fire — separate followup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 19:43:00 +10:00
jules
066a16bb8b ai: scope console theme to content wrapper, fix font loading
Two bugs in the previous /ai redesign:

1. theme="console" on AppShell put the entire shell (sidebar, appbar,
   appbar dropdowns, the lot) inside [data-theme="console"], so the
   console palette + JetBrains Mono override leaked into the sidebar
   and made light mode look broken on /ai. Scoped now: the AppShell
   stays in skyrise (so light/dark toggle keeps working everywhere),
   and only the route content area gets data-theme="console" via an
   inner wrapper.

2. The Google Fonts @import inside console.css was being silently
   dropped because @import rules must precede all other rules in the
   final bundle, and skyrise's content lands first. Moved JetBrains
   Mono + Newsreader into app.css's top-level @import url() alongside
   the existing Inter/Instrument Sans/Geist Mono families.

Atmosphere ::before was also position: fixed, which painted the grain
overlay across the whole viewport (including the sidebar) regardless
of where data-theme lived. Now position: absolute on the wrapper, with
isolation: isolate to keep z-index local.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 19:39:51 +10:00
jules
4f699bb90e ai: redesign /ai surface as Mission Console
Replaces the conventional chat aesthetic on /ai with a brutalist-mono
operator deck. The page now reads as a flight recorder — turn numbers
in the gutter, hairline rules, sodium-amber phosphor primary on
deep-ink ground, vim-style modeline at the foot.

Type system is the design's load-bearing element:
- JetBrains Mono for everything system-y (operator lines, signatures,
  modeline, session ids, tool calls)
- Newsreader serif for the agent's prose only — the synthesis voice
  literally lifts off the page in a different family from the machine
  voice. Operator and agent are typographically inseparable from their
  speaker.

Layout changes:
- Sticky session header with a giant base36 session id ("3K9P · A4C2")
  and a metadata strip showing agent, model, turn count, status. The
  status pill flips colour: AMBER on stream, ROSE on awaiting confirm,
  MINT on ready, MUTED on mock.
- Empty state is no longer the apologetic "How can I help you today?".
  It's "ATLAS. standing by." in oversize mono with the agent name in
  italic serif amber, a hairline divider, and a single one-liner
  instruction prefixed with ›. Lines stagger in via animation-delay.
- Operator turns: monospace, 14px, sodium-amber › prompt, no bubble.
  Hangs from a left gutter with T01/T02… turn number + UTC timestamp.
- Agent turns: serif, 17px/1.55, with a tiny mono signature underneath
  ("atlas» 03:14:08Z · recv"). Cyan accent column instead of amber.
- Composer: terminal frame (square, 1px border, focus ring is amber
  glow). Internal ›_ prompt mark in front of the textarea, mono input.
- Bottom modeline: utc clock + turn count + estimated tokens on the
  left, keyboard hints on the right. Streaming flips the right side
  to a pulsing phosphor bar + STREAM label.

Atmosphere details:
- 2px scanline overlay (very faint, 1.2% opacity)
- Corner phosphor blooms (amber top-right, cyan bottom-left)
- Inline SVG turbulence grain (3.5% opacity) over the whole theme
- Cursor blink animation on the prompt mark
- Consolas-tier ligatures on the mono via JetBrains Mono ss01/calt

All theming scoped via [data-theme="console"] — picks up automatically
because /ai's AppShell now passes theme="console". Other routes are
untouched. Tool-call cards from @crema/agent-ui inherit the palette
via overridden CSS variables (--card, --border, --primary, etc) plus
a [data-slot="tool-call-card"] override for the frame.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 19:32:22 +10:00
jules
c0eb85d2fe LLM configs: edit/delete buttons available for all rows
Previously gated to tenant-owned rows (tenant_id != null), which made
seed-from-catalog rows uneditable since they default to platform scope
on the backend. The backend doesn't enforce extra ownership rules on
update/delete either, so the gate was a UI overreach.

Buttons now appear on every row. Tooltips clarify when a row is a
platform default so the operator knows the change applies broadly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 18:59:56 +10:00
jules
b397bbcb9e LLM configs: vault secret picker (Select from api_key secrets)
Replaces the free-text secret name input with a Select populated from
/api/v1/admin/secrets, filtered to category=api_key + enabled. Each
option shows the secret name plus its description for context.

Includes "(none — keyless / local)" for lmstudio-style configs and a
"Type a name…" escape hatch for secrets that don't exist in the vault
yet (the proxy will fail loudly at request time if the name is wrong,
which is the right behaviour — better than silently saving a config
that can't authenticate).

Secrets are fetched once on panel load alongside configs/catalog/usage,
not per modal-open, so the dialog opens instantly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 18:49:47 +10:00
jules
5dfceeff94 Settings/LLM: unified panel with per-row active toggle, edit, spend
Reworks the LLM settings surface based on UX feedback. Drops the
separate "Active LLM (this session)" card — its functionality is now
inline on each saved config as a star toggle (writes the same
localStorage key the Assistant reads via @crema/llm-providers-ui's
saveSettings, so the existing assistant code picks the change up
without any plumbing).

Per-row controls now include:
- Star: make this config active for the current browser
- Switch: enable/disable server-side
- Pencil: edit (modal, not inline-expand)
- Trash: delete (with confirm)
- Spend (30d): cost + request count, sourced from
  /api/v1/ai/llm/usage/by-model and matched on (provider, model)

Other improvements:
- Add wizard moved to a Dialog modal instead of pushing the list
  around. Same form handles edit.
- Empty state: "Seed from catalog" button creates a curated starter
  set (GPT-4o mini/4o, Sonnet 4.6, Haiku 4.5, DeepSeek V4 Flash, LM
  Studio) so first-time operators don't face a blank panel.
- Catalog dropdown picks now auto-fill input/output costs as you
  switch models, so the rates always reflect the chosen model unless
  manually overridden.
- The lib's full settings card (system prompt, transport, context
  budget) is still reachable for advanced cases — collapsed into a
  <details> below the panel.

Adds llm-configs.ts: getUsageByModel + findSpend helper for the
per-row spend lookup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 18:47:45 +10:00
jules
bfe61c220a Default to dark mode and small text on first load
The bootstrap script previously fell back to the OS color-scheme
preference and left font-scale unset until the user picked one. For
admin work — dense tables, lots of small text, monitoring dashboards —
dark + sm is the better starting point and matches what most operators
end up choosing anyway.

Users who've already picked a theme/font keep their stored preference;
this only affects fresh sessions where localStorage has no value yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 18:41:54 +10:00
jules
8e07f4b9c0 Settings: real model dropdown with cost hints + Custom escape hatch
The previous datalist-on-input approach was fragile — Safari hid the
suggestions, and there was no visual cue that a dropdown existed.
Replace with a proper Select populated from the catalog. Each option
shows the per-1M-token rates inline so operators see cost while
choosing. "Custom…" switches to free-text for models the catalog
doesn't know about, with a "Catalog" button to flip back.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 18:25:49 +10:00
jules
baf42c4cec Settings: server-side LLM configurations + 30d spend roll-up
Replaces the localStorage-only LLM settings with a persisted catalogue
backed by /api/v1/admin/llm-configurations. The Settings → LLM screen
now has two cards:

- "Saved configurations" — full CRUD against the server. Each row shows
  provider/model/secret/published per-1M-token costs. Add wizard
  auto-fills costs from the curated catalog. One-click "Import local"
  button promotes any pre-existing localStorage settings into a server
  row, then clears the local store.
- "Active LLM (this session)" — the existing LLMProvidersSettingsCard,
  scoped down to "what does the Assistant use right now" (still
  localStorage; per-operator).

Spend (30d) tile in the configurations card header reads
/api/v1/ai/llm/usage/summary and surfaces total cost / requests /
tokens. First visible cost roll-up in the admin UI.

New module app/lib/arcadia/llm-configs.ts: typed CRUD client,
catalog lookup, computeCostCents helper (mirrors the server's
LlmConfiguration.compute_cost_cents/3), and getUsageSummary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 18:06:29 +10:00
jules
29030c9e72 Wire health probes, host stats, and LLM proxy round-trip
Three things from the latest arcadia-app pull:

- health.ts: client for /api/v1/health{,/:service,/detailed,/host}.
  monitoring.tsx now reads real per-subsystem probe state instead of
  synthesizing it from indirect signals (rate limits, sessions, jobs).
- New Host tab on Monitoring with KPI tiles + per-core CPU bars,
  load-avg cards, memory + swap usage, and per-mount disk bars,
  backed by /api/v1/health/host.
- llm-proxy.ts: typed errors (secret_disabled, ip_not_allowed, etc.)
  and a probeProxy() that round-trips a 1-token chat. settings.tsx's
  "Test connection" in proxy mode now exercises the real endpoint
  instead of just confirming the adapter built. Contract doc flipped
  from "not yet implemented" to "implemented".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 17:05:22 +10:00
jules
0fcb9e40f1 Add Buckets, Monitoring, Memberships, Networking, SSO, Announcements, Status page
Full set of admin surfaces on top of /platform/* and /admin/* endpoints,
plus a migration of /assistant onto @crema/llm-providers-ui.

Buckets (/buckets):
  S3-level CRUD over /platform/buckets — list, create, delete (with the
  6-digit confirmation flow the backend enforces), per-bucket configure
  for versioning / CORS rules / policy JSON, plus an object browser
  with FileGrid/FileList from @crema/file-ui and presigned-URL reveal.
  Storage-config picker scopes the view to one credential at a time.

Monitoring (/monitoring):
  Live dashboard. Service health board derived from indirect signals
  (status-ui OverallStatus + ComponentRow). KPI tiles for sessions,
  jobs, audit. Tabs: background jobs (Donut + BarChart + retry recent),
  sessions (Sparkline of last 24h sign-ins), audit activity (BarChart
  of severity / top resource types), infrastructure (DO summary +
  WorldMapSvg coloured by droplet region + droplet list + Spaces),
  rate limits. 30s auto-refresh.

Memberships (/memberships):
  M:N glue between users and tenants over /admin/memberships. Add /
  edit / suspend / activate / remove with role multi-select.

Networking (/networking):
  Tabs over /platform/{firewalls,vpcs,domains,floating_ips}.
  Read/delete on firewalls, read on VPCs, full DNS-record CRUD, and
  inline assign/unassign for floating IPs.

SSO (/sso):
  /sso/identity-providers CRUD with PEM cert as write-only field, plus
  /sso/sessions list with destroy.

Announcements (/announcements):
  /admin/announcements CRUD. Platform-wide vs per-tenant audience,
  schedule windows, dismissible + active toggles.

Status page (/status-page):
  /admin/status-page/{components,incidents,subscribers}. Components
  CRUD, incidents with timeline + post-update + resolve flow,
  subscriber list. Public preview at the top using StatusBoard +
  IncidentTimeline from @crema/status-ui.

Assistant migration:
  /assistant now uses @crema/llm-providers-ui (provider catalog +
  vault key resolution) instead of ~/lib/llm-settings. Same async
  buildAdapter() flow used by /ai. The legacy lib file is now
  unreferenced and can be removed when ready.

New sibling libs wired (cloned from CremaUIStudio):
  lib-file-ui, lib-card-ui, lib-dashboard-ui, lib-chart-ui,
  lib-map-ui, lib-status-ui.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 07:55:46 +10:00
jules
7ba415d78e Wire @crema/llm-providers-ui: multi-provider picker + AI persistence
Replaces the single-base-URL LLM settings with the new providers lib
(OpenAI, Anthropic, DeepSeek, Qwen, LM Studio). Settings/LLM hosts the
catalog-aware card; the /ai route builds adapters via buildAdapter()
and resolves API keys from the arcadia vault per-call (direct mode).
Anthropic skips the /v1/models probe (no such endpoint) and uses
catalog defaults; failed probes for keyed providers fall back to the
catalog instead of dropping to mock.

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

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

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

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

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

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

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

6
.gitignore vendored
View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 *));

View File

@@ -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

View 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>
)
}

View File

@@ -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
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(" ")
}
>
<Icon className="size-5 shrink-0" />
{expanded && <span className="truncate">{item.label}</span>}
</NavLink>
)
})}
<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 (
<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"
>
<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
<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}
item={item}
expanded
mobile
onNavigate={() => setMobileOpen(false)}
/>
))}
{visibleNavGroups.map((group) => {
const isOpen = !!openGroups[group.key]
const GroupIcon = group.icon
return (
<NavLink
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(" ")
}
>
<Icon className="size-5 shrink-0" />
<span>{item.label}</span>
</NavLink>
<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"
>
<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"

View 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>
)
}

View 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>
)
}

View File

@@ -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"

View 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"
}
}

View File

@@ -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={mergedClassName}
render={child}
{...props}
/>
)
}
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
className={mergedClassName}
render={render}
{...props}
/>
>
{children}
</ButtonPrimitive>
)
}
export { Button, buttonVariants }
export type { ButtonProps }

View File

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

View File

@@ -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])
}

View File

@@ -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 secondsminutes 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"

View File

@@ -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"

View File

@@ -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.

View 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}`)
}

View File

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

View File

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

217
app/lib/arcadia/buckets.ts Normal file
View 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>,
})
}

View 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
View 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)
}

View 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)

View File

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

View 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)
}
}

View 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
}
}

View 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
}

View 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
}

View 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`)
}

View 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
}

View 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
View File

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

View File

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

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

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

99
app/lib/arcadia/sso.ts Normal file
View 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}`)
}

View 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
}

View 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 === "***"
}

View File

@@ -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
}

View File

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

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

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

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

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

247
app/lib/block-schemas.ts Normal file
View 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 (26 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 26 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 ~80120px 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
View 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
View 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
View 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
}
}

View 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
}

View 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
}

View File

@@ -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

View File

@@ -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()
})
})

View File

@@ -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
View 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"),
}

View File

@@ -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")
})
})

View File

@@ -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()

View File

@@ -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>

View File

@@ -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

View File

@@ -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>.
</p>
</div>
<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>
</CardContent>
</Card>
<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
}, {})
}

File diff suppressed because it is too large Load Diff

View 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
View 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>
)
}

View File

@@ -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 }),
PROBE_TIMEOUT_MS,
ac.signal,
)
.then((rows) => {
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,
)
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
setStatus({
kind: "mock",
reason: err instanceof Error ? err.message : "LM Studio unreachable",
})
})
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 : "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

File diff suppressed because it is too large Load Diff

View 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>
)
}

View File

@@ -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() {
return (
<AppShell title="Overview">
<Card>
<CardHeader>
<CardTitle>Welcome</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.
</CardDescription>
</CardHeader>
</Card>
const session = useSession()
const arcadia = useArcadiaClient()
<div className="grid gap-4 md:grid-cols-2">
{tiles.map((t) => {
const Icon = t.icon
return (
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>
<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>
Latest audit events across the platform.
</CardDescription>
</div>
<Link
key={t.to}
to={t.to}
data-action={`home-tile-${t.title.toLowerCase()}`}
className="group block"
to="/activity"
data-action="overview-activity-all"
className="text-xs font-medium text-muted-foreground hover:text-foreground"
>
<Card
className={[
"h-full transition-colors",
t.accent
? "border-primary/30 bg-primary/5 hover:border-primary/50"
: "hover:border-foreground/20",
].join(" ")}
>
<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>
</CardHeader>
</Card>
View all
</Link>
)
})}
</CardHeader>
<CardContent>
<RecentActivity logs={data.audit} loading={loading} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Subsystems</CardTitle>
<CardDescription>
Live probe of each platform subsystem.
</CardDescription>
</CardHeader>
<CardContent>
<SubsystemList health={data.health} loading={loading} />
</CardContent>
</Card>
</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
View 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 &amp; 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">Couldnt 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>
)
}

View File

@@ -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
View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -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
View 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

File diff suppressed because it is too large Load Diff

837
app/routes/networking.tsx Normal file
View 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>
)
}

View 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
View 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>
)
}

View File

@@ -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
}
// 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
}
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)
}
reader.readAsDataURL(file)
}
const save = () => {
saveProfile(draft)
setSavedAt(Date.now())
// Mirror the avatar URL into localStorage so it survives reloads.
const savePrefsLocal = (next: Profile) => {
saveProfile(next)
}
const defaultAgent =
agents.find((a) => a.id === draft.defaultAgentId) ?? null
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-email"
type="email"
value={draft.email}
data-action="profile-last-name"
value={accountDraft.last_name}
onChange={(e) =>
setDraft((d) => ({ ...d, email: e.target.value }))
setAccountDraft((d) => ({ ...d, last_name: e.target.value }))
}
autoComplete="email"
/>
</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"
autoComplete="family-name"
disabled={accountLoading || accountSaving}
/>
</Field>
<Field
label="Default agent"
hint="Used as the active persona on first load."
label="Email"
hint="Updating your email may require re-verification."
>
<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>
<Input
data-action="profile-email"
type="email"
value={accountDraft.email}
onChange={(e) =>
setAccountDraft((d) => ({ ...d, email: e.target.value }))
}
autoComplete="email"
disabled={accountLoading || accountSaving}
/>
</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>
)
}

View File

@@ -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>
)
}

View 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
View 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/&lt;id&gt;/
</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

File diff suppressed because it is too large Load Diff

View File

@@ -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,53 +69,94 @@ 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,
})
} finally {
clearTimeout(timeout)
// 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(t)
}
}
// Anthropic doesn't expose a /models list; we just confirm adapter built.
return { ok: true, message: `Adapter ready (${adapter.label ?? adapter.id}).` }
} catch (e) {
return { ok: false, message: e instanceof Error ? e.message : String(e) }
}
}
const dirty =
draft.baseURL !== settings.baseURL ||
draft.contextTokens !== settings.contextTokens ||
draft.responseBudget !== settings.responseBudget
const save = () => {
saveLLMSettings(draft)
setSavedAt(Date.now())
}
const reset = () => {
setDraft(DEFAULT_SETTINGS)
}
const [section, setSection] = useState<SectionId>(() => {
if (typeof window === "undefined") return "llm"
const stored = localStorage.getItem(SECTION_KEY)
@@ -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>
<Field
label="System prompt"
hint="Sent at the start of every conversation. Shapes the assistant's persona and scope. UI Control adds an action-driving preface on top of this when enabled."
>
<Textarea
data-action="settings-system-prompt"
value={draft.systemPrompt}
onChange={(e) =>
setDraft((d) => ({ ...d, systemPrompt: e.target.value }))
}
rows={5}
spellCheck={false}
className="min-h-24 font-mono text-xs"
/>
<button
type="button"
data-action="settings-system-prompt-reset"
onClick={() =>
setDraft((d) => ({
...d,
systemPrompt: DEFAULT_SYSTEM_PROMPT,
}))
}
className="self-start text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
>
Reset to default prompt
</button>
</Field>
<Field
label="Response cap (max tokens)"
hint="Upper bound on each model reply. Smaller = faster, less rambling."
>
<Input
data-action="settings-response-budget"
type="number"
min={64}
step={64}
value={draft.responseBudget}
onChange={(e) =>
setDraft((d) => ({
...d,
responseBudget:
Number(e.target.value) || d.responseBudget,
}))
}
/>
</Field>
<div className="flex flex-wrap items-center gap-2">
<Button
data-action="settings-save"
onClick={save}
disabled={!dirty}
>
Save
</Button>
<Button
data-action="settings-test"
variant="outline"
onClick={runTest}
disabled={test.kind === "running"}
>
{test.kind === "running" ? (
<Loader2 className="size-4 animate-spin" />
) : test.kind === "ok" ? (
<Check className="size-4 text-emerald-600" />
) : test.kind === "fail" ? (
<X className="size-4 text-destructive" />
) : null}
Test connection
</Button>
<Button
data-action="settings-reset"
variant="outline"
onClick={reset}
>
Reset to defaults
</Button>
{savedAt && !dirty && (
<span className="text-sm text-muted-foreground">
Saved.
</span>
)}
{test.kind === "ok" && (
<span className="text-sm text-emerald-700 dark:text-emerald-400">
{test.count} model{test.count === 1 ? "" : "s"} available.
</span>
)}
{test.kind === "fail" && (
<span
className="text-sm text-destructive"
title={test.reason}
>
Failed: {test.reason.slice(0, 60)}
</span>
)}
</div>
</CardContent>
</Card>
</details>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => resetProviderSettings()}
data-action="settings-reset"
>
Reset to defaults
</Button>
<span className="text-xs text-muted-foreground">
Need to manage stored keys? See <a href="/secrets" className="underline">Secrets</a>.
</span>
</div>
</div>
)}
{section === "agents" && <AgentsPanel />}

44
app/routes/signup.tsx Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

843
app/routes/storage.tsx Normal file
View 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]}`
}

View File

@@ -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

File diff suppressed because it is too large Load Diff

900
app/routes/webhooks.tsx Normal file
View 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
View 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
View 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
View 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, ~3080MB resident |
| Practical corpus size | ~510MB / ~50100k 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 H1H3,
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 ~510MB 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, ~3080MB 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
View File

@@ -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",

View File

@@ -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",

View 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
View 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.`)

View File

@@ -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/*"],

View File

@@ -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