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>
This commit is contained in:
jules
2026-05-02 07:55:46 +10:00
parent 7ba415d78e
commit 0fcb9e40f1
20 changed files with 7472 additions and 28 deletions

View File

@@ -19,6 +19,12 @@
@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";
/* CREMA:SOURCES */
@custom-variant dark (&:is(.dark *));

View File

@@ -25,6 +25,12 @@ import {
KeyRound,
Webhook as WebhookIcon,
CalendarClock,
Gauge,
UserCheck,
Network,
ShieldCheck,
Megaphone,
AlertOctagon,
// CREMA:NAV-ICONS
} from "lucide-react"
@@ -92,12 +98,19 @@ type NavItem = {
const navItems: NavItem[] = [
{ to: "/", icon: LayoutDashboard, label: "Overview", end: true },
{ to: "/monitoring", icon: Gauge, label: "Monitoring" },
{ to: "/tenants", icon: Building2, label: "Tenants" },
{ to: "/storage", icon: HardDrive, label: "Storage" },
{ to: "/buckets", icon: Boxes, label: "Buckets" },
{ to: "/users", icon: UsersIcon, label: "Users" },
{ to: "/memberships", icon: UserCheck, label: "Memberships" },
{ to: "/sso", icon: ShieldCheck, label: "SSO" },
{ to: "/secrets", icon: KeyRound, label: "Secrets" },
{ to: "/webhooks", icon: WebhookIcon, label: "Webhooks" },
{ to: "/scheduled-tasks", icon: CalendarClock, label: "Scheduled" },
{ to: "/networking", icon: Network, label: "Networking" },
{ to: "/announcements", icon: Megaphone, label: "Announcements" },
{ to: "/status-page", icon: AlertOctagon, label: "Status page" },
{ to: "/activity", icon: Activity, label: "Audit log" },
{ to: "/ai", icon: Bot, label: "AI" },
{ to: "/settings", icon: Settings, label: "Settings" },

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

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

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

@@ -16,5 +16,12 @@ export default [
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("networking", "routes/networking.tsx"),
route("sso", "routes/sso.tsx"),
route("announcements", "routes/announcements.tsx"),
route("status-page", "routes/status-page.tsx"),
// CREMA:ROUTES
] satisfies RouteConfig

View File

@@ -0,0 +1,653 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { Link } from "react-router"
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 { useRegisterAdminContext } from "~/lib/admin-context"
export const meta = () => pageTitle("Announcements")
const TYPES: AnnouncementType[] = ["info", "warning", "maintenance", "incident", "feature"]
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 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)
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Failed to load announcements.")
} finally {
setLoading(false)
}
}, [arcadia])
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: "Scope",
cell: (a) =>
a.tenant_id ? (
<Badge variant="secondary">tenant</Badge>
) : (
<Badge>platform</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],
)
const summary = useMemo(
() => ({
total: items.length,
active: items.filter((a) => a.active).length,
byType: countBy(items, (a) => a.announcement_type),
}),
[items],
)
useRegisterAdminContext("announcements", summary)
const table = useTable<Announcement>({
data: items,
columns,
getRowId: (a) => a.id,
initialPageSize: 25,
initialSearch: search,
})
useEffect(() => {
table.setSearch(search)
}, [search, table])
if (!session) {
return (
<AppShell title="Announcements">
<div className="p-8">
<Card className="max-w-md">
<CardHeader>
<CardTitle>Sign in required</CardTitle>
<CardDescription>Announcements require an admin session.</CardDescription>
</CardHeader>
<CardContent>
<Button asChild>
<Link to="/login?next=/announcements">Sign in</Link>
</Button>
</CardContent>
</Card>
</div>
</AppShell>
)
}
return (
<AppShell title="Announcements">
<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">Announcements</h1>
<p className="text-sm text-muted-foreground">
Platform-wide and per-tenant banners. Apps consuming arcadia surface these to users.
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={refresh}
disabled={loading}
data-action="announcements-refresh"
>
<RefreshCw className={`size-4 ${loading ? "animate-spin" : ""}`} />
Refresh
</Button>
<Button
size="sm"
onClick={() => setEditor({ kind: "create" })}
data-action="announcements-create"
>
<Plus className="size-4" />
New announcement
</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 title, body, or type"
data-action="announcements-search"
className="max-w-sm flex-1"
/>
<div className="ml-auto text-xs text-muted-foreground">
{table.total} of {items.length}
</div>
</CardHeader>
<CardContent className="relative p-0">
<LoadingOverlay
active={loading && items.length === 0}
label="Loading announcements…"
/>
{table.total === 0 && !loading ? (
<EmptyState
icon={<Megaphone className="size-6" />}
title={search ? "No announcements match." : "No announcements yet."}
description={
search ? "Try a different search." : "Post the first one — platform-wide or scoped to a tenant."
}
className="py-12"
/>
) : (
<>
<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)
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)
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) {
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-xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{isEdit ? "Edit announcement" : "New announcement"}</DialogTitle>
<DialogDescription>
Banners surface in apps that consume arcadia. Active + currently within the start/end
window = visible.
</DialogDescription>
</DialogHeader>
<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"
/>
</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={4}
data-action="announcement-form-body"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>Type</Label>
<Select value={type} onValueChange={setType}>
<SelectTrigger data-action="announcement-form-type">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TYPES.map((t) => (
<SelectItem key={t} value={t}>
{t}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label>Audience</Label>
<Select value={audience} onValueChange={(v) => setAudience(v as "platform" | "tenant")}>
<SelectTrigger data-action="announcement-form-audience">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="platform">Platform-wide</SelectItem>
<SelectItem value="tenant">Single tenant</SelectItem>
</SelectContent>
</Select>
</div>
{audience === "tenant" ? (
<div className="col-span-2 flex flex-col gap-1.5">
<Label>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 at</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 at</Label>
<Input
id="ann-ends"
type="datetime-local"
value={endsAt}
onChange={(e) => setEndsAt(e.target.value)}
data-action="announcement-form-ends"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="ann-action-label">Action label (optional)</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">Action URL (optional)</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 className="flex items-center justify-between rounded-md border px-3 py-2">
<Label className="text-sm">Dismissible</Label>
<Switch
checked={dismissible}
onCheckedChange={setDismissible}
data-action="announcement-form-dismissible"
/>
</div>
<div className="flex items-center justify-between rounded-md border px-3 py-2">
<Label className="text-sm">Active</Label>
<Switch
checked={active}
onCheckedChange={setActive}
data-action="announcement-form-active"
/>
</div>
</div>
<DialogFooter>
<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" />}
{isEdit ? "Save" : "Post"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -136,7 +136,6 @@ function withTimeout<T>(p: Promise<T>, ms: number, signal: AbortSignal): Promise
import {
LLMProvider,
MockLLM,
OpenAICompatibleAdapter,
listModels,
useChat,
useCompletion,
@@ -154,7 +153,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,
@@ -233,7 +236,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 +287,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,14 +396,6 @@ 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"
@@ -339,7 +412,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}

1491
app/routes/buckets.tsx Normal file

File diff suppressed because it is too large Load Diff

668
app/routes/memberships.tsx Normal file
View File

@@ -0,0 +1,668 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { Link } from "react-router"
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 { useRegisterAdminContext } from "~/lib/admin-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],
)
useRegisterAdminContext("memberships", summary)
const table = useTable<Membership>({
data: filtered,
columns,
getRowId: (m) => m.id,
initialPageSize: 25,
initialSearch: search,
})
useEffect(() => {
table.setSearch(search)
}, [search, table])
if (!session) {
return (
<AppShell title="Memberships">
<div className="p-8">
<Card className="max-w-md">
<CardHeader>
<CardTitle>Sign in required</CardTitle>
<CardDescription>Membership management requires an admin session.</CardDescription>
</CardHeader>
<CardContent>
<Button asChild>
<Link to="/login?next=/memberships">Sign in</Link>
</Button>
</CardContent>
</Card>
</div>
</AppShell>
)
}
return (
<AppShell title="Memberships">
<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">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

895
app/routes/monitoring.tsx Normal file
View File

@@ -0,0 +1,895 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { Link } from "react-router"
import {
Activity,
AlertTriangle,
Cpu,
Database,
Globe,
HardDrive,
Loader2,
RefreshCw,
RotateCw,
Server,
Users,
Zap,
} from "lucide-react"
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
import { AlertBanner } from "@crema/feedback-ui"
import {
BarChart,
Donut,
Heatmap,
LineChart,
Sparkline,
type ChartDatum,
type SeriesPoint,
} from "@crema/chart-ui"
import { KpiTile, formatCompact, formatPercent } from "@crema/dashboard-ui"
import {
ComponentRow,
IncidentTimeline,
OverallStatus,
type ComponentState,
type StatusComponent,
type StatusIncident,
} from "@crema/status-ui"
import {
ChoroplethMap,
WorldMapSvg,
} from "@crema/map-ui"
import { formatBytes } from "@crema/file-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 { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"
import {
getActiveSessions,
getAuditStats,
getInfrastructureSummary,
getJobStats,
getRateLimits,
getRecentJobs,
getSpaces,
listDroplets,
retryJob,
type ActiveSession,
type AuditStats,
type Droplet,
type InfrastructureSummary,
type JobStats,
type ObanJob,
type RateLimit,
type Space,
} from "~/lib/arcadia/monitoring"
import { pageTitle } from "~/lib/page-meta"
import { useSession } from "~/lib/session"
import { useRegisterAdminContext } from "~/lib/admin-context"
export const meta = () => pageTitle("Monitoring")
interface DashboardData {
jobStats: JobStats | null
recentJobs: ObanJob[]
sessions: { sessions: ActiveSession[]; count: number } | null
rateLimits: RateLimit[]
infraSummary: InfrastructureSummary | null
spaces: Space[]
droplets: Droplet[]
auditStats: AuditStats | null
}
const EMPTY: DashboardData = {
jobStats: null,
recentJobs: [],
sessions: null,
rateLimits: [],
infraSummary: null,
spaces: [],
droplets: [],
auditStats: null,
}
export default function MonitoringRoute() {
const session = useSession()
const arcadia = useArcadiaClient()
const [data, setData] = useState<DashboardData>(EMPTY)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [autoRefresh, setAutoRefresh] = useState(true)
const refresh = useCallback(async () => {
setError(null)
setLoading(true)
try {
const [
jobStats,
recentJobs,
sessions,
rateLimits,
infraSummary,
spaces,
droplets,
auditStats,
] = await Promise.all([
getJobStats(arcadia).catch(() => null),
getRecentJobs(arcadia, { limit: 50 }).catch(() => []),
getActiveSessions(arcadia).catch(() => null),
getRateLimits(arcadia).catch(() => []),
getInfrastructureSummary(arcadia),
getSpaces(arcadia),
listDroplets(arcadia),
getAuditStats(arcadia, {
from: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
}).catch(() => null),
])
setData({
jobStats,
recentJobs,
sessions,
rateLimits,
infraSummary,
spaces,
droplets,
auditStats,
})
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Failed to load monitoring data.")
} finally {
setLoading(false)
}
}, [arcadia])
useEffect(() => {
if (session) refresh()
}, [session, refresh])
// Auto-refresh every 30s for the live feel.
useEffect(() => {
if (!session || !autoRefresh) return
const t = setInterval(refresh, 30000)
return () => clearInterval(t)
}, [session, autoRefresh, refresh])
const components = useMemo(() => buildStatusComponents(data), [data])
const summary = useMemo(
() => ({
jobs: data.jobStats?.counts ?? {},
jobs_executing: data.jobStats?.counts?.executing ?? 0,
jobs_retryable: data.jobStats?.counts?.retryable ?? 0,
sessions_24h: data.sessions?.count ?? 0,
droplets: data.droplets.length,
spaces: data.spaces.length,
audit_total_7d: data.auditStats?.total ?? 0,
}),
[data],
)
useRegisterAdminContext("monitoring", summary)
if (!session) {
return (
<AppShell title="Monitoring">
<div className="p-8">
<Card className="max-w-md">
<CardHeader>
<CardTitle>Sign in required</CardTitle>
<CardDescription>Monitoring requires an admin session.</CardDescription>
</CardHeader>
<CardContent>
<Button asChild>
<Link to="/login?next=/monitoring">Sign in</Link>
</Button>
</CardContent>
</Card>
</div>
</AppShell>
)
}
return (
<AppShell title="Monitoring">
<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">Server stats &amp; health</h1>
<p className="text-sm text-muted-foreground">
Live view of background jobs, active sessions, infrastructure, and audit
activity. Refreshes every 30s.
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant={autoRefresh ? "outline" : "ghost"}
size="sm"
onClick={() => setAutoRefresh((v) => !v)}
data-action="monitoring-auto-refresh"
>
<RotateCw className={`size-4 ${autoRefresh ? "animate-pulse" : ""}`} />
Auto-refresh {autoRefresh ? "on" : "off"}
</Button>
<Button
size="sm"
onClick={refresh}
disabled={loading}
data-action="monitoring-refresh"
>
<RefreshCw className={`size-4 ${loading ? "animate-spin" : ""}`} />
Refresh
</Button>
</div>
</header>
{error ? (
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
{error}
</AlertBanner>
) : null}
{/* Service status board */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Service health</CardTitle>
<CardDescription>Derived from live signals on each subsystem.</CardDescription>
</div>
<OverallStatus components={components} />
</CardHeader>
<CardContent className="flex flex-col gap-1">
{components.map((c) => (
<ComponentRow key={c.id} component={c} showUptime={false} />
))}
</CardContent>
</Card>
{/* KPI tiles */}
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<KpiTile
label="Active sessions (24h)"
value={formatCompact(data.sessions?.count ?? 0)}
icon={<Users className="size-4" />}
/>
<KpiTile
label="Jobs executing"
value={String(data.jobStats?.counts?.executing ?? 0)}
icon={<Zap className="size-4" />}
tone={
(data.jobStats?.counts?.executing ?? 0) > 0 ? "info" : "neutral"
}
/>
<KpiTile
label="Retryable jobs"
value={String(data.jobStats?.counts?.retryable ?? 0)}
icon={<AlertTriangle className="size-4" />}
tone={
(data.jobStats?.counts?.retryable ?? 0) > 0 ? "warning" : "neutral"
}
/>
<KpiTile
label="Audit events (7d)"
value={formatCompact(data.auditStats?.total ?? 0)}
icon={<Activity className="size-4" />}
/>
</div>
<Tabs defaultValue="jobs">
<TabsList>
<TabsTrigger value="jobs" data-action="monitoring-tab-jobs">
Background jobs
</TabsTrigger>
<TabsTrigger value="sessions" data-action="monitoring-tab-sessions">
Sessions
</TabsTrigger>
<TabsTrigger value="audit" data-action="monitoring-tab-audit">
Audit activity
</TabsTrigger>
<TabsTrigger value="infra" data-action="monitoring-tab-infra">
Infrastructure
</TabsTrigger>
<TabsTrigger value="rate-limits" data-action="monitoring-tab-rate-limits">
Rate limits
</TabsTrigger>
</TabsList>
<TabsContent value="jobs" className="pt-4">
<JobsPanel
stats={data.jobStats}
recent={data.recentJobs}
onRetry={async (id) => {
try {
await retryJob(arcadia, id)
await refresh()
} catch (err) {
setError(
err instanceof ArcadiaError ? err.message : "Retry failed.",
)
}
}}
/>
</TabsContent>
<TabsContent value="sessions" className="pt-4">
<SessionsPanel sessions={data.sessions} />
</TabsContent>
<TabsContent value="audit" className="pt-4">
<AuditPanel stats={data.auditStats} />
</TabsContent>
<TabsContent value="infra" className="pt-4">
<InfraPanel
summary={data.infraSummary}
spaces={data.spaces}
droplets={data.droplets}
/>
</TabsContent>
<TabsContent value="rate-limits" className="pt-4">
<RateLimitsPanel limits={data.rateLimits} />
</TabsContent>
</Tabs>
</div>
</AppShell>
)
}
// Synthesize a status board from the live signals we have.
function buildStatusComponents(d: DashboardData): StatusComponent[] {
const apiOk = d.rateLimits.length > 0
const dbOk = d.sessions !== null
const workersState: ComponentState = (() => {
if (!d.jobStats) return "partial-outage"
const r = d.jobStats.counts.retryable ?? 0
const x = d.jobStats.counts.discarded ?? 0
if (x > 100) return "major-outage"
if (r > 50 || x > 0) return "degraded"
return "operational"
})()
const storageState: ComponentState =
d.spaces.length > 0 || d.infraSummary ? "operational" : "partial-outage"
return [
{
id: "api",
name: "API",
description: "/api/v1 — auth, REST endpoints",
state: apiOk ? "operational" : "partial-outage",
},
{
id: "db",
name: "Database",
description: "Postgres — sessions, audit log",
state: dbOk ? "operational" : "partial-outage",
},
{
id: "workers",
name: "Background workers",
description: "Oban — webhook delivery, scheduled tasks",
state: workersState,
},
{
id: "storage",
name: "Storage",
description: "DigitalOcean Spaces / S3-compatible object storage",
state: storageState,
},
]
}
// --- Jobs panel --------------------------------------------------------
function JobsPanel({
stats,
recent,
onRetry,
}: {
stats: JobStats | null
recent: ObanJob[]
onRetry: (id: number) => Promise<void>
}) {
if (!stats) {
return <PanelStub icon={<Server className="size-5" />} text="No job stats available." />
}
const stateData: ChartDatum[] = (Object.entries(stats.counts) as [string, number][])
.filter(([, n]) => n > 0)
.map(([state, n]) => ({ label: state, value: n, color: jobStateColor(state) }))
const queueData: ChartDatum[] = stats.queues.map((q) => {
const totals = stats.by_queue[q] ?? {}
const sum = Object.values(totals).reduce<number>((a, n) => a + (n ?? 0), 0)
return { label: q, value: sum }
})
return (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-1 gap-3 lg:grid-cols-3">
<Card>
<CardHeader>
<CardTitle className="text-base">Jobs by state</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-center">
{stateData.length === 0 ? (
<p className="py-6 text-sm text-muted-foreground">No active jobs.</p>
) : (
<Donut data={stateData} size={180} thickness={28} />
)}
<ul className="ml-4 flex flex-col gap-1 text-xs">
{stateData.map((d) => (
<li key={d.label} className="flex items-center gap-2">
<span
className="size-2.5 rounded-full"
style={{ background: d.color }}
aria-hidden
/>
<span className="font-mono">{d.label}</span>
<span className="ml-auto text-muted-foreground">{d.value}</span>
</li>
))}
</ul>
</CardContent>
</Card>
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle className="text-base">Active jobs by queue</CardTitle>
</CardHeader>
<CardContent>
{queueData.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">
No queued or executing jobs.
</p>
) : (
<BarChart data={queueData} width={520} height={180} />
)}
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">Recent jobs</CardTitle>
<CardDescription>Latest 50 newest first.</CardDescription>
</CardHeader>
<CardContent className="p-0">
{recent.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">No recent jobs.</p>
) : (
<ul className="divide-y border-y">
{recent.map((j) => (
<li key={j.id} className="flex items-center justify-between gap-3 px-3 py-2">
<div className="flex flex-col gap-0.5">
<span className="flex items-center gap-2 text-sm">
<Badge variant={jobStateVariant(j.state)}>{j.state}</Badge>
<code className="font-mono text-xs">{j.worker}</code>
<span className="text-xs text-muted-foreground">queue: {j.queue}</span>
</span>
<span className="text-[11px] text-muted-foreground">
attempt {j.attempt}/{j.max_attempts} · inserted{" "}
{new Date(j.inserted_at).toLocaleString()}
{j.completed_at
? ` · completed ${new Date(j.completed_at).toLocaleString()}`
: ""}
</span>
{j.errors && j.errors.length > 0 ? (
<span className="text-xs text-destructive">
{j.errors[j.errors.length - 1]?.error ?? "(error)"}
</span>
) : null}
</div>
{j.state === "retryable" || j.state === "discarded" ? (
<Button
variant="outline"
size="sm"
onClick={() => onRetry(j.id)}
data-action={`monitoring-job-${j.id}-retry`}
>
<RotateCw className="size-3.5" />
Retry
</Button>
) : null}
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
)
}
// --- Sessions panel ----------------------------------------------------
function SessionsPanel({
sessions,
}: {
sessions: { sessions: ActiveSession[]; count: number } | null
}) {
if (!sessions) {
return <PanelStub icon={<Users className="size-5" />} text="No session data available." />
}
// Bucket sign-ins by hour for a 24h sparkline.
const hourly = useMemo(() => {
const now = Date.now()
const buckets = Array.from({ length: 24 }, (_, i) => ({
x: i,
y: 0,
}))
for (const s of sessions.sessions) {
const t = new Date(s.last_sign_in_at).getTime()
const ago = (now - t) / (60 * 60 * 1000)
const idx = 23 - Math.floor(ago)
if (idx >= 0 && idx < 24) buckets[idx].y++
}
return buckets
}, [sessions])
return (
<div className="flex flex-col gap-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-base">Sign-ins over the last 24h</CardTitle>
<CardDescription>One bar per hour, latest on the right.</CardDescription>
</div>
<Sparkline data={hourly} width={240} height={48} stroke="var(--primary)" />
</CardHeader>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">{sessions.count} recent sessions</CardTitle>
</CardHeader>
<CardContent className="p-0">
{sessions.sessions.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">
No sign-ins in the last 24 hours.
</p>
) : (
<ul className="divide-y border-y">
{sessions.sessions.map((s) => (
<li
key={s.user_id}
className="flex items-center justify-between gap-3 px-3 py-2"
>
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium">{s.email}</span>
<span className="text-xs text-muted-foreground">
{s.first_name || s.last_name
? `${s.first_name ?? ""} ${s.last_name ?? ""}`.trim() + " · "
: ""}
{s.user_type ?? "user"} · status: {s.status}
{s.two_factor_enabled ? " · 2FA" : ""}
</span>
</div>
<span className="text-xs text-muted-foreground">
{new Date(s.last_sign_in_at).toLocaleString()}
</span>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
)
}
// --- Audit panel -------------------------------------------------------
function AuditPanel({ stats }: { stats: AuditStats | null }) {
if (!stats) {
return <PanelStub icon={<Activity className="size-5" />} text="No audit stats available." />
}
const bySeverity: ChartDatum[] = Object.entries(stats.by_severity ?? {}).map(
([k, v]) => ({ label: k, value: v, color: severityColor(k) }),
)
const byResource: ChartDatum[] = Object.entries(stats.by_resource_type ?? {})
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([k, v]) => ({ label: k, value: v }))
// Time series — only render if backend supplied it.
const series: SeriesPoint[] | null = stats.over_time
? stats.over_time.map((p, i) => ({ x: i, y: p.total }))
: null
return (
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-base">Events by severity (7d)</CardTitle>
</CardHeader>
<CardContent>
{bySeverity.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">No events.</p>
) : (
<BarChart data={bySeverity} width={500} height={180} />
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Top resource types</CardTitle>
</CardHeader>
<CardContent>
{byResource.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">No events.</p>
) : (
<BarChart data={byResource} width={500} height={Math.max(180, byResource.length * 22)} />
)}
</CardContent>
</Card>
{series ? (
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle className="text-base">Events over time</CardTitle>
</CardHeader>
<CardContent>
<LineChart data={series} width={900} height={200} />
</CardContent>
</Card>
) : null}
</div>
)
}
// --- Infrastructure panel ----------------------------------------------
function InfraPanel({
summary,
spaces,
droplets,
}: {
summary: InfrastructureSummary | null
spaces: Space[]
droplets: Droplet[]
}) {
const dropletsByRegion = useMemo(() => {
const out: Record<string, number> = {}
for (const d of droplets) {
const r =
typeof d.region === "string"
? d.region
: d.region?.slug ?? d.region?.name ?? "unknown"
out[r] = (out[r] ?? 0) + 1
}
return out
}, [droplets])
if (!summary && spaces.length === 0 && droplets.length === 0) {
return (
<PanelStub
icon={<Globe className="size-5" />}
text="No infrastructure connected. Wire a DigitalOcean token in arcadia's .env to see this section populate."
/>
)
}
return (
<div className="flex flex-col gap-4">
{summary ? (
<Card>
<CardHeader>
<CardTitle className="text-base">DigitalOcean summary</CardTitle>
</CardHeader>
<CardContent>
<pre className="overflow-x-auto rounded-md border bg-muted/50 p-3 text-xs">
{JSON.stringify(summary, null, 2)}
</pre>
</CardContent>
</Card>
) : null}
<div className="grid grid-cols-1 gap-3 lg:grid-cols-3">
<KpiTile
label="Droplets"
value={String(droplets.length)}
icon={<Server className="size-4" />}
/>
<KpiTile
label="Spaces"
value={String(spaces.length)}
icon={<HardDrive className="size-4" />}
/>
<KpiTile
label="Regions in use"
value={String(Object.keys(dropletsByRegion).length)}
icon={<Globe className="size-4" />}
/>
</div>
{droplets.length > 0 ? (
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-base">Droplet regions</CardTitle>
<CardDescription>Coloured continents indicate any droplets in that hemisphere.</CardDescription>
</CardHeader>
<CardContent>
<WorldMapSvg
regionColors={regionColorsFor(dropletsByRegion)}
className="w-full"
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Droplets ({droplets.length})</CardTitle>
</CardHeader>
<CardContent className="p-0">
<ul className="divide-y border-y">
{droplets.slice(0, 20).map((d) => (
<li
key={String(d.id)}
className="flex items-center justify-between gap-3 px-3 py-2"
>
<div className="flex flex-col gap-0.5">
<span className="flex items-center gap-2 text-sm font-medium">
<Cpu className="size-3.5 text-muted-foreground" />
{d.name}
</span>
<span className="text-xs text-muted-foreground">
{typeof d.region === "string"
? d.region
: d.region?.slug ?? "—"}
{d.size_slug ? ` · ${d.size_slug}` : ""}
{d.vcpus ? ` · ${d.vcpus} vCPU` : ""}
{d.memory ? ` · ${formatBytes(d.memory * 1024 * 1024)}` : ""}
</span>
</div>
<Badge
variant={d.status === "active" ? "default" : "secondary"}
>
{d.status}
</Badge>
</li>
))}
{droplets.length > 20 ? (
<li className="px-3 py-2 text-center text-xs text-muted-foreground">
+ {droplets.length - 20} more
</li>
) : null}
</ul>
</CardContent>
</Card>
</div>
) : null}
{spaces.length > 0 ? (
<Card>
<CardHeader>
<CardTitle className="text-base">Spaces ({spaces.length})</CardTitle>
</CardHeader>
<CardContent>
<pre className="overflow-x-auto rounded-md border bg-muted/50 p-3 text-xs">
{JSON.stringify(spaces, null, 2)}
</pre>
</CardContent>
</Card>
) : null}
</div>
)
}
function regionColorsFor(byRegion: Record<string, number>): Record<string, string> {
// Best-effort mapping from DO region slugs to continent IDs the WorldMapSvg knows.
// The lib exposes regions like "north-america", "europe", "asia", etc.
const colors: Record<string, string> = {}
const continentOf = (r: string): string | null => {
const lc = r.toLowerCase()
if (/^(nyc|sfo|tor|ams_tor)/.test(lc)) return "north-america"
if (/^(lon|ams|fra)/.test(lc)) return "europe"
if (/^(blr|sgp)/.test(lc)) return "asia"
if (/^(syd)/.test(lc)) return "oceania"
return null
}
for (const r of Object.keys(byRegion)) {
const c = continentOf(r)
if (c) colors[c] = "var(--primary)"
}
return colors
}
// --- Rate limits panel -------------------------------------------------
function RateLimitsPanel({ limits }: { limits: RateLimit[] }) {
if (limits.length === 0) {
return (
<PanelStub
icon={<Database className="size-5" />}
text="No rate-limit configuration available."
/>
)
}
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Configured rate limits</CardTitle>
<CardDescription>
The maximum requests allowed per window for each authenticated bucket.
</CardDescription>
</CardHeader>
<CardContent className="p-0">
<ul className="divide-y border-y">
{limits.map((l) => (
<li key={l.type} className="flex items-center justify-between gap-3 px-3 py-2">
<div className="flex flex-col">
<span className="text-sm font-medium capitalize">{l.type}</span>
<span className="text-xs text-muted-foreground">
Window: {l.window_seconds}s
</span>
</div>
<span className="font-mono text-sm">
{l.max_requests.toLocaleString()} req
</span>
</li>
))}
</ul>
</CardContent>
</Card>
)
}
// --- helpers ----------------------------------------------------------
function PanelStub({ icon, text }: { icon: React.ReactNode; text: string }) {
return (
<Card>
<CardContent className="flex items-center gap-3 py-8 text-sm text-muted-foreground">
{icon}
{text}
</CardContent>
</Card>
)
}
function jobStateColor(state: string): string {
switch (state) {
case "executing":
return "#3b82f6"
case "available":
case "scheduled":
return "#a3a3a3"
case "retryable":
return "#f59e0b"
case "discarded":
return "#ef4444"
case "cancelled":
return "#737373"
case "completed":
return "#10b981"
default:
return "#9ca3af"
}
}
function jobStateVariant(
state: string,
): "default" | "secondary" | "destructive" | "outline" {
if (state === "executing" || state === "completed") return "default"
if (state === "discarded") return "destructive"
if (state === "retryable" || state === "scheduled") return "secondary"
return "outline"
}
function severityColor(s: string): string {
if (s === "critical" || s === "error") return "#ef4444"
if (s === "warning") return "#f59e0b"
return "#94a3b8"
}

858
app/routes/networking.tsx Normal file
View File

@@ -0,0 +1,858 @@
import { useCallback, useEffect, useState } from "react"
import { Link } from "react-router"
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 { useRegisterAdminContext } from "~/lib/admin-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])
useRegisterAdminContext("networking", {
firewalls: firewalls.length,
vpcs: vpcs.length,
domains: domains.length,
floating_ips: floatingIps.length,
droplets: droplets.length,
})
if (!session) {
return (
<AppShell title="Networking">
<div className="p-8">
<Card className="max-w-md">
<CardHeader>
<CardTitle>Sign in required</CardTitle>
<CardDescription>Networking requires an admin session.</CardDescription>
</CardHeader>
<CardContent>
<Button asChild>
<Link to="/login?next=/networking">Sign in</Link>
</Button>
</CardContent>
</Card>
</div>
</AppShell>
)
}
return (
<AppShell title="Networking">
<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">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>
)
}

676
app/routes/sso.tsx Normal file
View File

@@ -0,0 +1,676 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { Link } from "react-router"
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 { useRegisterAdminContext } from "~/lib/admin-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])
useRegisterAdminContext("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,
})
if (!session) {
return (
<AppShell title="SSO">
<div className="p-8">
<Card className="max-w-md">
<CardHeader>
<CardTitle>Sign in required</CardTitle>
<CardDescription>SSO administration requires an admin session.</CardDescription>
</CardHeader>
<CardContent>
<Button asChild>
<Link to="/login?next=/sso">Sign in</Link>
</Button>
</CardContent>
</Card>
</div>
</AppShell>
)
}
return (
<AppShell title="SSO">
<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">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>
)
}

1048
app/routes/status-page.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -42,6 +42,18 @@
"@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:PATHS": [""],
"react": ["./node_modules/@types/react"],
"react/*": ["./node_modules/@types/react/*"],

View File

@@ -71,6 +71,21 @@ const llmUiSrc = fileURLToPath(
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),
)
// 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
@@ -126,6 +141,11 @@ export default defineConfig({
"@crema/arcadia-auth-ui": `${arcadiaAuthUiSrc}/index.tsx`,
"@crema/llm-ui": `${llmUiSrc}/index.tsx`,
"@crema/llm-providers-ui": `${llmProvidersUiSrc}/index.tsx`,
"@crema/file-ui": `${fileUiSrc}/index.tsx`,
"@crema/card-ui": `${cardUiSrc}/index.tsx`,
"@crema/dashboard-ui": `${dashboardUiSrc}/index.tsx`,
"@crema/chart-ui": `${chartUiSrc}/index.tsx`,
"@crema/status-ui": `${statusUiSrc}/index.tsx`,
...sharedDepAliases,
},
dedupe: dedupeDeps,