Files
caravel/frontend/src/components/RelayDetailCard.tsx
T
2026-06-02 09:24:27 -07:00

250 lines
8.7 KiB
TypeScript

import { Show, createSignal } from "solid-js"
import type { Relay, PlanId } from "@/lib/api"
import ConfirmDialog from "@/components/ConfirmDialog"
import Field from "@/components/Field"
import PricingTable from "@/components/PricingTable"
import ToggleButton from "@/components/ToggleButton"
import ToggleField from "@/components/ToggleField"
import RelayCardHeader from "@/components/relay/RelayCardHeader"
import PlanGatedToggle from "@/components/relay/PlanGatedToggle"
import { setToastMessage } from "@/lib/state"
import { useProfileMetadata } from "@/lib/hooks"
import { flagToBool } from "@/lib/relayFlags"
import { plans } from "@/lib/state"
function DetailSection(props: { title: string; children: any }) {
return (
<div>
<h3 class="text-sm font-semibold uppercase tracking-wider mb-6">{props.title}</h3>
<dl class="grid lg:grid-cols-2 gap-x-32 gap-y-4">
{props.children}
</dl>
</div>
)
}
function MembershipSection(props: { title: string; children: any }) {
return (
<div>
<h3 class="text-sm font-semibold uppercase tracking-wider mb-6">{props.title}</h3>
<dl class="grid gap-x-6 gap-y-4 sm:grid-cols-2">
{props.children}
</dl>
</div>
)
}
type RelayDetailCardProps = {
relay: Relay
currentMembers?: number
showTenant?: boolean
editHref?: string
onDeactivate?: () => void | Promise<void>
onReactivate?: () => void | Promise<void>
deactivating?: boolean
reactivating?: boolean
onTogglePublicJoin?: () => void
onToggleStripSignatures?: () => void
onToggleGroups?: () => void
onToggleManagement?: () => void
onToggleMediaStorage?: () => void
onToggleLivekitSupport?: () => void
onTogglePushNotifications?: () => void
onUpdatePlan?: (planId: PlanId) => Promise<void>
enforcePlanLimits?: boolean
showPlanActions?: boolean
}
export default function RelayDetailCard(props: RelayDetailCardProps) {
const r = () => props.relay
const [planId, setPlanId] = createSignal<PlanId>(props.relay.plan_id)
const [pendingAction, setPendingAction] = createSignal<"deactivate" | "reactivate" | null>(null)
// Resolve the owning tenant's profile so the Tenant field can show a name and
// avatar instead of a raw pubkey. Only relevant in admin (showTenant) views.
// This subscription stays in the parent so the header doesn't double-subscribe.
const tenantProfile = useProfileMetadata(() => (props.showTenant ? props.relay.tenant_pubkey : undefined))
const memberLimitLabel = () => {
const p = plans().find(p => p.id === r().plan_id)
if (!p) return "?"
return p.members === null ? "∞" : String(p.members)
}
const planLimited = () => (props.enforcePlanLimits ?? true) && r().plan_id === "free"
const showPlanActions = () => props.showPlanActions ?? true
const actionBusy = () => pendingAction() === "deactivate" ? !!props.deactivating : pendingAction() === "reactivate" ? !!props.reactivating : false
const relayLabel = () => r().info_name || r().subdomain
const confirmTitle = () => pendingAction() === "deactivate" ? "Deactivate relay?" : "Reactivate relay?"
const confirmDescription = () => pendingAction() === "deactivate"
? `${relayLabel()} will be taken offline immediately.`
: `${relayLabel()} will come back online and start accepting connections.`
const confirmDetails = () => pendingAction() === "deactivate"
? [
"All client connections will be dropped immediately.",
"Members will be unable to read from or publish to the relay.",
"Scheduled and automated tasks (billing, syncing) will be paused.",
"All relay data, settings, and members are preserved, nothing is deleted.",
"You can reactivate at any time from this page.",
]
: undefined
const confirmLabel = () => pendingAction() === "deactivate" ? "Yes, deactivate" : "Yes, reactivate"
const confirmBusyLabel = () => pendingAction() === "deactivate" ? "Deactivating..." : "Reactivating..."
const confirmTone = () => pendingAction() === "deactivate" ? "danger" : "primary"
async function changePlanId(planId: PlanId) {
setPlanId(planId)
try {
await props.onUpdatePlan?.(planId)
setToastMessage(`Plan updated to ${planId}`, "success")
} catch {
// error is handled by the caller
}
}
function openActionDialog(action: "deactivate" | "reactivate") {
setPendingAction(action)
}
function closeActionDialog() {
if (actionBusy()) return
setPendingAction(null)
}
async function confirmAction() {
const action = pendingAction()
if (!action) return
if (action === "deactivate") {
await props.onDeactivate?.()
} else {
await props.onReactivate?.()
}
setPendingAction(null)
}
return (
<div class="space-y-6">
<RelayCardHeader
relay={r}
showTenant={props.showTenant}
tenantProfile={tenantProfile}
editHref={props.editHref}
deactivating={props.deactivating}
reactivating={props.reactivating}
onRequestDeactivate={props.onDeactivate ? () => openActionDialog("deactivate") : undefined}
onRequestReactivate={props.onReactivate ? () => openActionDialog("reactivate") : undefined}
/>
<hr class="border-gray-200" />
<DetailSection title="Policy">
<ToggleField label="Public join">
<ToggleButton
enabled={flagToBool(r().policy_public_join, false)}
onToggle={props.onTogglePublicJoin}
/>
</ToggleField>
<ToggleField label="Strip signatures">
<ToggleButton
enabled={flagToBool(r().policy_strip_signatures, false)}
onToggle={props.onToggleStripSignatures}
/>
</ToggleField>
</DetailSection>
<hr class="border-gray-200" />
<DetailSection title="Features">
<ToggleField label="Rooms">
<ToggleButton
enabled={flagToBool(r().groups_enabled, true)}
onToggle={props.onToggleGroups}
/>
</ToggleField>
<ToggleField label="Management API">
<ToggleButton
enabled={flagToBool(r().management_enabled, true)}
onToggle={props.onToggleManagement}
/>
</ToggleField>
<ToggleField label="Push notifications">
<ToggleButton
enabled={flagToBool(r().push_enabled, true)}
onToggle={props.onTogglePushNotifications}
/>
</ToggleField>
<ToggleField label="Media storage">
<PlanGatedToggle
enabled={flagToBool(r().blossom_enabled, true)}
fallbackEnabled={flagToBool(r().blossom_enabled, false)}
planLimited={planLimited()}
showPlanActions={showPlanActions()}
canUpdatePlan={!!props.onUpdatePlan}
editHref={props.editHref}
onToggle={props.onToggleMediaStorage}
/>
</ToggleField>
<ToggleField label="LiveKit support">
<PlanGatedToggle
enabled={flagToBool(r().livekit_enabled, true)}
fallbackEnabled={flagToBool(r().livekit_enabled, false)}
planLimited={planLimited()}
showPlanActions={showPlanActions()}
canUpdatePlan={!!props.onUpdatePlan}
editHref={props.editHref}
onToggle={props.onToggleLivekitSupport}
/>
</ToggleField>
</DetailSection>
<hr class="border-gray-200" />
<MembershipSection title="Membership">
<Field label="Current members">
<span class="font-medium text-gray-900">{props.currentMembers ?? "—"}</span>
</Field>
<Field label="Member limit">
<span class="text-gray-900">{memberLimitLabel()}</span>
</Field>
</MembershipSection>
<Show when={showPlanActions()}>
<hr class="border-gray-200" />
<DetailSection title="Plan">
<Show
when={props.onUpdatePlan}
fallback={
<Field label="Current plan">
<span class="capitalize text-gray-900">{r().plan_id}</span>
</Field>
}
>
<div class="lg:col-span-2 space-y-4">
<PricingTable
selectable
selectedPlanId={planId()}
onSelect={changePlanId}
/>
</div>
</Show>
</DetailSection>
</Show>
<ConfirmDialog
open={pendingAction() !== null}
title={confirmTitle()}
description={confirmDescription()}
details={confirmDetails()}
confirmLabel={confirmLabel()}
busyLabel={confirmBusyLabel()}
busy={actionBusy()}
tone={confirmTone()}
onConfirm={confirmAction}
onClose={closeActionDialog}
/>
</div>
)
}