From 0fcb9e40f10691e55527e9f33ebd40e01485cf39 Mon Sep 17 00:00:00 2001 From: jules Date: Sat, 2 May 2026 07:55:46 +1000 Subject: [PATCH] Add Buckets, Monitoring, Memberships, Networking, SSO, Announcements, Status page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app/app.css | 6 + app/components/layout/app-shell.tsx | 13 + app/lib/arcadia/announcements.ts | 79 ++ app/lib/arcadia/buckets.ts | 217 ++++ app/lib/arcadia/memberships.ts | 96 ++ app/lib/arcadia/monitoring.ts | 199 ++++ app/lib/arcadia/networking.ts | 162 +++ app/lib/arcadia/sso.ts | 99 ++ app/lib/arcadia/status-page.ts | 172 +++ app/routes.ts | 7 + app/routes/announcements.tsx | 653 ++++++++++++ app/routes/assistant.tsx | 129 ++- app/routes/buckets.tsx | 1491 +++++++++++++++++++++++++++ app/routes/memberships.tsx | 668 ++++++++++++ app/routes/monitoring.tsx | 895 ++++++++++++++++ app/routes/networking.tsx | 858 +++++++++++++++ app/routes/sso.tsx | 676 ++++++++++++ app/routes/status-page.tsx | 1048 +++++++++++++++++++ tsconfig.json | 12 + vite.config.ts | 20 + 20 files changed, 7472 insertions(+), 28 deletions(-) create mode 100644 app/lib/arcadia/announcements.ts create mode 100644 app/lib/arcadia/buckets.ts create mode 100644 app/lib/arcadia/memberships.ts create mode 100644 app/lib/arcadia/monitoring.ts create mode 100644 app/lib/arcadia/networking.ts create mode 100644 app/lib/arcadia/sso.ts create mode 100644 app/lib/arcadia/status-page.ts create mode 100644 app/routes/announcements.tsx create mode 100644 app/routes/buckets.tsx create mode 100644 app/routes/memberships.tsx create mode 100644 app/routes/monitoring.tsx create mode 100644 app/routes/networking.tsx create mode 100644 app/routes/sso.tsx create mode 100644 app/routes/status-page.tsx diff --git a/app/app.css b/app/app.css index 38dba09..5391ae3 100644 --- a/app/app.css +++ b/app/app.css @@ -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 *)); diff --git a/app/components/layout/app-shell.tsx b/app/components/layout/app-shell.tsx index ebbbb5f..ef29412 100644 --- a/app/components/layout/app-shell.tsx +++ b/app/components/layout/app-shell.tsx @@ -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" }, diff --git a/app/lib/arcadia/announcements.ts b/app/lib/arcadia/announcements.ts new file mode 100644 index 0000000..8761220 --- /dev/null +++ b/app/lib/arcadia/announcements.ts @@ -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 { + const res = await arcadia.GET<{ data: Announcement[] }>(BASE) + return res.data +} + +export async function createAnnouncement( + arcadia: ArcadiaClient, + input: AnnouncementInput, +): Promise { + const res = await arcadia.POST<{ data: Announcement }>(BASE, { + body: { announcement: input }, + }) + return res.data +} + +export async function updateAnnouncement( + arcadia: ArcadiaClient, + id: string, + input: Partial, +): Promise { + const res = await arcadia.PUT<{ data: Announcement }>(`${BASE}/${id}`, { + body: { announcement: input }, + }) + return res.data +} + +export async function deleteAnnouncement(arcadia: ArcadiaClient, id: string): Promise { + await arcadia.DELETE(`${BASE}/${id}`) +} diff --git a/app/lib/arcadia/buckets.ts b/app/lib/arcadia/buckets.ts new file mode 100644 index 0000000..f6bf877 --- /dev/null +++ b/app/lib/arcadia/buckets.ts @@ -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 { + 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 { + return arcadia.POST(`${BASE}/create`, { body: input }) +} + +export async function deleteBucket( + arcadia: ArcadiaClient, + input: DeleteBucketInput, +): Promise { + 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 { + 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 { + return arcadia.POST(`${BASE}/versioning`, { body: input }) +} + +export async function configureLifecycle( + arcadia: ArcadiaClient, + input: { + storage_config_id: string + bucket_name: string + rules: Array> + dry_run?: boolean + }, +): Promise { + 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 { + return arcadia.POST(`${BASE}/replication`, { body: input }) +} + +export async function configurePolicy( + arcadia: ArcadiaClient, + input: { + storage_config_id: string + bucket_name: string + policy: Record + dry_run?: boolean + }, +): Promise { + 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 { + return arcadia.POST(`${BASE}/cors`, { body: input }) +} + +export async function deleteCors( + arcadia: ArcadiaClient, + storageConfigId: string, + bucketName: string, +): Promise { + 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 { + return arcadia.GET(`${BASE}/objects`, { + params: params as Record, + }) +} + +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, + }) +} diff --git a/app/lib/arcadia/memberships.ts b/app/lib/arcadia/memberships.ts new file mode 100644 index 0000000..6fd43d9 --- /dev/null +++ b/app/lib/arcadia/memberships.ts @@ -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 + roles: MembershipRole[] +} + +export interface MembershipInput { + user_id: string + status?: MembershipStatus + metadata?: Record + role_ids?: string[] +} + +const BASE = "/api/v1/admin/memberships" + +export async function listMemberships(arcadia: ArcadiaClient): Promise { + const res = await arcadia.GET<{ data: Membership[] }>(BASE) + return res.data +} + +export async function createMembership( + arcadia: ArcadiaClient, + input: MembershipInput, +): Promise { + const res = await arcadia.POST<{ data: Membership }>(BASE, { + body: { membership: input }, + }) + return res.data +} + +export async function updateMembership( + arcadia: ArcadiaClient, + id: string, + input: Partial, +): Promise { + const res = await arcadia.PATCH<{ data: Membership }>(`${BASE}/${id}`, { + body: { membership: input }, + }) + return res.data +} + +export async function deleteMembership(arcadia: ArcadiaClient, id: string): Promise { + await arcadia.DELETE(`${BASE}/${id}`) +} + +export async function suspendMembership( + arcadia: ArcadiaClient, + id: string, +): Promise { + const res = await arcadia.POST<{ data: Membership }>(`${BASE}/${id}/suspend`) + return res.data +} + +export async function activateMembership( + arcadia: ArcadiaClient, + id: string, +): Promise { + const res = await arcadia.POST<{ data: Membership }>(`${BASE}/${id}/activate`) + return res.data +} diff --git a/app/lib/arcadia/monitoring.ts b/app/lib/arcadia/monitoring.ts new file mode 100644 index 0000000..d33f252 --- /dev/null +++ b/app/lib/arcadia/monitoring.ts @@ -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 { + 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 + by_queue: Record>> + 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 { + 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 { + const res = await arcadia.GET<{ data: { jobs: ObanJob[]; count: number } }>( + "/api/v1/admin/monitoring/jobs", + { params: params as Record }, + ) + return res.data.jobs ?? [] +} + +export async function retryJob(arcadia: ArcadiaClient, id: number): Promise { + 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 +export type Space = Record + +export async function getInfrastructureSummary( + arcadia: ArcadiaClient, +): Promise { + 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 { + 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 { + 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 { + 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 + by_severity?: Record + by_resource_type?: Record + /** 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 { + const res = await arcadia.GET<{ data: AuditStats }>( + "/api/v1/observability/audit_stats", + { params: params as Record }, + ) + return res.data +} diff --git a/app/lib/arcadia/networking.ts b/app/lib/arcadia/networking.ts new file mode 100644 index 0000000..4fc4ba6 --- /dev/null +++ b/app/lib/arcadia/networking.ts @@ -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 + created_at?: string + [key: string]: unknown +} + +export async function listFirewalls(arcadia: ArcadiaClient): Promise { + 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, +): Promise { + return arcadia.POST(`${BASE}/firewalls`, { body: input }) +} + +export async function deleteFirewall( + arcadia: ArcadiaClient, + id: string | number, +): Promise { + 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 { + 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 { + 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 { + 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 { + return arcadia.POST(`${BASE}/domains/${encodeURIComponent(domainName)}/records`, { + body: input, + }) +} + +export async function deleteDnsRecord( + arcadia: ArcadiaClient, + domainName: string, + recordId: string | number, +): Promise { + 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 { + 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 { + return arcadia.POST(`${BASE}/floating_ips/${ip}/assign`, { + body: { droplet_id: dropletId }, + }) +} + +export async function unassignFloatingIp( + arcadia: ArcadiaClient, + ip: string, +): Promise { + return arcadia.POST(`${BASE}/floating_ips/${ip}/unassign`) +} diff --git a/app/lib/arcadia/sso.ts b/app/lib/arcadia/sso.ts new file mode 100644 index 0000000..f4a3f9e --- /dev/null +++ b/app/lib/arcadia/sso.ts @@ -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 + 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 + 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 { + const res = await arcadia.GET<{ data: IdentityProvider[] }>(`${BASE}/identity-providers`) + return res.data +} + +export async function createIdentityProvider( + arcadia: ArcadiaClient, + input: IdentityProviderInput, +): Promise { + 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, +): Promise { + 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 { + await arcadia.DELETE(`${BASE}/identity-providers/${id}`) +} + +export async function listSamlSessions(arcadia: ArcadiaClient): Promise { + const res = await arcadia.GET<{ data: SamlSession[] }>(`${BASE}/sessions`) + return res.data +} + +export async function destroySamlSession( + arcadia: ArcadiaClient, + sessionId: string, +): Promise { + await arcadia.DELETE(`${BASE}/sessions/${sessionId}`) +} diff --git a/app/lib/arcadia/status-page.ts b/app/lib/arcadia/status-page.ts new file mode 100644 index 0000000..c43a982 --- /dev/null +++ b/app/lib/arcadia/status-page.ts @@ -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 + 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 +} + +export interface IncidentUpdateInput { + status: IncidentStatus + body: string +} + +const BASE = "/api/v1/admin/status-page" + +// --- Components --------------------------------------------------------- + +export async function listComponents(arcadia: ArcadiaClient): Promise { + const res = await arcadia.GET<{ data: StatusComponent[] }>(`${BASE}/components`) + return res.data +} + +export async function createComponent( + arcadia: ArcadiaClient, + input: ComponentInput, +): Promise { + 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, +): Promise { + 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 { + await arcadia.DELETE(`${BASE}/components/${id}`) +} + +// --- Incidents ---------------------------------------------------------- + +export async function listIncidents(arcadia: ArcadiaClient): Promise { + const res = await arcadia.GET<{ data: Incident[] }>(`${BASE}/incidents`) + return res.data +} + +export async function getIncident(arcadia: ArcadiaClient, id: string): Promise { + const res = await arcadia.GET<{ data: Incident }>(`${BASE}/incidents/${id}`) + return res.data +} + +export async function createIncident( + arcadia: ArcadiaClient, + input: IncidentInput, +): Promise { + 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, +): Promise { + 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 { + 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 { + 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 { + const res = await arcadia.GET<{ data: Subscriber[] }>(`${BASE}/subscribers`) + return res.data +} diff --git a/app/routes.ts b/app/routes.ts index f24ca25..f709856 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -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 diff --git a/app/routes/announcements.tsx b/app/routes/announcements.tsx new file mode 100644 index 0000000..c380f15 --- /dev/null +++ b/app/routes/announcements.tsx @@ -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([]) + const [tenants, setTenants] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [info, setInfo] = useState(null) + const [search, setSearch] = useState("") + const [editor, setEditor] = useState(null) + const [pendingDelete, setPendingDelete] = useState(null) + + const 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[]>( + () => [ + { + id: "title", + header: "Title", + accessor: "title", + sortable: true, + cell: (a) => ( +
+ {a.title} + {a.body ? ( + {a.body} + ) : null} +
+ ), + }, + { + id: "type", + header: "Type", + accessor: "announcement_type", + sortable: true, + cell: (a) => , + }, + { + id: "scope", + header: "Scope", + cell: (a) => + a.tenant_id ? ( + tenant + ) : ( + platform + ), + }, + { + id: "active", + header: "Active", + accessor: "active", + sortable: true, + cell: (a) => ( + + ), + }, + { + id: "window", + header: "Window", + cell: (a) => ( + + {a.starts_at ? new Date(a.starts_at).toLocaleDateString() : "—"} + {" → "} + {a.ends_at ? new Date(a.ends_at).toLocaleDateString() : "∞"} + + ), + }, + { + id: "updated", + header: "Updated", + accessor: "updated_at", + sortable: true, + cell: (a) => , + }, + { + 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: , + destructive: true, + dataAction: `announcement-${a.id}-delete`, + onSelect: () => setPendingDelete(a), + }, + ] + return + }, + }, + ], + [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({ + data: items, + columns, + getRowId: (a) => a.id, + initialPageSize: 25, + initialSearch: search, + }) + useEffect(() => { + table.setSearch(search) + }, [search, table]) + + if (!session) { + return ( + +
+ + + Sign in required + Announcements require an admin session. + + + + + +
+
+ ) + } + + return ( + +
+
+
+

Announcements

+

+ Platform-wide and per-tenant banners. Apps consuming arcadia surface these to users. +

+
+
+ + +
+
+ + {error ? ( + setError(null)}> + {error} + + ) : null} + {info ? ( + setInfo(null)}> + {info} + + ) : null} + + + + +
+ {table.total} of {items.length} +
+
+ + + + {table.total === 0 && !loading ? ( + } + 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" + /> + ) : ( + <> + a.id} + sort={table.sort} + onSortToggle={table.toggleSort} + loading={loading && items.length > 0} + stickyHeader + /> + + + )} + +
+
+ + !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) + } + }} + /> + + setEditor(null)} + onSaved={async (msg) => { + setEditor(null) + if (msg) setInfo(msg) + await refresh() + }} + onError={setError} + /> +
+ ) +} + +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(arr: T[], key: (x: T) => string): Record { + return arr.reduce>((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 + 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("info") + const [audience, setAudience] = useState<"platform" | "tenant">("platform") + const [tenantId, setTenantId] = useState("") + 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 ( + !o && onClose()}> + + + {isEdit ? "Edit announcement" : "New announcement"} + + Banners surface in apps that consume arcadia. Active + currently within the start/end + window = visible. + + + +
+
+ + setTitle(e.target.value)} + data-action="announcement-form-title" + /> +
+
+ +