Add relay activity

This commit is contained in:
Jon Staab
2026-03-27 15:24:08 -07:00
parent 6510bc0d85
commit 77365f74ee
10 changed files with 152 additions and 6 deletions
+8
View File
@@ -32,11 +32,13 @@ Notes:
## `async fn list_plans(...) -> Response`
- Serves `GET /plans`
- No authentication required
- Return `data` is a list of plan structs from `Repo::list_plans`
## `async fn get_plan(...) -> Response`
- Serves `GET /plans/:id`
- No authentication required
- Return `data` is a single plan struct matching `id`
- If plan does not exist, return `404` with `code=not-found`
@@ -114,6 +116,12 @@ Notes:
- If relay is a duplicate by subdomain, return a `422` with `code=subdomain-exists`
- Return `data` is a single relay struct.
## `async fn list_relay_activity(...) -> Response`
- Serves `GET /relays/:id/activity`
- Authorizes admin or relay owner
- Return `data` is a list of activity structs from `repo.list_activity_for_relay`
## `async fn deactivate_relay(...) -> Response`
- Serves `POST /relays/:id/deactivate`
+5
View File
@@ -138,6 +138,11 @@ Notes:
- Returns all activity occuring after `since` matching `tenant`
## `pub fn list_activity_for_relay(&self, relay_id: &str) -> Result<Vec<Activity>>`
- Returns all activity where `resource_type = 'relay'` and `resource_id = relay_id`
- Ordered newest-first
## `pub fn get_relay_plan_sats(&self, plan: &str) -> Result<i64>`
- Returns the monthly sats amount for a given plan id
+22
View File
@@ -110,6 +110,7 @@ impl Api {
.route("/tenants/:pubkey/billing", put(update_tenant_billing))
.route("/relays", get(list_relays).post(create_relay))
.route("/relays/:id", get(get_relay).put(update_relay))
.route("/relays/:id/activity", get(list_relay_activity))
.route("/relays/:id/deactivate", post(deactivate_relay))
.route("/invoices", get(list_invoices))
.route("/invoices/:id", get(get_invoice))
@@ -472,6 +473,27 @@ async fn get_relay(
Ok(ok(StatusCode::OK, relay))
}
async fn list_relay_activity(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
) -> std::result::Result<Response, ApiError> {
let auth = state.api.extract_auth_pubkey(&headers)?;
let relay = match state.api.repo.get_relay(&id).await {
Ok(Some(r)) => r,
Ok(None) => return Ok(err(StatusCode::NOT_FOUND, "not-found", "relay not found")),
Err(e) => return Ok(err(StatusCode::INTERNAL_SERVER_ERROR, "internal", &e.to_string())),
};
state.api.require_admin_or_tenant(&auth, &relay.tenant)?;
match state.api.repo.list_activity_for_relay(&id).await {
Ok(activity) => Ok(ok(StatusCode::OK, activity)),
Err(e) => Ok(err(StatusCode::INTERNAL_SERVER_ERROR, "internal", &e.to_string())),
}
}
async fn create_relay(
State(state): State<AppState>,
headers: HeaderMap,
+13
View File
@@ -499,6 +499,19 @@ impl Repo {
Ok(rows)
}
pub async fn list_activity_for_relay(&self, relay_id: &str) -> Result<Vec<Activity>> {
let rows = sqlx::query_as::<_, Activity>(
"SELECT id, tenant, created_at, activity_type, resource_type, resource_id
FROM activity
WHERE resource_type = 'relay' AND resource_id = ?
ORDER BY created_at DESC, id DESC",
)
.bind(relay_id)
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
pub async fn get_invoice_items(&self, invoice_id: &str) -> Result<Vec<InvoiceItem>> {
let rows = sqlx::query_as::<_, InvoiceItem>(
"SELECT id, invoice, relay, sats
+53
View File
@@ -0,0 +1,53 @@
import { For, Show } from "solid-js"
import type { Activity } from "@/lib/hooks"
const ACTIVITY_LABELS: Record<string, string> = {
create_relay: "Relay created",
update_relay: "Relay updated",
deactivate_relay: "Relay deactivated",
fail_relay_sync: "Relay sync failed",
create_tenant: "Account created",
update_tenant_billing_anchor: "Billing anchor updated",
update_tenant_nwc_url: "Wallet connection updated",
create_invoice: "Invoice created",
mark_invoice_paid: "Invoice paid",
mark_invoice_attempted: "Invoice payment attempted",
mark_invoice_sent: "Invoice sent",
mark_invoice_closed: "Invoice closed",
}
function formatDate(ts: number) {
return new Date(ts * 1000).toLocaleString(undefined, {
month: "short", day: "numeric", year: "numeric",
hour: "2-digit", minute: "2-digit",
})
}
export default function ActivityFeed(props: { activity: Activity[]; loading: boolean }) {
return (
<section class="bg-white border border-gray-200 rounded-xl p-6">
<h3 class="text-sm font-semibold uppercase tracking-wider mb-6">Activity</h3>
<Show when={props.loading}>
<p class="text-sm text-gray-400">Loading activity...</p>
</Show>
<Show when={!props.loading && props.activity.length === 0}>
<p class="text-sm text-gray-400">No activity yet.</p>
</Show>
<Show when={!props.loading && props.activity.length > 0}>
<ol class="relative border-l border-gray-200 space-y-6 ml-3">
<For each={props.activity}>
{(item) => (
<li class="pl-6">
<span class="absolute -left-1.5 mt-1.5 h-3 w-3 rounded-full border-2 border-white bg-gray-300" />
<p class="text-sm font-medium text-gray-900">
{ACTIVITY_LABELS[item.activity_type] ?? item.activity_type.replace(/_/g, " ")}
</p>
<time class="text-xs text-gray-400">{formatDate(item.created_at)}</time>
</li>
)}
</For>
</ol>
</Show>
</section>
)
}
+23 -1
View File
@@ -9,6 +9,25 @@ import ToggleField from "@/components/ToggleField"
import { setToastMessage } from "@/components/Toast"
import { plans } from "@/lib/state"
const STATUS_STYLES: Record<string, string> = {
active: "bg-green-50 text-green-700 border-green-200",
new: "bg-blue-50 text-blue-700 border-blue-200",
pending: "bg-yellow-50 text-yellow-700 border-yellow-200",
provisioning_failed: "bg-red-50 text-red-700 border-red-200",
deactivated: "bg-gray-100 text-gray-500 border-gray-200",
suspended: "bg-orange-50 text-orange-700 border-orange-200",
}
function StatusBadge(props: { status: string }) {
const styles = () => STATUS_STYLES[props.status] ?? "bg-gray-100 text-gray-500 border-gray-200"
const label = () => props.status.replace(/_/g, " ")
return (
<span class={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium capitalize ${styles()}`}>
{label()}
</span>
)
}
function DetailSection(props: { title: string; children: any }) {
return (
<div>
@@ -114,7 +133,10 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
<img src={r().info_icon} alt="" class="w-14 h-14 rounded-xl object-cover flex-shrink-0 border border-gray-200" />
</Show>
<div class="min-w-0">
<h1 class="text-2xl font-bold text-gray-900">{r().info_name || r().subdomain}</h1>
<div class="flex items-center gap-3 flex-wrap">
<h1 class="text-2xl font-bold text-gray-900">{r().info_name || r().subdomain}</h1>
<StatusBadge status={r().status} />
</div>
<a
href={`wss://${r().subdomain}.spaces.coracle.social`}
class="text-sm text-blue-600 hover:underline break-all"
+13
View File
@@ -115,6 +115,15 @@ export type Invoice = {
period_end: number
}
export type Activity = {
id: string
tenant: string
created_at: number
activity_type: string
resource_type: string
resource_id: string
}
export type Identity = {
pubkey: string
is_admin: boolean
@@ -229,6 +238,10 @@ export function getRelay(id: string) {
return callApi<undefined, Relay>("GET", `/relays/${id}`)
}
export function listRelayActivity(id: string) {
return callApi<undefined, Activity[]>("GET", `/relays/${id}/activity`)
}
export function createRelay(input: CreateRelayInput) {
return callApi<CreateRelayInput, Relay>("POST", "/relays", input)
}
+5 -1
View File
@@ -9,12 +9,14 @@ import {
deactivateRelay,
getRelay,
getTenant,
listRelayActivity,
listRelays,
listTenantInvoices,
listTenantRelays,
listTenants,
updateRelay,
updateTenantBilling,
type Activity,
type CreateRelayInput,
type Relay,
type Tenant,
@@ -88,6 +90,8 @@ export const useTenantInvoices = () => createResource(() => listTenantInvoices(a
export const useRelay = (relayId: () => string) => createResource(relayId, getRelay)
export const useRelayActivity = (relayId: () => string) => createResource(relayId, listRelayActivity)
export const useAdminTenants = () => createResource(listTenants)
export const useAdminRelays = () => createResource(listRelays)
@@ -135,4 +139,4 @@ export async function getRelayMembers(url: string) {
}
}
export type { Relay, Tenant }
export type { Activity, Relay, Tenant }
@@ -5,7 +5,8 @@ import PageContainer from "@/components/PageContainer"
import RelayDetailCard from "@/components/RelayDetailCard"
import ResourceState from "@/components/ResourceState"
import useMinLoading from "@/components/useMinLoading"
import { useRelay } from "@/lib/hooks"
import ActivityFeed from "@/components/ActivityFeed"
import { useRelay, useRelayActivity } from "@/lib/hooks"
import useRelayToggles from "@/lib/useRelayToggles"
export default function AdminRelayDetail() {
@@ -13,6 +14,7 @@ export default function AdminRelayDetail() {
const relayId = () => params.id ?? ""
const [relay, { refetch, mutate }] = useRelay(relayId)
const loading = useMinLoading(() => relay.loading && !relay())
const [activity] = useRelayActivity(relayId)
const { busy, handleDeactivate, toggles } = useRelayToggles(relayId, relay, { refetch, mutate })
return (
@@ -21,7 +23,7 @@ export default function AdminRelayDetail() {
<ResourceState loading={loading()} error={relay.error} loadingText="Loading relay..." errorText="Failed to load relay." class="mb-4" />
<Show when={!loading() && relay()}>
{(r) => (
<div class="mb-6">
<div class="space-y-6 mb-6">
<RelayDetailCard
relay={r()}
showTenant
@@ -32,6 +34,7 @@ export default function AdminRelayDetail() {
showPlanActions={false}
{...toggles}
/>
<ActivityFeed activity={activity() ?? []} loading={activity.loading} />
</div>
)}
</Show>
+5 -2
View File
@@ -5,7 +5,8 @@ import PageContainer from "@/components/PageContainer"
import RelayDetailCard from "@/components/RelayDetailCard"
import ResourceState from "@/components/ResourceState"
import useMinLoading from "@/components/useMinLoading"
import { getRelayMembers, useRelay } from "@/lib/hooks"
import ActivityFeed from "@/components/ActivityFeed"
import { getRelayMembers, useRelay, useRelayActivity } from "@/lib/hooks"
import useRelayToggles from "@/lib/useRelayToggles"
export default function RelayDetail() {
@@ -18,6 +19,7 @@ export default function RelayDetail() {
})
const [members] = createResource(relayUrl, getRelayMembers)
const loading = useMinLoading(() => relay.loading && !relay())
const [activity] = useRelayActivity(relayId)
const { busy, handleDeactivate, handleUpdatePlan, toggles } = useRelayToggles(relayId, relay, { refetch, mutate })
return (
@@ -26,7 +28,7 @@ export default function RelayDetail() {
<ResourceState loading={loading()} error={relay.error} loadingText="Loading relay..." errorText="Failed to load relay." class="mb-4" />
<Show when={!loading() && relay()}>
{(r) => (
<div class="mb-6">
<div class="space-y-6 mb-6">
<RelayDetailCard
relay={r()}
currentMembers={members.length}
@@ -36,6 +38,7 @@ export default function RelayDetail() {
onUpdatePlan={handleUpdatePlan}
{...toggles}
/>
<ActivityFeed activity={activity() ?? []} loading={activity.loading} />
</div>
)}
</Show>