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:
@@ -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 *));
|
||||
|
||||
@@ -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" },
|
||||
|
||||
79
app/lib/arcadia/announcements.ts
Normal file
79
app/lib/arcadia/announcements.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
// Platform announcements helpers.
|
||||
// Backend: /api/v1/admin/announcements (admin CRUD).
|
||||
|
||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
||||
|
||||
export type AnnouncementType =
|
||||
| "info"
|
||||
| "warning"
|
||||
| "maintenance"
|
||||
| "incident"
|
||||
| "feature"
|
||||
| string
|
||||
|
||||
export type AnnouncementAudience = "all" | "tenant" | "platform" | string
|
||||
|
||||
export interface Announcement {
|
||||
id: string
|
||||
tenant_id: string | null
|
||||
announcement_type: AnnouncementType
|
||||
title: string
|
||||
body: string | null
|
||||
action_label: string | null
|
||||
action_url: string | null
|
||||
starts_at: string | null
|
||||
ends_at: string | null
|
||||
audience: AnnouncementAudience
|
||||
dismissible: boolean
|
||||
active: boolean
|
||||
created_by_id: string | null
|
||||
inserted_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface AnnouncementInput {
|
||||
title: string
|
||||
body?: string
|
||||
announcement_type?: AnnouncementType
|
||||
audience?: AnnouncementAudience
|
||||
action_label?: string | null
|
||||
action_url?: string | null
|
||||
starts_at?: string | null
|
||||
ends_at?: string | null
|
||||
dismissible?: boolean
|
||||
active?: boolean
|
||||
/** Platform-wide if null, otherwise scoped. */
|
||||
tenant_id?: string | null
|
||||
}
|
||||
|
||||
const BASE = "/api/v1/admin/announcements"
|
||||
|
||||
export async function listAnnouncements(arcadia: ArcadiaClient): Promise<Announcement[]> {
|
||||
const res = await arcadia.GET<{ data: Announcement[] }>(BASE)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function createAnnouncement(
|
||||
arcadia: ArcadiaClient,
|
||||
input: AnnouncementInput,
|
||||
): Promise<Announcement> {
|
||||
const res = await arcadia.POST<{ data: Announcement }>(BASE, {
|
||||
body: { announcement: input },
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function updateAnnouncement(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
input: Partial<AnnouncementInput>,
|
||||
): Promise<Announcement> {
|
||||
const res = await arcadia.PUT<{ data: Announcement }>(`${BASE}/${id}`, {
|
||||
body: { announcement: input },
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function deleteAnnouncement(arcadia: ArcadiaClient, id: string): Promise<void> {
|
||||
await arcadia.DELETE(`${BASE}/${id}`)
|
||||
}
|
||||
217
app/lib/arcadia/buckets.ts
Normal file
217
app/lib/arcadia/buckets.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
// Platform-level bucket management.
|
||||
// Backend: /api/v1/platform/buckets/*. All operations require a
|
||||
// storage_config_id pointing at a credential row in /api/v1/storage_configs.
|
||||
|
||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
||||
|
||||
export interface Bucket {
|
||||
name: string
|
||||
region?: string
|
||||
size_bytes?: number | null
|
||||
object_count?: number | null
|
||||
created_at?: string | null
|
||||
/** Backend may return additional provider-specific fields. */
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface BucketObject {
|
||||
key: string
|
||||
size: number
|
||||
last_modified: string | null
|
||||
etag: string | null
|
||||
storage_class: string | null
|
||||
}
|
||||
|
||||
export interface ListObjectsResponse {
|
||||
objects: BucketObject[]
|
||||
is_truncated: boolean
|
||||
continuation_token: string | null
|
||||
prefix: string | null
|
||||
bucket_name: string
|
||||
}
|
||||
|
||||
export interface CreateBucketInput {
|
||||
storage_config_id: string
|
||||
bucket_name: string
|
||||
region?: string
|
||||
acl?: "private" | "public-read" | string
|
||||
versioning?: boolean
|
||||
/** Pre-validate without creating. Default false. */
|
||||
dry_run?: boolean
|
||||
}
|
||||
|
||||
export interface DeleteBucketInput {
|
||||
storage_config_id: string
|
||||
bucket_name: string
|
||||
/** 6-digit code from /confirmation-code. */
|
||||
confirmation_code?: string
|
||||
/** DANGEROUS — empty the bucket first. */
|
||||
force_empty?: boolean
|
||||
/** Verify a backup exists before delete. Default true. */
|
||||
verify_backup?: boolean
|
||||
/** Preview-only. Default true on first call so the UI can confirm. */
|
||||
dry_run?: boolean
|
||||
}
|
||||
|
||||
const BASE = "/api/v1/platform/buckets"
|
||||
|
||||
export async function listBuckets(
|
||||
arcadia: ArcadiaClient,
|
||||
storageConfigId: string,
|
||||
): Promise<Bucket[]> {
|
||||
const res = await arcadia.GET<{ buckets: Bucket[]; count: number }>(`${BASE}/list`, {
|
||||
params: { storage_config_id: storageConfigId },
|
||||
})
|
||||
return res.buckets ?? []
|
||||
}
|
||||
|
||||
export async function createBucket(
|
||||
arcadia: ArcadiaClient,
|
||||
input: CreateBucketInput,
|
||||
): Promise<unknown> {
|
||||
return arcadia.POST(`${BASE}/create`, { body: input })
|
||||
}
|
||||
|
||||
export async function deleteBucket(
|
||||
arcadia: ArcadiaClient,
|
||||
input: DeleteBucketInput,
|
||||
): Promise<unknown> {
|
||||
return arcadia.POST(`${BASE}/delete`, { body: input })
|
||||
}
|
||||
|
||||
export async function generateConfirmationCode(
|
||||
arcadia: ArcadiaClient,
|
||||
storageConfigId: string,
|
||||
bucketName: string,
|
||||
): Promise<{ code: string; expires_at?: string }> {
|
||||
return arcadia.GET(`${BASE}/confirmation-code`, {
|
||||
params: { storage_config_id: storageConfigId, bucket_name: bucketName },
|
||||
})
|
||||
}
|
||||
|
||||
export async function listRegions(
|
||||
arcadia: ArcadiaClient,
|
||||
storageConfigId: string,
|
||||
): Promise<string[]> {
|
||||
const res = await arcadia.GET<{ regions?: string[]; data?: string[] }>(`${BASE}/regions`, {
|
||||
params: { storage_config_id: storageConfigId },
|
||||
})
|
||||
return res.regions ?? res.data ?? []
|
||||
}
|
||||
|
||||
// --- Versioning / lifecycle / replication / policy / CORS -----------------
|
||||
|
||||
export async function configureVersioning(
|
||||
arcadia: ArcadiaClient,
|
||||
input: { storage_config_id: string; bucket_name: string; enabled: boolean; dry_run?: boolean },
|
||||
): Promise<unknown> {
|
||||
return arcadia.POST(`${BASE}/versioning`, { body: input })
|
||||
}
|
||||
|
||||
export async function configureLifecycle(
|
||||
arcadia: ArcadiaClient,
|
||||
input: {
|
||||
storage_config_id: string
|
||||
bucket_name: string
|
||||
rules: Array<Record<string, unknown>>
|
||||
dry_run?: boolean
|
||||
},
|
||||
): Promise<unknown> {
|
||||
return arcadia.POST(`${BASE}/lifecycle`, { body: input })
|
||||
}
|
||||
|
||||
export async function configureReplication(
|
||||
arcadia: ArcadiaClient,
|
||||
input: {
|
||||
storage_config_id: string
|
||||
bucket_name: string
|
||||
destination_bucket: string
|
||||
destination_region?: string
|
||||
dry_run?: boolean
|
||||
},
|
||||
): Promise<unknown> {
|
||||
return arcadia.POST(`${BASE}/replication`, { body: input })
|
||||
}
|
||||
|
||||
export async function configurePolicy(
|
||||
arcadia: ArcadiaClient,
|
||||
input: {
|
||||
storage_config_id: string
|
||||
bucket_name: string
|
||||
policy: Record<string, unknown>
|
||||
dry_run?: boolean
|
||||
},
|
||||
): Promise<unknown> {
|
||||
return arcadia.POST(`${BASE}/policy`, { body: input })
|
||||
}
|
||||
|
||||
export interface CorsRule {
|
||||
allowed_origins: string[]
|
||||
allowed_methods: string[]
|
||||
allowed_headers?: string[]
|
||||
expose_headers?: string[]
|
||||
max_age_seconds?: number
|
||||
}
|
||||
|
||||
export async function getCors(
|
||||
arcadia: ArcadiaClient,
|
||||
storageConfigId: string,
|
||||
bucketName: string,
|
||||
): Promise<{ rules: CorsRule[] } | null> {
|
||||
return arcadia.GET(`${BASE}/cors`, {
|
||||
params: { storage_config_id: storageConfigId, bucket_name: bucketName },
|
||||
})
|
||||
}
|
||||
|
||||
export async function configureCors(
|
||||
arcadia: ArcadiaClient,
|
||||
input: {
|
||||
storage_config_id: string
|
||||
bucket_name: string
|
||||
rules: CorsRule[]
|
||||
dry_run?: boolean
|
||||
},
|
||||
): Promise<unknown> {
|
||||
return arcadia.POST(`${BASE}/cors`, { body: input })
|
||||
}
|
||||
|
||||
export async function deleteCors(
|
||||
arcadia: ArcadiaClient,
|
||||
storageConfigId: string,
|
||||
bucketName: string,
|
||||
): Promise<unknown> {
|
||||
return arcadia.DELETE(`${BASE}/cors`, {
|
||||
params: { storage_config_id: storageConfigId, bucket_name: bucketName },
|
||||
})
|
||||
}
|
||||
|
||||
// --- Objects ---------------------------------------------------------------
|
||||
|
||||
export async function listObjects(
|
||||
arcadia: ArcadiaClient,
|
||||
params: {
|
||||
storage_config_id: string
|
||||
bucket_name: string
|
||||
prefix?: string
|
||||
max_keys?: number
|
||||
continuation_token?: string
|
||||
},
|
||||
): Promise<ListObjectsResponse> {
|
||||
return arcadia.GET<ListObjectsResponse>(`${BASE}/objects`, {
|
||||
params: params as Record<string, string | number | boolean | null | undefined>,
|
||||
})
|
||||
}
|
||||
|
||||
export async function getPresignedUrl(
|
||||
arcadia: ArcadiaClient,
|
||||
params: {
|
||||
storage_config_id: string
|
||||
bucket_name: string
|
||||
key: string
|
||||
expires_in?: number
|
||||
},
|
||||
): Promise<{ url: string; expires_at?: string; expires_in?: number }> {
|
||||
return arcadia.GET(`${BASE}/presigned-url`, {
|
||||
params: params as Record<string, string | number | undefined>,
|
||||
})
|
||||
}
|
||||
96
app/lib/arcadia/memberships.ts
Normal file
96
app/lib/arcadia/memberships.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
// Tenant memberships — the M:N glue between users and tenants.
|
||||
// Backend: /api/v1/admin/memberships (admin) + /api/v1/me/tenants (self).
|
||||
|
||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
||||
|
||||
export type MembershipStatus = "active" | "suspended" | "deactivated" | string
|
||||
|
||||
export interface MembershipUser {
|
||||
id: string
|
||||
email: string
|
||||
first_name: string | null
|
||||
last_name: string | null
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface MembershipTenant {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface MembershipRole {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
export interface Membership {
|
||||
id: string
|
||||
tenant_id: string
|
||||
tenant: MembershipTenant | null
|
||||
user_id: string
|
||||
user: MembershipUser | null
|
||||
status: MembershipStatus
|
||||
is_primary: boolean
|
||||
joined_at: string | null
|
||||
last_accessed_at: string | null
|
||||
metadata: Record<string, unknown>
|
||||
roles: MembershipRole[]
|
||||
}
|
||||
|
||||
export interface MembershipInput {
|
||||
user_id: string
|
||||
status?: MembershipStatus
|
||||
metadata?: Record<string, unknown>
|
||||
role_ids?: string[]
|
||||
}
|
||||
|
||||
const BASE = "/api/v1/admin/memberships"
|
||||
|
||||
export async function listMemberships(arcadia: ArcadiaClient): Promise<Membership[]> {
|
||||
const res = await arcadia.GET<{ data: Membership[] }>(BASE)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function createMembership(
|
||||
arcadia: ArcadiaClient,
|
||||
input: MembershipInput,
|
||||
): Promise<Membership> {
|
||||
const res = await arcadia.POST<{ data: Membership }>(BASE, {
|
||||
body: { membership: input },
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function updateMembership(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
input: Partial<MembershipInput>,
|
||||
): Promise<Membership> {
|
||||
const res = await arcadia.PATCH<{ data: Membership }>(`${BASE}/${id}`, {
|
||||
body: { membership: input },
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function deleteMembership(arcadia: ArcadiaClient, id: string): Promise<void> {
|
||||
await arcadia.DELETE(`${BASE}/${id}`)
|
||||
}
|
||||
|
||||
export async function suspendMembership(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
): Promise<Membership> {
|
||||
const res = await arcadia.POST<{ data: Membership }>(`${BASE}/${id}/suspend`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function activateMembership(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
): Promise<Membership> {
|
||||
const res = await arcadia.POST<{ data: Membership }>(`${BASE}/${id}/activate`)
|
||||
return res.data
|
||||
}
|
||||
199
app/lib/arcadia/monitoring.ts
Normal file
199
app/lib/arcadia/monitoring.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
// Server stats / health helpers.
|
||||
// Wraps /api/v1/admin/monitoring/* + /api/v1/platform/* + a few observability
|
||||
// endpoints used by the monitoring dashboard.
|
||||
|
||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
||||
|
||||
// --- Rate limits ---------------------------------------------------------
|
||||
|
||||
export interface RateLimit {
|
||||
type: string
|
||||
max_requests: number
|
||||
window_seconds: number
|
||||
}
|
||||
|
||||
export async function getRateLimits(arcadia: ArcadiaClient): Promise<RateLimit[]> {
|
||||
const res = await arcadia.GET<{ data: { limits: RateLimit[] } }>(
|
||||
"/api/v1/admin/monitoring/rate-limits",
|
||||
)
|
||||
return res.data.limits ?? []
|
||||
}
|
||||
|
||||
// --- Active sessions ----------------------------------------------------
|
||||
|
||||
export interface ActiveSession {
|
||||
user_id: string
|
||||
email: string
|
||||
first_name: string | null
|
||||
last_name: string | null
|
||||
status: string
|
||||
user_type: string | null
|
||||
last_sign_in_at: string
|
||||
tenant_id: string
|
||||
two_factor_enabled: boolean
|
||||
}
|
||||
|
||||
export async function getActiveSessions(
|
||||
arcadia: ArcadiaClient,
|
||||
): Promise<{ sessions: ActiveSession[]; count: number }> {
|
||||
const res = await arcadia.GET<{ data: { sessions: ActiveSession[]; count: number } }>(
|
||||
"/api/v1/admin/monitoring/sessions",
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
// --- Background jobs (Oban) ---------------------------------------------
|
||||
|
||||
export type JobState =
|
||||
| "available"
|
||||
| "executing"
|
||||
| "scheduled"
|
||||
| "retryable"
|
||||
| "discarded"
|
||||
| "cancelled"
|
||||
| "completed"
|
||||
|
||||
export interface JobStats {
|
||||
counts: Record<JobState, number>
|
||||
by_queue: Record<string, Partial<Record<JobState, number>>>
|
||||
queues: string[]
|
||||
}
|
||||
|
||||
export interface ObanJob {
|
||||
id: number
|
||||
queue: string
|
||||
state: JobState
|
||||
worker: string
|
||||
attempt: number
|
||||
max_attempts: number
|
||||
inserted_at: string
|
||||
attempted_at: string | null
|
||||
completed_at: string | null
|
||||
scheduled_at: string | null
|
||||
errors: Array<{ at?: string; attempt?: number; error?: string }> | null
|
||||
}
|
||||
|
||||
export async function getJobStats(arcadia: ArcadiaClient): Promise<JobStats> {
|
||||
const res = await arcadia.GET<{ data: JobStats }>(
|
||||
"/api/v1/admin/monitoring/jobs/stats",
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function getRecentJobs(
|
||||
arcadia: ArcadiaClient,
|
||||
params?: { limit?: number; state?: JobState; queue?: string },
|
||||
): Promise<ObanJob[]> {
|
||||
const res = await arcadia.GET<{ data: { jobs: ObanJob[]; count: number } }>(
|
||||
"/api/v1/admin/monitoring/jobs",
|
||||
{ params: params as Record<string, string | number | undefined> },
|
||||
)
|
||||
return res.data.jobs ?? []
|
||||
}
|
||||
|
||||
export async function retryJob(arcadia: ArcadiaClient, id: number): Promise<void> {
|
||||
await arcadia.POST(`/api/v1/admin/monitoring/jobs/${id}/retry`)
|
||||
}
|
||||
|
||||
// --- Platform infrastructure (DigitalOcean) -----------------------------
|
||||
|
||||
/** Provider returns whatever it returns; admin UI surfaces it loosely. */
|
||||
export type InfrastructureSummary = Record<string, unknown>
|
||||
export type Space = Record<string, unknown>
|
||||
|
||||
export async function getInfrastructureSummary(
|
||||
arcadia: ArcadiaClient,
|
||||
): Promise<InfrastructureSummary | null> {
|
||||
try {
|
||||
const res = await arcadia.GET<{ data: InfrastructureSummary }>(
|
||||
"/api/v1/platform/infrastructure/summary",
|
||||
)
|
||||
return res.data
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSpaces(arcadia: ArcadiaClient): Promise<Space[]> {
|
||||
try {
|
||||
const res = await arcadia.GET<{ data: Space[] }>(
|
||||
"/api/v1/platform/infrastructure/spaces",
|
||||
)
|
||||
return res.data ?? []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// --- Droplets ------------------------------------------------------------
|
||||
|
||||
export interface Droplet {
|
||||
id: number | string
|
||||
name: string
|
||||
status: string
|
||||
region?: { slug?: string; name?: string } | string
|
||||
size_slug?: string
|
||||
vcpus?: number
|
||||
memory?: number
|
||||
disk?: number
|
||||
created_at?: string
|
||||
networks?: unknown
|
||||
/** Provider-specific fields surface verbatim. */
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface DropletMetrics {
|
||||
cpu?: Array<{ time: string; value: number }>
|
||||
memory?: Array<{ time: string; value: number }>
|
||||
disk?: Array<{ time: string; value: number }>
|
||||
bandwidth?: Array<{ time: string; value: number }>
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export async function listDroplets(arcadia: ArcadiaClient): Promise<Droplet[]> {
|
||||
try {
|
||||
const res = await arcadia.GET<{ droplets?: Droplet[]; data?: Droplet[] }>(
|
||||
"/api/v1/platform/droplets",
|
||||
)
|
||||
return res.droplets ?? res.data ?? []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDropletMetrics(
|
||||
arcadia: ArcadiaClient,
|
||||
id: number | string,
|
||||
): Promise<DropletMetrics | null> {
|
||||
try {
|
||||
const res = await arcadia.GET<{ data: DropletMetrics }>(
|
||||
`/api/v1/platform/droplets/${id}/metrics`,
|
||||
)
|
||||
return res.data
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// --- Audit stats (already used by /activity, exposed here for the dashboard) ---
|
||||
|
||||
export interface AuditStats {
|
||||
total: number
|
||||
by_action?: Record<string, number>
|
||||
by_severity?: Record<string, number>
|
||||
by_resource_type?: Record<string, number>
|
||||
/** When backend supports it: { period: ISO, total: number }[] */
|
||||
over_time?: Array<{ period: string; total: number }>
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export async function getAuditStats(
|
||||
arcadia: ArcadiaClient,
|
||||
params?: { from?: string; to?: string },
|
||||
): Promise<AuditStats> {
|
||||
const res = await arcadia.GET<{ data: AuditStats }>(
|
||||
"/api/v1/observability/audit_stats",
|
||||
{ params: params as Record<string, string | undefined> },
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
162
app/lib/arcadia/networking.ts
Normal file
162
app/lib/arcadia/networking.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
// Networking helpers: firewalls, VPCs, domains + DNS records, floating IPs.
|
||||
// Backend: /api/v1/platform/{firewalls,vpcs,domains,floating_ips,...}.
|
||||
|
||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
||||
|
||||
const BASE = "/api/v1/platform"
|
||||
|
||||
// --- Firewalls ----------------------------------------------------------
|
||||
|
||||
export interface Firewall {
|
||||
id: string | number
|
||||
name: string
|
||||
status?: string
|
||||
inbound_rules?: unknown[]
|
||||
outbound_rules?: unknown[]
|
||||
droplet_ids?: Array<string | number>
|
||||
created_at?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export async function listFirewalls(arcadia: ArcadiaClient): Promise<Firewall[]> {
|
||||
try {
|
||||
const res = await arcadia.GET<{ firewalls?: Firewall[]; data?: Firewall[] }>(
|
||||
`${BASE}/firewalls`,
|
||||
)
|
||||
return res.firewalls ?? res.data ?? []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function createFirewall(
|
||||
arcadia: ArcadiaClient,
|
||||
input: Partial<Firewall>,
|
||||
): Promise<unknown> {
|
||||
return arcadia.POST(`${BASE}/firewalls`, { body: input })
|
||||
}
|
||||
|
||||
export async function deleteFirewall(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string | number,
|
||||
): Promise<void> {
|
||||
await arcadia.DELETE(`${BASE}/firewalls/${id}`)
|
||||
}
|
||||
|
||||
// --- VPCs ---------------------------------------------------------------
|
||||
|
||||
export interface Vpc {
|
||||
id: string
|
||||
name: string
|
||||
region?: string
|
||||
ip_range?: string
|
||||
default?: boolean
|
||||
created_at?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export async function listVpcs(arcadia: ArcadiaClient): Promise<Vpc[]> {
|
||||
try {
|
||||
const res = await arcadia.GET<{ vpcs?: Vpc[]; data?: Vpc[] }>(`${BASE}/vpcs`)
|
||||
return res.vpcs ?? res.data ?? []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// --- Domains + DNS records ----------------------------------------------
|
||||
|
||||
export interface Domain {
|
||||
name: string
|
||||
ttl?: number
|
||||
zone_file?: string | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface DnsRecord {
|
||||
id: string | number
|
||||
type: string
|
||||
name: string
|
||||
data: string
|
||||
priority?: number | null
|
||||
port?: number | null
|
||||
ttl?: number
|
||||
weight?: number | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export async function listDomains(arcadia: ArcadiaClient): Promise<Domain[]> {
|
||||
try {
|
||||
const res = await arcadia.GET<{ domains?: Domain[]; data?: Domain[] }>(`${BASE}/domains`)
|
||||
return res.domains ?? res.data ?? []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function listDnsRecords(
|
||||
arcadia: ArcadiaClient,
|
||||
domainName: string,
|
||||
): Promise<DnsRecord[]> {
|
||||
const res = await arcadia.GET<{ domain_records?: DnsRecord[]; data?: DnsRecord[] }>(
|
||||
`${BASE}/domains/${encodeURIComponent(domainName)}/records`,
|
||||
)
|
||||
return res.domain_records ?? res.data ?? []
|
||||
}
|
||||
|
||||
export async function createDnsRecord(
|
||||
arcadia: ArcadiaClient,
|
||||
domainName: string,
|
||||
input: { type: string; name: string; data: string; ttl?: number; priority?: number },
|
||||
): Promise<unknown> {
|
||||
return arcadia.POST(`${BASE}/domains/${encodeURIComponent(domainName)}/records`, {
|
||||
body: input,
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteDnsRecord(
|
||||
arcadia: ArcadiaClient,
|
||||
domainName: string,
|
||||
recordId: string | number,
|
||||
): Promise<void> {
|
||||
await arcadia.DELETE(
|
||||
`${BASE}/domains/${encodeURIComponent(domainName)}/records/${recordId}`,
|
||||
)
|
||||
}
|
||||
|
||||
// --- Floating IPs -------------------------------------------------------
|
||||
|
||||
export interface FloatingIp {
|
||||
ip: string
|
||||
region?: { slug?: string; name?: string } | string
|
||||
droplet?: { id: number | string; name?: string } | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export async function listFloatingIps(arcadia: ArcadiaClient): Promise<FloatingIp[]> {
|
||||
try {
|
||||
const res = await arcadia.GET<{ floating_ips?: FloatingIp[]; data?: FloatingIp[] }>(
|
||||
`${BASE}/floating_ips`,
|
||||
)
|
||||
return res.floating_ips ?? res.data ?? []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function assignFloatingIp(
|
||||
arcadia: ArcadiaClient,
|
||||
ip: string,
|
||||
dropletId: number | string,
|
||||
): Promise<unknown> {
|
||||
return arcadia.POST(`${BASE}/floating_ips/${ip}/assign`, {
|
||||
body: { droplet_id: dropletId },
|
||||
})
|
||||
}
|
||||
|
||||
export async function unassignFloatingIp(
|
||||
arcadia: ArcadiaClient,
|
||||
ip: string,
|
||||
): Promise<unknown> {
|
||||
return arcadia.POST(`${BASE}/floating_ips/${ip}/unassign`)
|
||||
}
|
||||
99
app/lib/arcadia/sso.ts
Normal file
99
app/lib/arcadia/sso.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
// SSO / SAML helpers.
|
||||
// Backend: /api/v1/sso/identity-providers (tenant CRUD) + /sessions.
|
||||
// Note: certificates are large and write-only.
|
||||
|
||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
||||
|
||||
export interface IdentityProvider {
|
||||
id: string
|
||||
tenant_id: string
|
||||
name: string
|
||||
entity_id: string
|
||||
sso_url: string
|
||||
slo_url: string | null
|
||||
name_id_format: string | null
|
||||
attribute_mapping: Record<string, string>
|
||||
sp_entity_id: string | null
|
||||
sign_requests: boolean
|
||||
metadata_url: string | null
|
||||
callback_url: string | null
|
||||
enabled: boolean
|
||||
has_certificate: boolean
|
||||
inserted_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface IdentityProviderInput {
|
||||
name: string
|
||||
entity_id: string
|
||||
sso_url: string
|
||||
slo_url?: string | null
|
||||
name_id_format?: string | null
|
||||
attribute_mapping?: Record<string, string>
|
||||
sp_entity_id?: string | null
|
||||
sign_requests?: boolean
|
||||
metadata_url?: string | null
|
||||
callback_url?: string | null
|
||||
enabled?: boolean
|
||||
/** PEM cert from the IdP. Write-only. */
|
||||
certificate?: string
|
||||
}
|
||||
|
||||
export interface SamlSession {
|
||||
id: string
|
||||
user_id: string
|
||||
idp_id: string
|
||||
name_id: string | null
|
||||
session_index: string | null
|
||||
expires_at: string | null
|
||||
inserted_at: string
|
||||
}
|
||||
|
||||
const BASE = "/api/v1/sso"
|
||||
|
||||
export async function listIdentityProviders(arcadia: ArcadiaClient): Promise<IdentityProvider[]> {
|
||||
const res = await arcadia.GET<{ data: IdentityProvider[] }>(`${BASE}/identity-providers`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function createIdentityProvider(
|
||||
arcadia: ArcadiaClient,
|
||||
input: IdentityProviderInput,
|
||||
): Promise<IdentityProvider> {
|
||||
const res = await arcadia.POST<{ data: IdentityProvider }>(
|
||||
`${BASE}/identity-providers`,
|
||||
{ body: { identity_provider: input } },
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function updateIdentityProvider(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
input: Partial<IdentityProviderInput>,
|
||||
): Promise<IdentityProvider> {
|
||||
const res = await arcadia.PATCH<{ data: IdentityProvider }>(
|
||||
`${BASE}/identity-providers/${id}`,
|
||||
{ body: { identity_provider: input } },
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function deleteIdentityProvider(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
): Promise<void> {
|
||||
await arcadia.DELETE(`${BASE}/identity-providers/${id}`)
|
||||
}
|
||||
|
||||
export async function listSamlSessions(arcadia: ArcadiaClient): Promise<SamlSession[]> {
|
||||
const res = await arcadia.GET<{ data: SamlSession[] }>(`${BASE}/sessions`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function destroySamlSession(
|
||||
arcadia: ArcadiaClient,
|
||||
sessionId: string,
|
||||
): Promise<void> {
|
||||
await arcadia.DELETE(`${BASE}/sessions/${sessionId}`)
|
||||
}
|
||||
172
app/lib/arcadia/status-page.ts
Normal file
172
app/lib/arcadia/status-page.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
// Status page helpers — components, incidents, subscribers.
|
||||
// Backend: /api/v1/admin/status-page/* (admin CRUD).
|
||||
|
||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
||||
|
||||
export type ComponentStatus =
|
||||
| "operational"
|
||||
| "degraded_performance"
|
||||
| "partial_outage"
|
||||
| "major_outage"
|
||||
| "maintenance"
|
||||
| string
|
||||
|
||||
export interface StatusComponent {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
status: ComponentStatus
|
||||
display_order: number
|
||||
group_name: string | null
|
||||
inserted_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type IncidentStatus =
|
||||
| "investigating"
|
||||
| "identified"
|
||||
| "monitoring"
|
||||
| "resolved"
|
||||
| string
|
||||
|
||||
export type IncidentImpact = "none" | "minor" | "major" | "critical" | string
|
||||
|
||||
export interface IncidentUpdate {
|
||||
id: string
|
||||
status: IncidentStatus
|
||||
body: string
|
||||
inserted_at: string
|
||||
}
|
||||
|
||||
export interface Incident {
|
||||
id: string
|
||||
title: string
|
||||
status: IncidentStatus
|
||||
impact: IncidentImpact
|
||||
resolved_at: string | null
|
||||
metadata: Record<string, unknown>
|
||||
updates: IncidentUpdate[]
|
||||
components: StatusComponent[]
|
||||
inserted_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface Subscriber {
|
||||
id: string
|
||||
email: string
|
||||
confirmed_at: string | null
|
||||
inserted_at: string
|
||||
}
|
||||
|
||||
export interface ComponentInput {
|
||||
name: string
|
||||
description?: string
|
||||
status?: ComponentStatus
|
||||
display_order?: number
|
||||
group_name?: string | null
|
||||
}
|
||||
|
||||
export interface IncidentInput {
|
||||
title: string
|
||||
status?: IncidentStatus
|
||||
impact?: IncidentImpact
|
||||
/** IDs of affected components. */
|
||||
component_ids?: string[]
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface IncidentUpdateInput {
|
||||
status: IncidentStatus
|
||||
body: string
|
||||
}
|
||||
|
||||
const BASE = "/api/v1/admin/status-page"
|
||||
|
||||
// --- Components ---------------------------------------------------------
|
||||
|
||||
export async function listComponents(arcadia: ArcadiaClient): Promise<StatusComponent[]> {
|
||||
const res = await arcadia.GET<{ data: StatusComponent[] }>(`${BASE}/components`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function createComponent(
|
||||
arcadia: ArcadiaClient,
|
||||
input: ComponentInput,
|
||||
): Promise<StatusComponent> {
|
||||
const res = await arcadia.POST<{ data: StatusComponent }>(`${BASE}/components`, {
|
||||
body: { component: input },
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function updateComponent(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
input: Partial<ComponentInput>,
|
||||
): Promise<StatusComponent> {
|
||||
const res = await arcadia.PUT<{ data: StatusComponent }>(`${BASE}/components/${id}`, {
|
||||
body: { component: input },
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function deleteComponent(arcadia: ArcadiaClient, id: string): Promise<void> {
|
||||
await arcadia.DELETE(`${BASE}/components/${id}`)
|
||||
}
|
||||
|
||||
// --- Incidents ----------------------------------------------------------
|
||||
|
||||
export async function listIncidents(arcadia: ArcadiaClient): Promise<Incident[]> {
|
||||
const res = await arcadia.GET<{ data: Incident[] }>(`${BASE}/incidents`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function getIncident(arcadia: ArcadiaClient, id: string): Promise<Incident> {
|
||||
const res = await arcadia.GET<{ data: Incident }>(`${BASE}/incidents/${id}`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function createIncident(
|
||||
arcadia: ArcadiaClient,
|
||||
input: IncidentInput,
|
||||
): Promise<Incident> {
|
||||
const res = await arcadia.POST<{ data: Incident }>(`${BASE}/incidents`, {
|
||||
body: { incident: input },
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function updateIncident(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
input: Partial<IncidentInput>,
|
||||
): Promise<Incident> {
|
||||
const res = await arcadia.PUT<{ data: Incident }>(`${BASE}/incidents/${id}`, {
|
||||
body: { incident: input },
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function resolveIncident(arcadia: ArcadiaClient, id: string): Promise<Incident> {
|
||||
const res = await arcadia.POST<{ data: Incident }>(`${BASE}/incidents/${id}/resolve`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function addIncidentUpdate(
|
||||
arcadia: ArcadiaClient,
|
||||
incidentId: string,
|
||||
input: IncidentUpdateInput,
|
||||
): Promise<IncidentUpdate> {
|
||||
const res = await arcadia.POST<{ data: IncidentUpdate }>(
|
||||
`${BASE}/incidents/${incidentId}/updates`,
|
||||
{ body: { update: input } },
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
// --- Subscribers --------------------------------------------------------
|
||||
|
||||
export async function listSubscribers(arcadia: ArcadiaClient): Promise<Subscriber[]> {
|
||||
const res = await arcadia.GET<{ data: Subscriber[] }>(`${BASE}/subscribers`)
|
||||
return res.data
|
||||
}
|
||||
@@ -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
|
||||
|
||||
653
app/routes/announcements.tsx
Normal file
653
app/routes/announcements.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 }),
|
||||
|
||||
const resolveSecret = async (name: string): Promise<string> => {
|
||||
const res = await arcadia.GET<{ data: { value: string } }>(
|
||||
`/api/v1/secrets/${encodeURIComponent(name)}`,
|
||||
)
|
||||
return res.data.value
|
||||
}
|
||||
const arcadiaBaseURL =
|
||||
(import.meta.env.VITE_ARCADIA_URL as string | undefined) ?? "http://localhost:4000"
|
||||
const arcadiaTenantId =
|
||||
(import.meta.env.VITE_ARCADIA_TENANT as string | undefined) ?? "default"
|
||||
const arcadiaAuthToken =
|
||||
typeof window !== "undefined"
|
||||
? sessionStorage.getItem("arcadia_access_token") ?? undefined
|
||||
: undefined
|
||||
|
||||
;(async () => {
|
||||
try {
|
||||
const a = await buildAdapter({
|
||||
settings,
|
||||
resolveSecret,
|
||||
arcadiaBaseURL,
|
||||
arcadiaAuthToken,
|
||||
arcadiaTenantId,
|
||||
})
|
||||
setAdapter(a)
|
||||
} catch {
|
||||
setAdapter(mockAdapter)
|
||||
}
|
||||
|
||||
// Anthropic has no /v1/models endpoint — use the catalog defaults.
|
||||
if (provider.transport === "anthropic") {
|
||||
const ids = provider.defaultModels.length
|
||||
? provider.defaultModels
|
||||
: ["claude-opus-4-7"]
|
||||
setStatus({ kind: "live", models: ids })
|
||||
setModel((cur) => (cur && ids.includes(cur) ? cur : settings.model || ids[0]))
|
||||
return
|
||||
}
|
||||
|
||||
const baseURL = settings.baseURL || provider.baseURL
|
||||
let apiKey: string | undefined
|
||||
if (provider.requiresKey && settings.secretName) {
|
||||
try {
|
||||
apiKey = await resolveSecret(settings.secretName)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = await withTimeout(
|
||||
listModels({ baseURL, apiKey, signal: ac.signal }),
|
||||
PROBE_TIMEOUT_MS,
|
||||
ac.signal,
|
||||
)
|
||||
.then((rows) => {
|
||||
const ids = rows.map((m) => m.id)
|
||||
if (ids.length === 0) {
|
||||
setStatus({ kind: "mock", reason: "LM Studio returned no models" })
|
||||
setStatus({ kind: "mock", reason: "endpoint returned no models" })
|
||||
return
|
||||
}
|
||||
setStatus({ kind: "live", models: ids })
|
||||
setModel((cur) => (cur && ids.includes(cur) ? cur : ids[0]))
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
setModel((cur) => (cur && ids.includes(cur) ? cur : settings.model || ids[0]))
|
||||
} catch (err: unknown) {
|
||||
if ((err as DOMException)?.name === "AbortError") return
|
||||
if (provider.defaultModels.length) {
|
||||
setStatus({ kind: "live", models: provider.defaultModels })
|
||||
setModel((cur) =>
|
||||
cur && provider.defaultModels.includes(cur)
|
||||
? cur
|
||||
: settings.model || provider.defaultModels[0],
|
||||
)
|
||||
} else {
|
||||
setStatus({
|
||||
kind: "mock",
|
||||
reason: err instanceof Error ? err.message : "LM Studio unreachable",
|
||||
})
|
||||
reason: err instanceof Error ? err.message : "endpoint unreachable",
|
||||
})
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
return () => ac.abort()
|
||||
}, [settings.baseURL])
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
arcadia,
|
||||
settings.providerId,
|
||||
settings.baseURL,
|
||||
settings.secretName,
|
||||
settings.mode,
|
||||
settings.model,
|
||||
provider.transport,
|
||||
provider.baseURL,
|
||||
provider.requiresKey,
|
||||
])
|
||||
|
||||
useEffect(() => probe(), [probe])
|
||||
|
||||
@@ -315,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
1491
app/routes/buckets.tsx
Normal file
File diff suppressed because it is too large
Load Diff
668
app/routes/memberships.tsx
Normal file
668
app/routes/memberships.tsx
Normal 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
895
app/routes/monitoring.tsx
Normal 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 & 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
858
app/routes/networking.tsx
Normal 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
676
app/routes/sso.tsx
Normal 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
1048
app/routes/status-page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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/*"],
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user