From 77365f74ee11856f73c2258225d4b48530d6dd48 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Fri, 27 Mar 2026 15:24:08 -0700 Subject: [PATCH] Add relay activity --- backend/spec/api.md | 8 +++ backend/spec/repo.md | 5 ++ backend/src/api.rs | 22 ++++++++ backend/src/repo.rs | 13 +++++ frontend/src/components/ActivityFeed.tsx | 53 +++++++++++++++++++ frontend/src/components/RelayDetailCard.tsx | 24 ++++++++- frontend/src/lib/api.ts | 13 +++++ frontend/src/lib/hooks.ts | 6 ++- frontend/src/pages/admin/AdminRelayDetail.tsx | 7 ++- frontend/src/pages/relays/RelayDetail.tsx | 7 ++- 10 files changed, 152 insertions(+), 6 deletions(-) create mode 100644 frontend/src/components/ActivityFeed.tsx diff --git a/backend/spec/api.md b/backend/spec/api.md index 6a796c2..89e6c85 100644 --- a/backend/spec/api.md +++ b/backend/spec/api.md @@ -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` diff --git a/backend/spec/repo.md b/backend/spec/repo.md index 998b56e..47b6412 100644 --- a/backend/spec/repo.md +++ b/backend/spec/repo.md @@ -138,6 +138,11 @@ Notes: - Returns all activity occuring after `since` matching `tenant` +## `pub fn list_activity_for_relay(&self, relay_id: &str) -> Result>` + +- 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` - Returns the monthly sats amount for a given plan id diff --git a/backend/src/api.rs b/backend/src/api.rs index 528e823..fe98ab8 100644 --- a/backend/src/api.rs +++ b/backend/src/api.rs @@ -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, + headers: HeaderMap, + Path(id): Path, +) -> std::result::Result { + 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, headers: HeaderMap, diff --git a/backend/src/repo.rs b/backend/src/repo.rs index e9b077d..6c22cd9 100644 --- a/backend/src/repo.rs +++ b/backend/src/repo.rs @@ -499,6 +499,19 @@ impl Repo { Ok(rows) } + pub async fn list_activity_for_relay(&self, relay_id: &str) -> Result> { + 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> { let rows = sqlx::query_as::<_, InvoiceItem>( "SELECT id, invoice, relay, sats diff --git a/frontend/src/components/ActivityFeed.tsx b/frontend/src/components/ActivityFeed.tsx new file mode 100644 index 0000000..e7485bc --- /dev/null +++ b/frontend/src/components/ActivityFeed.tsx @@ -0,0 +1,53 @@ +import { For, Show } from "solid-js" +import type { Activity } from "@/lib/hooks" + +const ACTIVITY_LABELS: Record = { + 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 ( +
+

Activity

+ +

Loading activity...

+
+ +

No activity yet.

+
+ 0}> +
    + + {(item) => ( +
  1. + +

    + {ACTIVITY_LABELS[item.activity_type] ?? item.activity_type.replace(/_/g, " ")} +

    + +
  2. + )} +
    +
+
+
+ ) +} diff --git a/frontend/src/components/RelayDetailCard.tsx b/frontend/src/components/RelayDetailCard.tsx index 9808b48..30d05d2 100644 --- a/frontend/src/components/RelayDetailCard.tsx +++ b/frontend/src/components/RelayDetailCard.tsx @@ -9,6 +9,25 @@ import ToggleField from "@/components/ToggleField" import { setToastMessage } from "@/components/Toast" import { plans } from "@/lib/state" +const STATUS_STYLES: Record = { + 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 ( + + {label()} + + ) +} + function DetailSection(props: { title: string; children: any }) { return (
@@ -114,7 +133,10 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
-

{r().info_name || r().subdomain}

+
+

{r().info_name || r().subdomain}

+ +
("GET", `/relays/${id}`) } +export function listRelayActivity(id: string) { + return callApi("GET", `/relays/${id}/activity`) +} + export function createRelay(input: CreateRelayInput) { return callApi("POST", "/relays", input) } diff --git a/frontend/src/lib/hooks.ts b/frontend/src/lib/hooks.ts index 6acf22c..3665c03 100644 --- a/frontend/src/lib/hooks.ts +++ b/frontend/src/lib/hooks.ts @@ -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 } diff --git a/frontend/src/pages/admin/AdminRelayDetail.tsx b/frontend/src/pages/admin/AdminRelayDetail.tsx index 1bab2ef..5106d7a 100644 --- a/frontend/src/pages/admin/AdminRelayDetail.tsx +++ b/frontend/src/pages/admin/AdminRelayDetail.tsx @@ -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() { {(r) => ( -
+
+
)} diff --git a/frontend/src/pages/relays/RelayDetail.tsx b/frontend/src/pages/relays/RelayDetail.tsx index 391ef0e..a34d487 100644 --- a/frontend/src/pages/relays/RelayDetail.tsx +++ b/frontend/src/pages/relays/RelayDetail.tsx @@ -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() { {(r) => ( -
+
+
)}