From c4f99bb97d9b715e9a10c4f621abad606f3e75ca Mon Sep 17 00:00:00 2001 From: miguelc Date: Tue, 14 Jul 2026 14:23:22 -0400 Subject: [PATCH 1/4] feat: UI revamp - sticky headers, tabs, failure reasons, notif filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lock table headers while scrolling in both apps: DataTable opts in by default, and the hand-rolled tables (activity, chain overview, provider breakdown, workflows) get bounded scroll boxes. #317 Consolidate multi-table screens into tabs: middleman Suppliers (Suppliers/Activity/Overview) and provider Keys (Keys/Activity), with the active tab persisted in the URL. #317 Surface the failure reason on failed transactions in both apps’ tables, reading the log/message columns through a shared failureReasonDisplay helper. #317 Add server-side filtering to notification history (event type, read/unread, channel) in both apps; the unread badge count stays unfiltered. #317 Make the left sidebar collapsible to an icon rail with a header toggle, hidden on the landing/auth pages. #317 Add unit tests for the notification filter conditions (both apps) and the shared failure-reason helper. #317 --- .../src/actions/NotificationChannels.ts | 8 +- .../src/app/admin/setup/providersForm.tsx | 6 +- .../(lists)/suppliers/ActivitiesSection.tsx | 59 ++++--- .../app/(lists)/suppliers/ChainOverview.tsx | 2 - .../app/(lists)/suppliers/SuppliersTabs.tsx | 52 ++++++ .../src/app/app/(lists)/suppliers/page.tsx | 10 +- .../(lists)/transactions/table/columns.tsx | 20 +++ .../app/(lists)/transactions/table/index.tsx | 1 + .../NotificationHistorySection.tsx | 17 +- .../app/app/overview/ProviderBreakdown.tsx | 8 +- apps/middleman/src/app/components/Sidebar.tsx | 9 +- .../src/app/components/SidebarTriggerGate.tsx | 16 ++ apps/middleman/src/app/layout.tsx | 3 +- .../dal/notificationChannels.filters.test.ts | 50 ++++++ .../src/lib/dal/notificationChannels.ts | 56 ++++++- .../src/actions/NotificationChannels.ts | 9 +- .../(internal)/keys/ActivitiesSection.tsx | 39 ++--- .../app/admin/(internal)/keys/KeysTabs.tsx | 47 ++++++ .../src/app/admin/(internal)/keys/page.tsx | 6 +- .../src/app/admin/(internal)/layout.tsx | 6 +- .../NotificationEventsSection.tsx | 18 +- .../(internal)/transactions/table/columns.tsx | 17 ++ apps/provider/src/components/Sidebar.tsx | 2 +- .../dal/notificationChannels.filters.test.ts | 49 ++++++ .../src/lib/dal/notificationChannels.ts | 46 +++++- packages/commons/src/utils.test.ts | 22 ++- packages/commons/src/utils.ts | 14 ++ packages/ui/src/components/AppSidebar.tsx | 3 + .../ui/src/components/AppTopBar/index.tsx | 6 +- .../ui/src/components/DataTable/index.tsx | 11 +- .../NotificationHistory.tsx | 154 ++++++++++++++---- .../src/components/workflows/SchedulesTab.tsx | 4 +- .../workflows/WorkflowDetailClient.tsx | 8 +- 33 files changed, 650 insertions(+), 128 deletions(-) create mode 100644 apps/middleman/src/app/app/(lists)/suppliers/SuppliersTabs.tsx create mode 100644 apps/middleman/src/app/components/SidebarTriggerGate.tsx create mode 100644 apps/middleman/src/lib/dal/notificationChannels.filters.test.ts create mode 100644 apps/provider/src/app/admin/(internal)/keys/KeysTabs.tsx create mode 100644 apps/provider/src/lib/dal/notificationChannels.filters.test.ts diff --git a/apps/middleman/src/actions/NotificationChannels.ts b/apps/middleman/src/actions/NotificationChannels.ts index d1d48440..9164a624 100644 --- a/apps/middleman/src/actions/NotificationChannels.ts +++ b/apps/middleman/src/actions/NotificationChannels.ts @@ -176,8 +176,12 @@ export async function TestNotificationChannel(id: number) { }) } -export async function ListNotificationEvents(page = 0, pageSize = 25) { - return run(async () => dal.listNotificationEvents(await requireAuth(), page, pageSize)) +export async function ListNotificationEvents( + page = 0, + pageSize = 25, + filters?: dal.NotificationEventFilters, +) { + return run(async () => dal.listNotificationEvents(await requireAuth(), page, pageSize, filters)) } export async function GetNotificationEvent(uuid: string) { diff --git a/apps/middleman/src/app/admin/setup/providersForm.tsx b/apps/middleman/src/app/admin/setup/providersForm.tsx index dd6901cb..63640066 100644 --- a/apps/middleman/src/app/admin/setup/providersForm.tsx +++ b/apps/middleman/src/app/admin/setup/providersForm.tsx @@ -94,10 +94,10 @@ const ProvidersForm: React.FC = ({ name="providers" render={() => ( -
+
- - + + diff --git a/apps/middleman/src/app/app/(lists)/suppliers/ActivitiesSection.tsx b/apps/middleman/src/app/app/(lists)/suppliers/ActivitiesSection.tsx index b2697df7..02020d67 100644 --- a/apps/middleman/src/app/app/(lists)/suppliers/ActivitiesSection.tsx +++ b/apps/middleman/src/app/app/(lists)/suppliers/ActivitiesSection.tsx @@ -1,9 +1,10 @@ 'use client' -import React, { useMemo } from 'react' +import React, { useMemo, useState } from 'react' import { useQuery } from '@tanstack/react-query' import { clsx } from 'clsx' import { GetPendingState } from '@/actions/Pending' +import { Input } from '@igniter/ui/components/input' import Address from '@igniter/ui/components/Address' import TransactionHash from '@igniter/ui/components/TransactionHash' import Amount from '@igniter/ui/components/Amount' @@ -23,11 +24,6 @@ function hasPendingOrLinger(state: PendingStateSerialized | undefined): boolean return (state.pendingOperations?.length ?? 0) > 0 } -function pendingCount(state: PendingStateSerialized | undefined): number { - if (!state) return 0 - return Object.keys(state.byOperator).length -} - // Status-aware label and color per design spec: // pending+stake → "Staking…" yellow, pending+unstake → "Unstaking…" yellow // success+stake → "Staked" green, success+unstake → "Unstaked" green @@ -63,33 +59,42 @@ export default function ActivitiesSection() { refetchInterval: (q) => (hasPendingOrLinger(q.state.data) ? 7000 : false), }) + const [search, setSearch] = useState('') + const rows = useMemo(() => { return pendingState?.pendingOperations ?? [] }, [pendingState]) - const count = pendingCount(pendingState) - - // Render nothing when no pending and no recently-settled rows. - if (rows.length === 0) return null + // Client-side filter across supplier/owner addresses and provider name — + // mirrors the Suppliers tab search, scoped to the fields this strip shows. + const filteredRows = useMemo(() => { + const q = search.trim().toLowerCase() + if (!q) return rows + return rows.filter((row) => + [row.operatorAddress, row.ownerAddress, row.providerName] + .some((field) => field?.toLowerCase().includes(q)), + ) + }, [rows, search]) return (
- {/* Section heading — matches RecentChanges / Services Overview style. - Badge shows PENDING count only; settled-linger rows don't inflate it. */} -

- In progress - {count > 0 && ( - · {count} - )} -

- {/* Hand-built using the shared Table primitives (the same ones DataTable renders internally) + DataTable's exact header-cell classes. DataTable's own toolbar + pagination chrome would still render with no props, so we reuse the Table primitives directly for a compact, chrome-free strip that is visually identical to our tables. ~5 rows then internal scroll. */} -
Name Identity
- + {rows.length > 0 && ( + setSearch(e.target.value)} + className="max-w-xs" + /> + )} +
+ {/* Lock header to the top of the scroll box; opaque root bg so rows + don't bleed through, plus a bottom divider that stays put. */} + {/* Column order: Tx Hash · Submitted · Supplier · Owner · Provider · Amount · Op Funds · Status */} Tx Hash @@ -103,7 +108,17 @@ export default function ActivitiesSection() { - {rows.map((row) => { + {filteredRows.length === 0 && ( + + + {search.trim() ? 'No matches.' : 'No activity yet.'} + + + )} + {filteredRows.map((row) => { const statusLabel = getStatusLabel(row) const statusClass = getStatusClass(row) const submittedStr = row.createdAt diff --git a/apps/middleman/src/app/app/(lists)/suppliers/ChainOverview.tsx b/apps/middleman/src/app/app/(lists)/suppliers/ChainOverview.tsx index 6f31817d..082477ac 100644 --- a/apps/middleman/src/app/app/(lists)/suppliers/ChainOverview.tsx +++ b/apps/middleman/src/app/app/(lists)/suppliers/ChainOverview.tsx @@ -151,8 +151,6 @@ export default function ChainOverview() { const handleExport = useCallback(() => exportChainOverviewCsv(sortedRows), [sortedRows]) - if (!isLoading && !isError && !allRows.length) return null - const cardClasses = 'rounded-lg border border-[color:--divider] bg-[color:--main-background] base-shadow p-4' if (isLoading) { diff --git a/apps/middleman/src/app/app/(lists)/suppliers/SuppliersTabs.tsx b/apps/middleman/src/app/app/(lists)/suppliers/SuppliersTabs.tsx new file mode 100644 index 00000000..57e2de6a --- /dev/null +++ b/apps/middleman/src/app/app/(lists)/suppliers/SuppliersTabs.tsx @@ -0,0 +1,52 @@ +'use client' + +import * as React from 'react' +import { usePathname, useRouter, useSearchParams } from 'next/navigation' + +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@igniter/ui/components/tabs' + +import NodesTable from '@/app/app/(lists)/suppliers/table' +import ActivitiesSection from '@/app/app/(lists)/suppliers/ActivitiesSection' +import ChainOverview from '@/app/app/(lists)/suppliers/ChainOverview' + +const TABS = ['suppliers', 'activity', 'overview'] as const +type TabValue = (typeof TABS)[number] + +// The three data tables that used to stack on the Suppliers screen now live one +// per tab. Selection is persisted in the URL (?tab=) so refreshes / deep-links +// land on the same table — same pattern as WorkflowsTabs. +export default function SuppliersTabs() { + const router = useRouter() + const pathname = usePathname() + const searchParams = useSearchParams() + + const param = searchParams.get('tab') + const tab: TabValue = (TABS as readonly string[]).includes(param ?? '') + ? (param as TabValue) + : 'suppliers' + + const onTabChange = (next: string) => { + const params = new URLSearchParams(searchParams.toString()) + params.set('tab', next) + router.replace(`${pathname}?${params.toString()}`, { scroll: false }) + } + + return ( + + + Suppliers + Activity + Overview + + + + + + + + + + + + ) +} \ No newline at end of file diff --git a/apps/middleman/src/app/app/(lists)/suppliers/page.tsx b/apps/middleman/src/app/app/(lists)/suppliers/page.tsx index 59d4b66b..03ec142c 100644 --- a/apps/middleman/src/app/app/(lists)/suppliers/page.tsx +++ b/apps/middleman/src/app/app/(lists)/suppliers/page.tsx @@ -1,10 +1,8 @@ import type { Metadata } from 'next' import React, { Suspense } from 'react' -import NodesTable from '@/app/app/(lists)/suppliers/table' import ProviderStats from '@/app/app/(lists)/suppliers/ProviderStats' -import ChainOverview from '@/app/app/(lists)/suppliers/ChainOverview' import RecentChanges from '@/app/app/(lists)/suppliers/RecentChanges' -import ActivitiesSection from '@/app/app/(lists)/suppliers/ActivitiesSection' +import SuppliersTabs from '@/app/app/(lists)/suppliers/SuppliersTabs' import { GetAppName } from '@/actions/ApplicationSettings' import Link from 'next/link' import { Button } from '@igniter/ui/components/button' @@ -42,12 +40,12 @@ export default async function Page() { /> - - - + + + ); diff --git a/apps/middleman/src/app/app/(lists)/transactions/table/columns.tsx b/apps/middleman/src/app/app/(lists)/transactions/table/columns.tsx index c13a3a32..bf63068c 100644 --- a/apps/middleman/src/app/app/(lists)/transactions/table/columns.tsx +++ b/apps/middleman/src/app/app/(lists)/transactions/table/columns.tsx @@ -7,6 +7,7 @@ import {ActivitySuccessIcon, ActivityWarningIcon, RightArrowIcon} from '@igniter import { Button } from '@igniter/ui/components/button' import { FilterGroup, SortOption } from '@igniter/ui/components/DataTable/index' import { amountToPokt } from '@igniter/ui/lib/utils' +import { failureReasonDisplay } from '@igniter/commons/utils' import { useAddItemToDetail } from '@igniter/ui/components/QuickDetails/Provider' import Amount from '@igniter/ui/components/Amount' import TransactionHash from '@igniter/ui/components/TransactionHash' @@ -27,6 +28,7 @@ export type Transaction = { provider: string, providerFee?: number | null, typeProviderFee?: ProviderFee | null, + log?: string | null, }; export const columns: (ColumnDef & CsvColumnDef)[] = [ @@ -67,6 +69,24 @@ export const columns: (ColumnDef & CsvColumnDef)[] = [ }, csvFormatterFn: ({status}) => status.charAt(0).toUpperCase() + status.slice(1), }, + { + id: "failureReason", + header: "Failure Reason", + cell: ({ row }) => { + const { status, log } = row.original; + const text = failureReasonDisplay(status === TransactionStatus.Failure, log); + if (text === null) { + return -; + } + return ( + + {text} + + ); + }, + csvFormatterFn: (item) => + failureReasonDisplay(item.status === TransactionStatus.Failure, item.log) ?? '', + }, { accessorKey: "hash", header: "Tx Hash", diff --git a/apps/middleman/src/app/app/(lists)/transactions/table/index.tsx b/apps/middleman/src/app/app/(lists)/transactions/table/index.tsx index 2d6f9645..67cd8c67 100644 --- a/apps/middleman/src/app/app/(lists)/transactions/table/index.tsx +++ b/apps/middleman/src/app/app/(lists)/transactions/table/index.tsx @@ -115,6 +115,7 @@ export default function TransactionsTable() { provider: tx.provider?.name || 'Height Pending', providerFee: tx.providerFee, typeProviderFee: tx.typeProviderFee, + log: tx.log, } }) || [] } diff --git a/apps/middleman/src/app/app/notifications/NotificationHistorySection.tsx b/apps/middleman/src/app/app/notifications/NotificationHistorySection.tsx index d0149124..e7483b91 100644 --- a/apps/middleman/src/app/app/notifications/NotificationHistorySection.tsx +++ b/apps/middleman/src/app/app/notifications/NotificationHistorySection.tsx @@ -10,6 +10,17 @@ import { MarkAllNotificationEventsViewed, } from '@/actions/NotificationChannels' import { EVENT_LABELS, describeEvent } from '@/lib/notificationEvents' +import { NOTIFICATION_EVENT_TYPES, NotificationChannelType } from '@igniter/db/middleman/enums' + +// Event-type + channel dropdown options for the history filters. +const EVENT_TYPE_OPTIONS = NOTIFICATION_EVENT_TYPES.map((t) => ({ + value: t, + label: EVENT_LABELS[t] ?? t, +})) +const CHANNEL_OPTIONS = Object.values(NotificationChannelType).map((t) => ({ + value: t, + label: t.charAt(0).toUpperCase() + t.slice(1), +})) // Middleman wrapper around the shared history table: wallet-scoped actions + // middleman's event vocabulary. No detail drawer (provider-only), so a row click @@ -27,13 +38,15 @@ export function NotificationHistorySection() { return ( EVENT_LABELS[type] ?? 'Notification'} summaryFor={(type, metadata) => describeEvent(type, (metadata ?? {}) as Record) } - listEvents={async (page, pageSize) => { - const result = await ListNotificationEvents(page, pageSize) + listEvents={async (page, pageSize, filters) => { + const result = await ListNotificationEvents(page, pageSize, filters) if (!result.success) throw new Error(result.error.message) return result.data }} diff --git a/apps/middleman/src/app/app/overview/ProviderBreakdown.tsx b/apps/middleman/src/app/app/overview/ProviderBreakdown.tsx index ed0d1fca..1fe60b8a 100644 --- a/apps/middleman/src/app/app/overview/ProviderBreakdown.tsx +++ b/apps/middleman/src/app/app/overview/ProviderBreakdown.tsx @@ -157,9 +157,12 @@ export default function ProviderBreakdown({ providerCount }: { providerCount: nu
{/* Table card */}
+ {/* Bounded scroll box: invisible until the table outgrows it, then the + header below stays pinned. */} +
- - + + @@ -200,6 +203,7 @@ export default function ProviderBreakdown({ providerCount }: { providerCount: nu ))}
handleSort('name')}> Provider{sortIndicator('name')}
+
{/* Pie charts */} diff --git a/apps/middleman/src/app/components/Sidebar.tsx b/apps/middleman/src/app/components/Sidebar.tsx index 61a78cf3..5a5ab829 100644 --- a/apps/middleman/src/app/components/Sidebar.tsx +++ b/apps/middleman/src/app/components/Sidebar.tsx @@ -80,6 +80,13 @@ export const dynamic = "force-dynamic"; export default function Sidebar({}: Readonly) { const pathname = usePathname(); + // Sidebar chrome belongs only to the authenticated app/admin areas. On the + // portal (landing) and auth pages the whole rail is hidden — returning null + // drops both the fixed rail and its layout spacer so content is full width. + const isInternal = + pathname.startsWith("/app") || pathname.startsWith("/admin"); + if (!isInternal) return null; + const routes = pathname.startsWith("/admin") ? adminRoutes : mainRoutes; const MainRoutesMenu = routes.map((route) => ( @@ -91,7 +98,7 @@ export default function Sidebar({}: Readonly) { : "text-text-secondary" } > - + {route.title} diff --git a/apps/middleman/src/app/components/SidebarTriggerGate.tsx b/apps/middleman/src/app/components/SidebarTriggerGate.tsx new file mode 100644 index 00000000..24ea78dd --- /dev/null +++ b/apps/middleman/src/app/components/SidebarTriggerGate.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { usePathname } from "next/navigation"; +import { SidebarTrigger } from "@igniter/ui/components/sidebar"; + +// The sidebar toggle only makes sense where the sidebar exists: the +// authenticated app/admin areas. On the portal (landing) and auth pages there +// is no rail, so the trigger is hidden — mirrors the gate in Sidebar.tsx. +export default function SidebarTriggerGate() { + const pathname = usePathname(); + const isInternal = + pathname.startsWith("/app") || pathname.startsWith("/admin"); + if (!isInternal) return null; + + return ; +} \ No newline at end of file diff --git a/apps/middleman/src/app/layout.tsx b/apps/middleman/src/app/layout.tsx index c4006231..005cc571 100644 --- a/apps/middleman/src/app/layout.tsx +++ b/apps/middleman/src/app/layout.tsx @@ -5,6 +5,7 @@ import { ThemeProvider } from "@/app/theme"; import WalletConnectionProvider from "@/app/context/WalletConnection/Provider"; import { ApplicationSettingsProvider } from "@/app/context/ApplicationSettings"; import { SidebarInset, SidebarProvider } from "@igniter/ui/components/sidebar"; +import SidebarTriggerGate from "@/app/components/SidebarTriggerGate"; import { AppTopBar } from "@igniter/ui/components/AppTopBar/index"; import CurrentUser from "@/app/components/CurrentUser"; @@ -59,7 +60,7 @@ export default function RootLayout({ - + }> diff --git a/apps/middleman/src/lib/dal/notificationChannels.filters.test.ts b/apps/middleman/src/lib/dal/notificationChannels.filters.test.ts new file mode 100644 index 00000000..aa6e28b8 --- /dev/null +++ b/apps/middleman/src/lib/dal/notificationChannels.filters.test.ts @@ -0,0 +1,50 @@ +jest.mock('server-only', () => ({})) +jest.mock('@/db', () => ({ getDb: () => ({}) })) + +import { buildNotificationEventFilterConditions } from './notificationChannels' + +// The helper turns the optional filter set into AND-able SQL conditions. These +// tests lock the branching (each active filter contributes exactly one +// condition; absent/empty filters contribute none) so a future refactor can't +// silently drop a filter dimension. +describe('buildNotificationEventFilterConditions (middleman)', () => { + it('produces no conditions when nothing is filtered', () => { + expect(buildNotificationEventFilterConditions(undefined)).toHaveLength(0) + expect(buildNotificationEventFilterConditions({})).toHaveLength(0) + }) + + it('adds exactly one condition per active filter dimension', () => { + expect(buildNotificationEventFilterConditions({ type: 'stake' })).toHaveLength(1) + expect(buildNotificationEventFilterConditions({ read: 'unread' })).toHaveLength(1) + expect(buildNotificationEventFilterConditions({ read: 'read' })).toHaveLength(1) + expect(buildNotificationEventFilterConditions({ channel: 'discord' })).toHaveLength(1) + expect(buildNotificationEventFilterConditions({ search: 'abc' })).toHaveLength(1) + }) + + it('ignores an empty/unknown read value', () => { + expect( + buildNotificationEventFilterConditions({ read: '' as unknown as 'read' }), + ).toHaveLength(0) + }) + + it('ignores empty-string filter values (treated as unconstrained)', () => { + expect( + buildNotificationEventFilterConditions({ type: '', channel: '', search: '' }), + ).toHaveLength(0) + }) + + it('ignores an unknown event type instead of shipping it to the enum column', () => { + expect(buildNotificationEventFilterConditions({ type: 'not_a_real_type' })).toHaveLength(0) + }) + + it('combines every active filter into four ANDable conditions', () => { + expect( + buildNotificationEventFilterConditions({ + type: 'stake', + read: 'unread', + channel: 'discord', + search: 'x', + }), + ).toHaveLength(4) + }) +}) diff --git a/apps/middleman/src/lib/dal/notificationChannels.ts b/apps/middleman/src/lib/dal/notificationChannels.ts index c5db2056..dcbe880d 100644 --- a/apps/middleman/src/lib/dal/notificationChannels.ts +++ b/apps/middleman/src/lib/dal/notificationChannels.ts @@ -1,6 +1,7 @@ import 'server-only' import { getDb } from '@/db' -import { and, count, desc, eq, inArray, isNull } from 'drizzle-orm' +import { and, count, desc, eq, inArray, isNotNull, isNull, sql } from 'drizzle-orm' +import { NOTIFICATION_EVENT_TYPES } from '@igniter/db/middleman/enums' import { notificationChannelsTable, notificationEventsTable, @@ -9,9 +10,47 @@ import { type InsertNotificationEvent, type NotificationChannel, type NotificationEvent, + type NotificationEventType, type NotificationPreferences, } from '@igniter/db/middleman/schema' +// Server-side filters for the notification history table. All optional; an +// absent field means "no constraint on that dimension". +export type NotificationEventFilters = { + /** Partial, case-insensitive match on the event UUID. */ + search?: string + /** Exact event type (e.g. 'stake', 'service_change'). */ + type?: string + /** Read/unread by viewedAt presence. */ + read?: 'read' | 'unread' + /** Delivering channel type (e.g. 'discord') — matched against the channels JSON. */ + channel?: string +} + +// Translates the optional filter set into a list of AND-able SQL conditions. +// Exported for unit testing of the filter branching. +export function buildNotificationEventFilterConditions(filters?: NotificationEventFilters) { + const conds = [] + // Only push a type condition for a KNOWN enum member — an arbitrary string + // (e.g. a hand-crafted request bypassing the UI) would otherwise reach the + // enum column and make Postgres throw "invalid input value for enum". + if (filters?.type && (NOTIFICATION_EVENT_TYPES as readonly string[]).includes(filters.type)) { + conds.push(eq(notificationEventsTable.type, filters.type as NotificationEventType)) + } + if (filters?.read === 'unread') conds.push(isNull(notificationEventsTable.viewedAt)) + if (filters?.read === 'read') conds.push(isNotNull(notificationEventsTable.viewedAt)) + if (filters?.search) { + conds.push(sql`${notificationEventsTable.uuid}::text ILIKE ${'%' + filters.search + '%'}`) + } + if (filters?.channel) { + // channels is a JSON array of { type, ... }; match any element's type. + conds.push( + sql`EXISTS (SELECT 1 FROM json_array_elements(${notificationEventsTable.channels}) elem WHERE elem->>'type' = ${filters.channel})`, + ) + } + return conds +} + // The list/table view never receives the encrypted config — secrets stay on the // server. Only these non-secret columns are selected. const listColumns = { @@ -126,12 +165,21 @@ export async function listNotificationEvents( userIdentity: string, page = 0, pageSize = 25, + filters?: NotificationEventFilters, ): Promise<{ data: NotificationEvent[]; total: number; unviewedTotal: number }> { const db = getDb() // Scoped to the owning wallet on BOTH the rows query and the counts, so the - // paginated total never leaks other wallets' events. - const where = eq(notificationEventsTable.createdBy, userIdentity) - const unviewedWhere = and(where, isNull(notificationEventsTable.viewedAt)) + // paginated total never leaks other wallets' events. Filters are ANDed on top. + const where = and( + eq(notificationEventsTable.createdBy, userIdentity), + ...buildNotificationEventFilterConditions(filters), + ) + // Unread count stays scoped-but-unfiltered so the badge/mark-all reflect the + // true unread total regardless of the active filters. + const unviewedWhere = and( + eq(notificationEventsTable.createdBy, userIdentity), + isNull(notificationEventsTable.viewedAt), + ) const [rows, [countRow], [unviewedRow]] = await Promise.all([ db .select() diff --git a/apps/provider/src/actions/NotificationChannels.ts b/apps/provider/src/actions/NotificationChannels.ts index f9bcd9a3..540fb6a7 100644 --- a/apps/provider/src/actions/NotificationChannels.ts +++ b/apps/provider/src/actions/NotificationChannels.ts @@ -13,6 +13,7 @@ import { listUnviewedNotificationEvents, markNotificationEventsViewed, markAllNotificationEventsViewed, + type NotificationEventFilters, } from '@/lib/dal/notificationChannels' import { withRequireOwner } from '@/lib/utils/actionUtils' import { NotificationChannelType } from '@igniter/db/provider/enums' @@ -267,8 +268,12 @@ export async function TestNotificationChannel(id: number) { }) } -export async function ListNotificationEvents(page = 0, pageSize = 25, search?: string) { - return withRequireOwner(async () => listNotificationEvents(page, pageSize, search)) +export async function ListNotificationEvents( + page = 0, + pageSize = 25, + filters?: NotificationEventFilters, +) { + return withRequireOwner(async () => listNotificationEvents(page, pageSize, filters)) } export async function GetNotificationEvent(uuid: string) { diff --git a/apps/provider/src/app/admin/(internal)/keys/ActivitiesSection.tsx b/apps/provider/src/app/admin/(internal)/keys/ActivitiesSection.tsx index 329eaab3..865edbbe 100644 --- a/apps/provider/src/app/admin/(internal)/keys/ActivitiesSection.tsx +++ b/apps/provider/src/app/admin/(internal)/keys/ActivitiesSection.tsx @@ -15,16 +15,7 @@ import { TableHeader, TableRow, } from '@igniter/ui/components/table' -import type { - PendingStateSerialized, - PendingOperationSerialized, -} from '@/lib/pending/derivePendingState' - -// PENDING-only count — recently-settled linger rows don't inflate the badge. -function pendingCount(state: PendingStateSerialized | undefined): number { - if (!state) return 0 - return Object.keys(state.byKey).length -} +import type { PendingOperationSerialized } from '@/lib/pending/derivePendingState' const TYPE_LABELS: Record = { stake: 'Stake', @@ -86,26 +77,14 @@ export default function ActivitiesSection() { return pendingState?.pendingOperations ?? [] }, [pendingState]) - const count = pendingCount(pendingState) - - // Render nothing when no pending and no recently-settled rows — section only - // appears during activity. - if (rows.length === 0) return null - return (
- {/* Badge shows PENDING count only; settled-linger rows don't inflate it. */} -

- In progress - {count > 0 && ( - · {count} - )} -

- {/* Compact, chrome-free strip built from the shared Table primitives (no DataTable toolbar/pagination). ~5 rows then internal scroll. */} - + {/* Lock header to the top of the scroll box; opaque root bg so rows + don't bleed through, plus a bottom divider that stays put. */} + {/* Column order: Tx Hash · Submitted · Supplier · Type · Status */} Tx Hash @@ -116,6 +95,16 @@ export default function ActivitiesSection() { + {rows.length === 0 && ( + + + No activity yet. + + + )} {rows.map((row) => { const statusLabel = getStatusLabel(row) const statusClass = getStatusClass(row) diff --git a/apps/provider/src/app/admin/(internal)/keys/KeysTabs.tsx b/apps/provider/src/app/admin/(internal)/keys/KeysTabs.tsx new file mode 100644 index 00000000..3b73aacf --- /dev/null +++ b/apps/provider/src/app/admin/(internal)/keys/KeysTabs.tsx @@ -0,0 +1,47 @@ +'use client' + +import * as React from 'react' +import { usePathname, useRouter, useSearchParams } from 'next/navigation' + +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@igniter/ui/components/tabs' + +import KeysTable from '@/app/admin/(internal)/keys/table' +import ActivitiesSection from '@/app/admin/(internal)/keys/ActivitiesSection' + +const TABS = ['keys', 'activity'] as const +type TabValue = (typeof TABS)[number] + +// The two data tables that used to stack on the Keys screen now live one per +// tab. Selection is persisted in the URL (?tab=) so refreshes / deep-links land +// on the same table — same pattern as the middleman Suppliers screen. +export default function KeysTabs() { + const router = useRouter() + const pathname = usePathname() + const searchParams = useSearchParams() + + const param = searchParams.get('tab') + const tab: TabValue = (TABS as readonly string[]).includes(param ?? '') + ? (param as TabValue) + : 'keys' + + const onTabChange = (next: string) => { + const params = new URLSearchParams(searchParams.toString()) + params.set('tab', next) + router.replace(`${pathname}?${params.toString()}`, { scroll: false }) + } + + return ( + + + Keys + Activity + + + + + + + + + ) +} \ No newline at end of file diff --git a/apps/provider/src/app/admin/(internal)/keys/page.tsx b/apps/provider/src/app/admin/(internal)/keys/page.tsx index 07a31f32..a434ad77 100644 --- a/apps/provider/src/app/admin/(internal)/keys/page.tsx +++ b/apps/provider/src/app/admin/(internal)/keys/page.tsx @@ -1,7 +1,6 @@ import type { Metadata } from 'next' import React from 'react' -import KeysTable from '@/app/admin/(internal)/keys/table' -import ActivitiesSection from '@/app/admin/(internal)/keys/ActivitiesSection' +import KeysTabs from '@/app/admin/(internal)/keys/KeysTabs' import { GetAppName, GetApplicationSettings } from '@/actions/ApplicationSettings' import ClearRemediationButton from '@/app/admin/(internal)/keys/ClearRemediationButton' import AutoStakeToggle from '@/app/admin/(internal)/keys/AutoStakeToggle' @@ -41,9 +40,8 @@ export default async function AddressesPage() { } /> - - + diff --git a/apps/provider/src/app/admin/(internal)/layout.tsx b/apps/provider/src/app/admin/(internal)/layout.tsx index 714ab58c..3d41e1f0 100644 --- a/apps/provider/src/app/admin/(internal)/layout.tsx +++ b/apps/provider/src/app/admin/(internal)/layout.tsx @@ -3,7 +3,7 @@ import "@/app/globals.css"; import { ThemeProvider } from "@/app/theme"; import WalletConnectionProvider from "@/app/context/WalletConnection/Provider"; import { ApplicationSettingsProvider } from "@/app/context/ApplicationSettings"; -import { SidebarInset, SidebarProvider } from "@igniter/ui/components/sidebar"; +import { SidebarInset, SidebarProvider, SidebarTrigger } from "@igniter/ui/components/sidebar"; import { AppTopBar } from "@igniter/ui/components/AppTopBar/index"; import CurrentUser from "@/components/CurrentUser"; import Sidebar from "@/components/Sidebar"; @@ -33,7 +33,7 @@ export default function RootLayout({ - + }> @@ -41,7 +41,7 @@ export default function RootLayout({
-
+
{children} diff --git a/apps/provider/src/app/admin/(internal)/notifications/NotificationEventsSection.tsx b/apps/provider/src/app/admin/(internal)/notifications/NotificationEventsSection.tsx index 7fd661a0..b214a637 100644 --- a/apps/provider/src/app/admin/(internal)/notifications/NotificationEventsSection.tsx +++ b/apps/provider/src/app/admin/(internal)/notifications/NotificationEventsSection.tsx @@ -15,8 +15,19 @@ import { } from '@/actions/NotificationChannels' import type { ProviderQuickDetailItem } from '@/app/admin/details/types' import type { NotificationEvent, NotificationEventMetadata } from '@igniter/db/provider/schema' +import { NOTIFICATION_EVENT_TYPES, NotificationChannelType } from '@igniter/db/provider/enums' import { NOTIFICATION_EVENT_LABELS, REMEDIATION_REASON_LABELS } from '@/lib/constants' +// Event-type + channel dropdown options for the history filters. +const EVENT_TYPE_OPTIONS = NOTIFICATION_EVENT_TYPES.map((t) => ({ + value: t, + label: NOTIFICATION_EVENT_LABELS[t as keyof typeof NOTIFICATION_EVENT_LABELS] ?? t, +})) +const CHANNEL_OPTIONS = Object.values(NotificationChannelType).map((t) => ({ + value: t, + label: t.charAt(0).toUpperCase() + t.slice(1), +})) + function metadataSummary(type: string, metadata: NotificationEventMetadata | null | undefined): string { if (!metadata) return '—' if ('addresses' in metadata) { @@ -96,7 +107,8 @@ export function NotificationEventsSection({ onMarkAllViewed }: NotificationEvent return ( } @@ -104,8 +116,8 @@ export function NotificationEventsSection({ onMarkAllViewed }: NotificationEvent NOTIFICATION_EVENT_LABELS[type as keyof typeof NOTIFICATION_EVENT_LABELS] ?? type } summaryFor={(type, metadata) => metadataSummary(type, metadata as NotificationEventMetadata)} - listEvents={async (page, pageSize, search) => { - const result = await ListNotificationEvents(page, pageSize, search) + listEvents={async (page, pageSize, filters) => { + const result = await ListNotificationEvents(page, pageSize, filters) if (!result.success) throw new Error(result.error.message) return result.data }} diff --git a/apps/provider/src/app/admin/(internal)/transactions/table/columns.tsx b/apps/provider/src/app/admin/(internal)/transactions/table/columns.tsx index 571a5a1c..69a8400e 100644 --- a/apps/provider/src/app/admin/(internal)/transactions/table/columns.tsx +++ b/apps/provider/src/app/admin/(internal)/transactions/table/columns.tsx @@ -5,6 +5,7 @@ import { FilterGroup, SortOption } from '@igniter/ui/components/DataTable/index' import Address from '@igniter/ui/components/Address' import type { Transaction } from '@igniter/db/provider/schema' import { TransactionStatus, TransactionType, TransactionTrigger, RemediationHistoryEntryReason } from '@igniter/db/provider/enums' +import { failureReasonDisplay } from '@igniter/commons/utils' import { Button } from '@igniter/ui/components/button' import { RightArrowIcon } from '@igniter/ui/assets' import { useAddItemToDetail } from '@igniter/ui/components/QuickDetails/Provider' @@ -99,6 +100,22 @@ export const columns: Array> = [ ) }, }, + { + accessorKey: "message", + header: "Failure Reason", + cell: ({ row }) => { + const status = row.getValue("status") as string + const text = failureReasonDisplay(status === TransactionStatus.Failure, row.original.message) + if (text === null) { + return - + } + return ( + + {text} + + ) + }, + }, { accessorKey: "trigger", header: "Trigger", diff --git a/apps/provider/src/components/Sidebar.tsx b/apps/provider/src/components/Sidebar.tsx index 290607d9..fbdd2aa0 100644 --- a/apps/provider/src/components/Sidebar.tsx +++ b/apps/provider/src/components/Sidebar.tsx @@ -104,7 +104,7 @@ export default function Sidebar({}: Readonly) { : "text-text-secondary" } > - + {route.title} diff --git a/apps/provider/src/lib/dal/notificationChannels.filters.test.ts b/apps/provider/src/lib/dal/notificationChannels.filters.test.ts new file mode 100644 index 00000000..05d109a2 --- /dev/null +++ b/apps/provider/src/lib/dal/notificationChannels.filters.test.ts @@ -0,0 +1,49 @@ +jest.mock('@/db', () => ({ getDb: () => ({}) })) + +import { buildNotificationEventFilterConditions } from './notificationChannels' + +// The helper turns the optional filter set into AND-able SQL conditions. These +// tests lock the branching (each active filter contributes exactly one +// condition; absent/empty filters contribute none) so a future refactor can't +// silently drop a filter dimension. +describe('buildNotificationEventFilterConditions (provider)', () => { + it('produces no conditions when nothing is filtered', () => { + expect(buildNotificationEventFilterConditions(undefined)).toHaveLength(0) + expect(buildNotificationEventFilterConditions({})).toHaveLength(0) + }) + + it('adds exactly one condition per active filter dimension', () => { + expect(buildNotificationEventFilterConditions({ type: 'keys_staked' })).toHaveLength(1) + expect(buildNotificationEventFilterConditions({ read: 'unread' })).toHaveLength(1) + expect(buildNotificationEventFilterConditions({ read: 'read' })).toHaveLength(1) + expect(buildNotificationEventFilterConditions({ channel: 'telegram' })).toHaveLength(1) + expect(buildNotificationEventFilterConditions({ search: 'abc' })).toHaveLength(1) + }) + + it('ignores an empty/unknown read value', () => { + expect( + buildNotificationEventFilterConditions({ read: '' as unknown as 'read' }), + ).toHaveLength(0) + }) + + it('ignores empty-string filter values (treated as unconstrained)', () => { + expect( + buildNotificationEventFilterConditions({ type: '', channel: '', search: '' }), + ).toHaveLength(0) + }) + + it('ignores an unknown event type instead of shipping it to the enum column', () => { + expect(buildNotificationEventFilterConditions({ type: 'not_a_real_type' })).toHaveLength(0) + }) + + it('combines every active filter into four ANDable conditions', () => { + expect( + buildNotificationEventFilterConditions({ + type: 'keys_staked', + read: 'unread', + channel: 'telegram', + search: 'x', + }), + ).toHaveLength(4) + }) +}) diff --git a/apps/provider/src/lib/dal/notificationChannels.ts b/apps/provider/src/lib/dal/notificationChannels.ts index 4392568a..6d1513a2 100644 --- a/apps/provider/src/lib/dal/notificationChannels.ts +++ b/apps/provider/src/lib/dal/notificationChannels.ts @@ -1,4 +1,5 @@ -import { count, desc, eq, inArray, isNull } from 'drizzle-orm' +import { and, count, desc, eq, inArray, isNotNull, isNull, sql } from 'drizzle-orm' +import { NOTIFICATION_EVENT_TYPES } from '@igniter/db/provider/enums' import { notificationChannelsTable, notificationEventsTable, @@ -6,11 +7,49 @@ import { type NotificationChannel, type InsertNotificationChannel, type NotificationEvent, + type NotificationEventType, type SmtpConfiguration, type InsertSmtpConfiguration, } from '@igniter/db/provider/schema' import { getDb } from '@/db' +// Server-side filters for the notification history table. All optional; an +// absent field means "no constraint on that dimension". +export type NotificationEventFilters = { + /** Partial, case-insensitive match on the event UUID. */ + search?: string + /** Exact event type (e.g. 'stake', 'service_change'). */ + type?: string + /** Read/unread by viewedAt presence. */ + read?: 'read' | 'unread' + /** Delivering channel type (e.g. 'discord') — matched against the channels JSON. */ + channel?: string +} + +// Translates the optional filter set into a list of AND-able SQL conditions. +// Exported for unit testing of the filter branching. +export function buildNotificationEventFilterConditions(filters?: NotificationEventFilters) { + const conds = [] + // Only push a type condition for a KNOWN enum member — an arbitrary string + // (e.g. a hand-crafted request bypassing the UI) would otherwise reach the + // enum column and make Postgres throw "invalid input value for enum". + if (filters?.type && (NOTIFICATION_EVENT_TYPES as readonly string[]).includes(filters.type)) { + conds.push(eq(notificationEventsTable.type, filters.type as NotificationEventType)) + } + if (filters?.read === 'unread') conds.push(isNull(notificationEventsTable.viewedAt)) + if (filters?.read === 'read') conds.push(isNotNull(notificationEventsTable.viewedAt)) + if (filters?.search) { + conds.push(sql`${notificationEventsTable.uuid}::text ILIKE ${'%' + filters.search + '%'}`) + } + if (filters?.channel) { + // channels is a JSON array of { type, ... }; match any element's type. + conds.push( + sql`EXISTS (SELECT 1 FROM json_array_elements(${notificationEventsTable.channels}) elem WHERE elem->>'type' = ${filters.channel})`, + ) + } + return conds +} + // Deliberately excludes `config`: it holds channel secrets (webhook URL, bot // token) and must not be shipped to the client with the list view. Use // getChannel(id) server-side when the config is actually needed. @@ -117,10 +156,11 @@ export async function deleteSmtpConfig(): Promise { export async function listNotificationEvents( page: number, pageSize: number, - search?: string, + filters?: NotificationEventFilters, ): Promise<{ data: NotificationEvent[]; total: number; unviewedTotal: number }> { const db = getDb() - const where = search ? eq(notificationEventsTable.uuid, search) : undefined + const conds = buildNotificationEventFilterConditions(filters) + const where = conds.length ? and(...conds) : undefined const [rows, [countRow], [unviewedRow]] = await Promise.all([ db .select() diff --git a/packages/commons/src/utils.test.ts b/packages/commons/src/utils.test.ts index a72987a3..010f6990 100644 --- a/packages/commons/src/utils.test.ts +++ b/packages/commons/src/utils.test.ts @@ -1,4 +1,4 @@ -import { parseEnvInt, checkEnvVariables } from './utils'; +import { parseEnvInt, checkEnvVariables, failureReasonDisplay } from './utils'; describe('parseEnvInt', () => { it('parses a valid integer string', () => { @@ -65,3 +65,23 @@ describe('checkEnvVariables', () => { expect(() => checkEnvVariables([])).not.toThrow(); }); }); + +describe('failureReasonDisplay', () => { + it('returns null when the transaction is not a failure (caller shows a dash)', () => { + expect(failureReasonDisplay(false, 'insufficient funds')).toBeNull(); + expect(failureReasonDisplay(false, null)).toBeNull(); + expect(failureReasonDisplay(false, undefined)).toBeNull(); + }); + + it('returns the trimmed reason for a failure', () => { + expect(failureReasonDisplay(true, 'insufficient funds')).toBe('insufficient funds'); + expect(failureReasonDisplay(true, ' sequence mismatch ')).toBe('sequence mismatch'); + }); + + it('falls back to "Unknown error" when a failure has no usable reason', () => { + expect(failureReasonDisplay(true, null)).toBe('Unknown error'); + expect(failureReasonDisplay(true, undefined)).toBe('Unknown error'); + expect(failureReasonDisplay(true, '')).toBe('Unknown error'); + expect(failureReasonDisplay(true, ' ')).toBe('Unknown error'); + }); +}); diff --git a/packages/commons/src/utils.ts b/packages/commons/src/utils.ts index 0c2cfacd..61ccaf78 100644 --- a/packages/commons/src/utils.ts +++ b/packages/commons/src/utils.ts @@ -10,3 +10,17 @@ export const checkEnvVariables = (vars: string[]) => { } } } + +/** + * Display text for a transaction's failure reason, shared by both apps' + * transaction tables. Returns `null` when the row is not a failure (the caller + * renders a placeholder such as "-"); otherwise the trimmed reason, or + * "Unknown error" when the reason is absent, empty, or whitespace-only. + */ +export const failureReasonDisplay = ( + isFailure: boolean, + reason: string | null | undefined, +): string | null => { + if (!isFailure) return null + return reason?.trim() || 'Unknown error' +} diff --git a/packages/ui/src/components/AppSidebar.tsx b/packages/ui/src/components/AppSidebar.tsx index 57ebdbe9..24f26fb1 100644 --- a/packages/ui/src/components/AppSidebar.tsx +++ b/packages/ui/src/components/AppSidebar.tsx @@ -6,6 +6,7 @@ import { SidebarGroupContent, SidebarHeader, SidebarMenu, + SidebarRail, } from "./sidebar"; import { ComponentType } from "react"; @@ -28,6 +29,7 @@ export default function AppSidebar({ }: Readonly) { return ( @@ -46,6 +48,7 @@ export default function AppSidebar({ + ); } diff --git a/packages/ui/src/components/AppTopBar/index.tsx b/packages/ui/src/components/AppTopBar/index.tsx index 5c4ad4d5..313462c2 100644 --- a/packages/ui/src/components/AppTopBar/index.tsx +++ b/packages/ui/src/components/AppTopBar/index.tsx @@ -5,10 +5,11 @@ import { PocketBrandLogo } from "../PocketBrandLogo"; export interface AppTopBarProps { logoIcon?: ComponentType; + leading?: React.ReactNode; children?: React.ReactNode; } -export async function AppTopBar({ logoIcon: LogoIcon, children } : Readonly) { +export async function AppTopBar({ logoIcon: LogoIcon, leading, children } : Readonly) { return (
-
+
+ { leading } { LogoIcon ? : }
diff --git a/packages/ui/src/components/DataTable/index.tsx b/packages/ui/src/components/DataTable/index.tsx index 09207ed1..f6ef630a 100644 --- a/packages/ui/src/components/DataTable/index.tsx +++ b/packages/ui/src/components/DataTable/index.tsx @@ -115,6 +115,8 @@ export interface DataTableProps { enableRowSelection?: boolean /** Derive a stable string key from a row; passed to tanstack getRowId. */ getRowId?: (row: TData) => string + /** Lock the header row so it stays visible while the table body scrolls. On by default. */ + stickyHeader?: boolean /** Called whenever the selection changes; receives the selected row originals. */ onSelectionChange?: (selectedRows: TData[]) => void } @@ -143,6 +145,7 @@ export default function DataTable({ enableRowSelection, getRowId, onSelectionChange, + stickyHeader = true, }: DataTableProps) { const isServerPaginated = !!manualPagination const defaultSort = sorts.flat().find((sort) => sort.isDefault); @@ -427,6 +430,12 @@ export default function DataTable({ "text-text-tertiary uppercase text-xs font-semibold tracking-wide px-4", align === 'center' && 'text-center', align === 'right' && 'text-right', + // Locks the header to the top of the (already scrollable) + // table container. Needs an opaque bg (the page/table + // root color) so scrolled rows don't bleed through, plus + // a bottom border since the header row's own divider + // scrolls away with the body. + stickyHeader && 'sticky top-0 z-10 bg-(--bg-root) border-b border-border-primary', ) } > @@ -434,7 +443,7 @@ export default function DataTable({ ) })} - {itemActions && } + {itemActions && } ))} diff --git a/packages/ui/src/components/NotificationChannels/NotificationHistory.tsx b/packages/ui/src/components/NotificationChannels/NotificationHistory.tsx index c849b6a1..989d1df8 100644 --- a/packages/ui/src/components/NotificationChannels/NotificationHistory.tsx +++ b/packages/ui/src/components/NotificationChannels/NotificationHistory.tsx @@ -5,6 +5,13 @@ import { useQuery, useQueryClient } from '@tanstack/react-query' import DataTable from '../DataTable/index' import { Button } from '../button' import { Input } from '../input' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '../select' import { RightArrowIcon } from '../../assets' import type { ColumnDef } from '../table' import type { CsvColumnDef } from '../../lib/csv' @@ -33,18 +40,37 @@ export interface NotificationHistoryEvent { viewedAt: Date | string | null } +/** A selectable value for one of the filter dropdowns. */ +export interface NotificationFilterOption { + value: string + label: string +} + +/** Server-side filter selection sent to `listEvents`. Absent field = unconstrained. */ +export interface NotificationHistoryFilters { + search?: string + type?: string + read?: 'read' | 'unread' + channel?: string +} + export interface NotificationHistoryProps { /** * App-specific, wallet/owner-scoped fetch returning a page + total count. * `unviewedTotal` is the server-side count of ALL unread events (not just this * page) so the badge and the mark-all control reflect the true unread total * rather than however many unread rows happen to land on the current page. + * `filters` carries the active dropdown/search selection (all optional). */ listEvents: ( page: number, pageSize: number, - search?: string, + filters?: NotificationHistoryFilters, ) => Promise<{ data: NotificationHistoryEvent[]; total: number; unviewedTotal?: number }> + /** When set, renders an event-type filter dropdown (each app's own vocabulary). */ + eventTypeOptions?: NotificationFilterOption[] + /** When set, renders a channel filter dropdown (e.g. Discord/Telegram/Email). */ + channelOptions?: NotificationFilterOption[] /** Marks every unread event viewed (app-scoped). */ markAllViewed: () => Promise /** Human label for an event type (vocabularies differ per app). */ @@ -83,6 +109,8 @@ function formatDate(date: Date | string) { */ export function NotificationHistory({ listEvents, + eventTypeOptions, + channelOptions, markAllViewed, labelFor, summaryFor, @@ -97,11 +125,27 @@ export function NotificationHistory({ const [pageIndex, setPageIndex] = useState(0) const [pageSize, setPageSize] = useState(initialPageSize) const [search, setSearch] = useState('') + const [typeFilter, setTypeFilter] = useState('') + const [readFilter, setReadFilter] = useState<'' | 'read' | 'unread'>('') + const [channelFilter, setChannelFilter] = useState('') const [isMarkingAll, setIsMarkingAll] = useState(false) + // Any filter change resets to page 0 so the user isn't stranded on a page + // that no longer exists under the narrower result set. + const onFilterChange = (apply: () => void) => { + apply() + setPageIndex(0) + } + const { data, isLoading, isError, refetch } = useQuery({ - queryKey: [queryKey, pageIndex, pageSize, search], - queryFn: () => listEvents(pageIndex, pageSize, search || undefined), + queryKey: [queryKey, pageIndex, pageSize, search, typeFilter, readFilter, channelFilter], + queryFn: () => + listEvents(pageIndex, pageSize, { + search: search || undefined, + type: typeFilter || undefined, + read: readFilter || undefined, + channel: channelFilter || undefined, + }), }) const handleMarkAll = async () => { @@ -180,35 +224,81 @@ export function NotificationHistory({ return (
- {(enableSearch || unviewedCount > 0) && ( -
- {enableSearch && ( - { - setSearch(e.target.value) - setPageIndex(0) - }} - className="max-w-xs" - /> - )} - {unviewedCount > 0 && ( - - )} -
- )} +
+ {enableSearch && ( + onFilterChange(() => setSearch(e.target.value))} + className="max-w-xs" + /> + )} + {eventTypeOptions && eventTypeOptions.length > 0 && ( + + )} + + {channelOptions && channelOptions.length > 0 && ( + + )} + {unviewedCount > 0 && ( + + )} +
{resumeError.scheduleId}: {resumeError.message}

)} -
- +
+ Schedule diff --git a/packages/ui/src/components/workflows/WorkflowDetailClient.tsx b/packages/ui/src/components/workflows/WorkflowDetailClient.tsx index 2af25836..e61df469 100644 --- a/packages/ui/src/components/workflows/WorkflowDetailClient.tsx +++ b/packages/ui/src/components/workflows/WorkflowDetailClient.tsx @@ -276,8 +276,8 @@ export function WorkflowDetailClient({

Child workflows ({detail.children.length})

-
- +
+ Workflow ID Type @@ -456,8 +456,8 @@ function ActivitiesTable({ return

No activities recorded.

} return ( -
- +
+ # From 5392fa52f5586e588b666bc674e998abcf48543a Mon Sep 17 00:00:00 2001 From: miguelc Date: Fri, 17 Jul 2026 17:38:26 -0400 Subject: [PATCH 2/4] fix: address PR #325 review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - provider: restore UUID search on notifications (re-add enableSearch) - provider/middleman: surface pending activity on the Activity tab by lifting the pending query above the Radix tab boundary and wiring TabsBadge — the poll was trapped in the unmounted tab, so pending stake/unstake was invisible on the default tab - db: extract shared buildNotificationEventFilterConditions to @igniter/db/notifications; both DAL copies now delegate (dedup) - commons: extract isInternalPath to @igniter/commons/utils; Sidebar and SidebarTriggerGate share it (dedup) --- .../app/(lists)/suppliers/SuppliersTabs.tsx | 20 ++++++- apps/middleman/src/app/components/Sidebar.tsx | 6 +-- .../src/app/components/SidebarTriggerGate.tsx | 8 +-- .../src/lib/dal/notificationChannels.ts | 45 ++++------------ .../app/admin/(internal)/keys/KeysTabs.tsx | 23 +++++++- .../NotificationEventsSection.tsx | 1 + .../src/lib/dal/notificationChannels.ts | 45 ++++------------ packages/commons/src/utils.ts | 9 ++++ packages/db/package.json | 5 ++ packages/db/src/notifications.ts | 53 +++++++++++++++++++ 10 files changed, 134 insertions(+), 81 deletions(-) create mode 100644 packages/db/src/notifications.ts diff --git a/apps/middleman/src/app/app/(lists)/suppliers/SuppliersTabs.tsx b/apps/middleman/src/app/app/(lists)/suppliers/SuppliersTabs.tsx index 57e2de6a..6fcfde9b 100644 --- a/apps/middleman/src/app/app/(lists)/suppliers/SuppliersTabs.tsx +++ b/apps/middleman/src/app/app/(lists)/suppliers/SuppliersTabs.tsx @@ -2,9 +2,11 @@ import * as React from 'react' import { usePathname, useRouter, useSearchParams } from 'next/navigation' +import { useQuery } from '@tanstack/react-query' -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@igniter/ui/components/tabs' +import { Tabs, TabsContent, TabsList, TabsTrigger, TabsBadge } from '@igniter/ui/components/tabs' +import { GetPendingState } from '@/actions/Pending' import NodesTable from '@/app/app/(lists)/suppliers/table' import ActivitiesSection from '@/app/app/(lists)/suppliers/ActivitiesSection' import ChainOverview from '@/app/app/(lists)/suppliers/ChainOverview' @@ -25,6 +27,17 @@ export default function SuppliersTabs() { ? (param as TabValue) : 'suppliers' + // Lifted above the tab boundary so the pending count stays live regardless of + // the active tab — ActivitiesSection (its own consumer of this same queryKey) + // only mounts on the Activity tab, so the badge needs its own always-mounted + // read. Same queryKey → react-query dedups to a single poll + shared cache. + const { data: pendingState } = useQuery({ + queryKey: ['pendingState'], + queryFn: GetPendingState, + refetchInterval: (q) => ((q.state.data?.pendingOperations?.length ?? 0) > 0 ? 7000 : false), + }) + const pendingCount = Object.keys(pendingState?.byOperator ?? {}).length + const onTabChange = (next: string) => { const params = new URLSearchParams(searchParams.toString()) params.set('tab', next) @@ -35,7 +48,10 @@ export default function SuppliersTabs() { Suppliers - Activity + + Activity + + Overview diff --git a/apps/middleman/src/app/components/Sidebar.tsx b/apps/middleman/src/app/components/Sidebar.tsx index 5a5ab829..e6ce2827 100644 --- a/apps/middleman/src/app/components/Sidebar.tsx +++ b/apps/middleman/src/app/components/Sidebar.tsx @@ -6,6 +6,7 @@ import { } from "@igniter/ui/components/sidebar"; import Link from "next/link"; import { usePathname } from "next/navigation"; +import { isInternalPath } from "@igniter/commons/utils"; import OverviewDark from "@/app/assets/icons/dark/overview.svg"; import ActivityDark from "@/app/assets/icons/dark/activity.svg"; import NodesDark from "@/app/assets/icons/dark/nodes.svg"; @@ -83,9 +84,8 @@ export default function Sidebar({}: Readonly) { // Sidebar chrome belongs only to the authenticated app/admin areas. On the // portal (landing) and auth pages the whole rail is hidden — returning null // drops both the fixed rail and its layout spacer so content is full width. - const isInternal = - pathname.startsWith("/app") || pathname.startsWith("/admin"); - if (!isInternal) return null; + // Same gate as SidebarTriggerGate via the shared isInternalPath helper. + if (!isInternalPath(pathname)) return null; const routes = pathname.startsWith("/admin") ? adminRoutes : mainRoutes; diff --git a/apps/middleman/src/app/components/SidebarTriggerGate.tsx b/apps/middleman/src/app/components/SidebarTriggerGate.tsx index 24ea78dd..0f29b293 100644 --- a/apps/middleman/src/app/components/SidebarTriggerGate.tsx +++ b/apps/middleman/src/app/components/SidebarTriggerGate.tsx @@ -1,16 +1,16 @@ "use client"; import { usePathname } from "next/navigation"; +import { isInternalPath } from "@igniter/commons/utils"; import { SidebarTrigger } from "@igniter/ui/components/sidebar"; // The sidebar toggle only makes sense where the sidebar exists: the // authenticated app/admin areas. On the portal (landing) and auth pages there -// is no rail, so the trigger is hidden — mirrors the gate in Sidebar.tsx. +// is no rail, so the trigger is hidden — same gate as Sidebar.tsx via the +// shared isInternalPath helper. export default function SidebarTriggerGate() { const pathname = usePathname(); - const isInternal = - pathname.startsWith("/app") || pathname.startsWith("/admin"); - if (!isInternal) return null; + if (!isInternalPath(pathname)) return null; return ; } \ No newline at end of file diff --git a/apps/middleman/src/lib/dal/notificationChannels.ts b/apps/middleman/src/lib/dal/notificationChannels.ts index dcbe880d..0e28cc3a 100644 --- a/apps/middleman/src/lib/dal/notificationChannels.ts +++ b/apps/middleman/src/lib/dal/notificationChannels.ts @@ -1,7 +1,11 @@ import 'server-only' import { getDb } from '@/db' -import { and, count, desc, eq, inArray, isNotNull, isNull, sql } from 'drizzle-orm' +import { and, count, desc, eq, inArray, isNull } from 'drizzle-orm' import { NOTIFICATION_EVENT_TYPES } from '@igniter/db/middleman/enums' +import { + buildNotificationEventFilterConditions as buildConditions, + type NotificationEventFilters, +} from '@igniter/db/notifications' import { notificationChannelsTable, notificationEventsTable, @@ -10,45 +14,16 @@ import { type InsertNotificationEvent, type NotificationChannel, type NotificationEvent, - type NotificationEventType, type NotificationPreferences, } from '@igniter/db/middleman/schema' -// Server-side filters for the notification history table. All optional; an -// absent field means "no constraint on that dimension". -export type NotificationEventFilters = { - /** Partial, case-insensitive match on the event UUID. */ - search?: string - /** Exact event type (e.g. 'stake', 'service_change'). */ - type?: string - /** Read/unread by viewedAt presence. */ - read?: 'read' | 'unread' - /** Delivering channel type (e.g. 'discord') — matched against the channels JSON. */ - channel?: string -} +export type { NotificationEventFilters } -// Translates the optional filter set into a list of AND-able SQL conditions. -// Exported for unit testing of the filter branching. +// Binds the middleman events table + enum to the shared filter builder in +// @igniter/db/notifications. Kept as a same-signature wrapper so call sites and +// the unit tests (notificationChannels.filters.test.ts) stay unchanged. export function buildNotificationEventFilterConditions(filters?: NotificationEventFilters) { - const conds = [] - // Only push a type condition for a KNOWN enum member — an arbitrary string - // (e.g. a hand-crafted request bypassing the UI) would otherwise reach the - // enum column and make Postgres throw "invalid input value for enum". - if (filters?.type && (NOTIFICATION_EVENT_TYPES as readonly string[]).includes(filters.type)) { - conds.push(eq(notificationEventsTable.type, filters.type as NotificationEventType)) - } - if (filters?.read === 'unread') conds.push(isNull(notificationEventsTable.viewedAt)) - if (filters?.read === 'read') conds.push(isNotNull(notificationEventsTable.viewedAt)) - if (filters?.search) { - conds.push(sql`${notificationEventsTable.uuid}::text ILIKE ${'%' + filters.search + '%'}`) - } - if (filters?.channel) { - // channels is a JSON array of { type, ... }; match any element's type. - conds.push( - sql`EXISTS (SELECT 1 FROM json_array_elements(${notificationEventsTable.channels}) elem WHERE elem->>'type' = ${filters.channel})`, - ) - } - return conds + return buildConditions(notificationEventsTable, NOTIFICATION_EVENT_TYPES, filters) } // The list/table view never receives the encrypted config — secrets stay on the diff --git a/apps/provider/src/app/admin/(internal)/keys/KeysTabs.tsx b/apps/provider/src/app/admin/(internal)/keys/KeysTabs.tsx index 3b73aacf..bcd18762 100644 --- a/apps/provider/src/app/admin/(internal)/keys/KeysTabs.tsx +++ b/apps/provider/src/app/admin/(internal)/keys/KeysTabs.tsx @@ -2,9 +2,11 @@ import * as React from 'react' import { usePathname, useRouter, useSearchParams } from 'next/navigation' +import { useQuery } from '@tanstack/react-query' -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@igniter/ui/components/tabs' +import { Tabs, TabsContent, TabsList, TabsTrigger, TabsBadge } from '@igniter/ui/components/tabs' +import { GetKeysPendingState } from '@/actions/Transactions' import KeysTable from '@/app/admin/(internal)/keys/table' import ActivitiesSection from '@/app/admin/(internal)/keys/ActivitiesSection' @@ -24,6 +26,20 @@ export default function KeysTabs() { ? (param as TabValue) : 'keys' + // Lifted above the tab boundary so the pending count stays live regardless of + // the active tab — ActivitiesSection (its own consumer of this same queryKey) + // only mounts on the Activity tab, so the badge needs its own always-mounted + // read. Same queryKey → react-query dedups to a single poll + shared cache. + const { data: pendingState } = useQuery({ + queryKey: ['keys-pending-state'], + queryFn: async () => { + const res = await GetKeysPendingState() + return res.success ? res.data : { byKey: {}, pendingOperations: [] } + }, + refetchInterval: 4000, + }) + const pendingCount = Object.keys(pendingState?.byKey ?? {}).length + const onTabChange = (next: string) => { const params = new URLSearchParams(searchParams.toString()) params.set('tab', next) @@ -34,7 +50,10 @@ export default function KeysTabs() { Keys - Activity + + Activity + + diff --git a/apps/provider/src/app/admin/(internal)/notifications/NotificationEventsSection.tsx b/apps/provider/src/app/admin/(internal)/notifications/NotificationEventsSection.tsx index b214a637..66d76c63 100644 --- a/apps/provider/src/app/admin/(internal)/notifications/NotificationEventsSection.tsx +++ b/apps/provider/src/app/admin/(internal)/notifications/NotificationEventsSection.tsx @@ -107,6 +107,7 @@ export function NotificationEventsSection({ onMarkAllViewed }: NotificationEvent return ( >'type' = ${filters.channel})`, - ) - } - return conds + return buildConditions(notificationEventsTable, NOTIFICATION_EVENT_TYPES, filters) } // Deliberately excludes `config`: it holds channel secrets (webhook URL, bot diff --git a/packages/commons/src/utils.ts b/packages/commons/src/utils.ts index 61ccaf78..f23608e7 100644 --- a/packages/commons/src/utils.ts +++ b/packages/commons/src/utils.ts @@ -24,3 +24,12 @@ export const failureReasonDisplay = ( if (!isFailure) return null return reason?.trim() || 'Unknown error' } + +/** + * Whether a pathname belongs to the authenticated internal areas (app/admin), + * as opposed to the portal (landing) and auth pages. Single source of truth for + * the sidebar gate: both the rail (Sidebar) and its toggle (SidebarTriggerGate) + * must agree, so the prefix set lives here rather than being duplicated. + */ +export const isInternalPath = (pathname: string): boolean => + pathname.startsWith('/app') || pathname.startsWith('/admin') diff --git a/packages/db/package.json b/packages/db/package.json index ba055286..cb2aea16 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -42,6 +42,11 @@ "import": "./dist/src/watchdogStore.js", "require": "./dist/src/watchdogStore.js", "types": "./dist/src/watchdogStore.d.ts" + }, + "./notifications": { + "import": "./dist/src/notifications.js", + "require": "./dist/src/notifications.js", + "types": "./dist/src/notifications.d.ts" } }, "scripts": { diff --git a/packages/db/src/notifications.ts b/packages/db/src/notifications.ts new file mode 100644 index 00000000..1d6b76e0 --- /dev/null +++ b/packages/db/src/notifications.ts @@ -0,0 +1,53 @@ +import { eq, isNotNull, isNull, sql, type Column, type SQL } from 'drizzle-orm' + +// Server-side filters for the notification history table. All optional; an +// absent field means "no constraint on that dimension". App-agnostic — provider +// and middleman share the identical shape. +export type NotificationEventFilters = { + /** Partial, case-insensitive match on the event UUID. */ + search?: string + /** Exact event type (e.g. 'stake', 'service_change'). */ + type?: string + /** Read/unread by viewedAt presence. */ + read?: 'read' | 'unread' + /** Delivering channel type (e.g. 'discord') — matched against the channels JSON. */ + channel?: string +} + +// The subset of a notificationEventsTable's columns that filtering touches. +// Provider and middleman each own their own table, but both expose these +// identically, so the builder is app-agnostic — each app binds its own table. +export type NotificationEventFilterColumns = { + type: Column + viewedAt: Column + uuid: Column + channels: Column +} + +// Translates the optional filter set into a list of AND-able SQL conditions. +// `eventTypes` is the app's NOTIFICATION_EVENT_TYPES: only a KNOWN enum member +// yields a type condition — an arbitrary string (e.g. a hand-crafted request +// bypassing the UI) would otherwise reach the enum column and make Postgres +// throw "invalid input value for enum". +export function buildNotificationEventFilterConditions( + columns: NotificationEventFilterColumns, + eventTypes: readonly string[], + filters?: NotificationEventFilters, +): SQL[] { + const conds: SQL[] = [] + if (filters?.type && eventTypes.includes(filters.type)) { + conds.push(eq(columns.type, filters.type)) + } + if (filters?.read === 'unread') conds.push(isNull(columns.viewedAt)) + if (filters?.read === 'read') conds.push(isNotNull(columns.viewedAt)) + if (filters?.search) { + conds.push(sql`${columns.uuid}::text ILIKE ${'%' + filters.search + '%'}`) + } + if (filters?.channel) { + // channels is a JSON array of { type, ... }; match any element's type. + conds.push( + sql`EXISTS (SELECT 1 FROM json_array_elements(${columns.channels}) elem WHERE elem->>'type' = ${filters.channel})`, + ) + } + return conds +} \ No newline at end of file From 76438be3e15b7dee0208f2890dcad04c2f239f8e Mon Sep 17 00:00:00 2001 From: miguelc Date: Mon, 20 Jul 2026 16:01:10 -0400 Subject: [PATCH 3/4] fix(provider): gate Keys pending-state poll on active pending Gate the interval on byKey the same way middleman's SuppliersTabs gates its poll: only poll while there's pending activity, otherwise stop. byKey is the badge's own source, so the poll and badge can't diverge. --- apps/provider/src/app/admin/(internal)/keys/KeysTabs.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/provider/src/app/admin/(internal)/keys/KeysTabs.tsx b/apps/provider/src/app/admin/(internal)/keys/KeysTabs.tsx index bcd18762..e1c62f83 100644 --- a/apps/provider/src/app/admin/(internal)/keys/KeysTabs.tsx +++ b/apps/provider/src/app/admin/(internal)/keys/KeysTabs.tsx @@ -36,7 +36,7 @@ export default function KeysTabs() { const res = await GetKeysPendingState() return res.success ? res.data : { byKey: {}, pendingOperations: [] } }, - refetchInterval: 4000, + refetchInterval: (q) => (Object.keys(q.state.data?.byKey ?? {}).length > 0 ? 4000 : false), }) const pendingCount = Object.keys(pendingState?.byKey ?? {}).length From 25c9d995758ad2adf3e2a806ff93324443ca1b4f Mon Sep 17 00:00:00 2001 From: miguelc Date: Fri, 24 Jul 2026 18:41:56 -0400 Subject: [PATCH 4/4] feat: show friendly on-chain failure reasons in transaction tables(#328) -Show friendly on-chain failure reasons in transaction tables Failed transactions previously surfaced only the raw ABCI log (or a generic "Unknown error"), hard to read and duplicated in both the table cell and the detail drawer. Thread the chain's own error text end to end and map known Cosmos SDK error codes to short human-readable messages, shown through a single copyable popover. --- .../src/activities/index.ts | 13 ++- .../(lists)/transactions/table/columns.tsx | 17 ++-- .../app/(lists)/transactions/table/index.tsx | 5 +- .../src/app/detail/TransactionDetail.tsx | 17 +++- .../src/activities/index.ts | 13 ++- .../(internal)/transactions/table/columns.tsx | 12 ++- .../admin/details/TransactionDetail/index.tsx | 12 +-- packages/commons/src/utils.test.ts | 43 +++++++++- packages/commons/src/utils.ts | 81 ++++++++++++++++++- packages/pocket/src/index.ts | 17 ++-- packages/pocket/src/types.ts | 2 + packages/tx-verify/src/decide.ts | 10 ++- .../src/components/FailureReasonPopover.tsx | 73 +++++++++++++++++ 13 files changed, 263 insertions(+), 52 deletions(-) create mode 100644 packages/ui/src/components/FailureReasonPopover.tsx diff --git a/apps/middleman-workflows/src/activities/index.ts b/apps/middleman-workflows/src/activities/index.ts index 0b2c4939..709e6685 100644 --- a/apps/middleman-workflows/src/activities/index.ts +++ b/apps/middleman-workflows/src/activities/index.ts @@ -1284,7 +1284,7 @@ export const delegatorActivities = (dal: DAL, pocketRpcClient: PocketBlockchain, * height (or its execution height on the first sweep). Maps the pocket tri-state * result down to the minimal shape the pure decision logic consumes. */ - async verifyTxHash(transactionId: number): Promise> { + async verifyTxHash(transactionId: number): Promise> { const txn = await dal.transaction.getTransaction(transactionId) if (!txn?.hash) { throw new Error('verifyTxHash: tx missing hash') @@ -1296,7 +1296,7 @@ export const delegatorActivities = (dal: DAL, pocketRpcClient: PocketBlockchain, // activity boundary (the default payload converter cannot encode BigInt). return { status: 'confirmed', - data: { success: out.data.success, code: out.data.code, gasUsed: out.data.gasUsed.toString() }, + data: { success: out.data.success, code: out.data.code, gasUsed: out.data.gasUsed.toString(), rawLog: out.data.rawLog }, } }, @@ -1399,11 +1399,16 @@ export const delegatorActivities = (dal: DAL, pocketRpcClient: PocketBlockchain, const status = decision.tx === 'success' ? TransactionStatus.Success : TransactionStatus.Failure const verificationHeight = await pocketRpcClient.getHeight().catch(() => undefined) + // Failure log: prefer the chain's own error text (rawLog, present when the tx + // was found on-chain and failed) so the UI can show the real reason; the + // hardcoded summaries remain for paths where no chain text exists (absent-tx + // failure) or as suffix context (sibling-met goal). const fields: { code?: number; consumedFee?: number; verificationHeight?: number; log?: string } = { verificationHeight, log: decision.tx === 'success' ? 'verified' - : decision.effects === 'apply-success' ? 'tx failed on-chain; goal met by sibling tx' - : 'verification negative (validity bound covered, no effect)', + : decision.effects === 'apply-success' + ? `tx failed on-chain; goal met by sibling tx${decision.rawLog ? ` (${decision.rawLog})` : ''}` + : decision.rawLog || 'verification negative (validity bound covered, no effect)', } if (decision.code !== undefined) fields.code = decision.code if (decision.gasUsed !== undefined) fields.consumedFee = Number(decision.gasUsed) diff --git a/apps/middleman/src/app/app/(lists)/transactions/table/columns.tsx b/apps/middleman/src/app/app/(lists)/transactions/table/columns.tsx index bf63068c..d9a15e0f 100644 --- a/apps/middleman/src/app/app/(lists)/transactions/table/columns.tsx +++ b/apps/middleman/src/app/app/(lists)/transactions/table/columns.tsx @@ -8,6 +8,7 @@ import { Button } from '@igniter/ui/components/button' import { FilterGroup, SortOption } from '@igniter/ui/components/DataTable/index' import { amountToPokt } from '@igniter/ui/lib/utils' import { failureReasonDisplay } from '@igniter/commons/utils' +import { FailureReasonPopover } from '@igniter/ui/components/FailureReasonPopover' import { useAddItemToDetail } from '@igniter/ui/components/QuickDetails/Provider' import Amount from '@igniter/ui/components/Amount' import TransactionHash from '@igniter/ui/components/TransactionHash' @@ -29,6 +30,7 @@ export type Transaction = { providerFee?: number | null, typeProviderFee?: ProviderFee | null, log?: string | null, + code?: number | null, }; export const columns: (ColumnDef & CsvColumnDef)[] = [ @@ -73,17 +75,16 @@ export const columns: (ColumnDef & CsvColumnDef)[] = [ id: "failureReason", header: "Failure Reason", cell: ({ row }) => { - const { status, log } = row.original; - const text = failureReasonDisplay(status === TransactionStatus.Failure, log); + const { status, log, code } = row.original; + const text = failureReasonDisplay(status === TransactionStatus.Failure, log, code); if (text === null) { return -; } - return ( - - {text} - - ); + // Friendly text in the cell; click to open the full raw log (copyable) inline. + return ; }, + // CSV keeps the RAW chain log (more detail for debugging/support exports); + // the table cell shows the friendly mapped text. Intentionally no `code` arg. csvFormatterFn: (item) => failureReasonDisplay(item.status === TransactionStatus.Failure, item.log) ?? '', }, @@ -160,6 +161,8 @@ export const columns: (ColumnDef & CsvColumnDef)[] = [ provider: row.original.provider, providerFee: row.original.providerFee, typeProviderFee: row.original.typeProviderFee, + log: row.original.log, + code: row.original.code, } }) }} diff --git a/apps/middleman/src/app/app/(lists)/transactions/table/index.tsx b/apps/middleman/src/app/app/(lists)/transactions/table/index.tsx index 67cd8c67..5c1ebeea 100644 --- a/apps/middleman/src/app/app/(lists)/transactions/table/index.tsx +++ b/apps/middleman/src/app/app/(lists)/transactions/table/index.tsx @@ -64,7 +64,9 @@ export default function TransactionsTable() { consumedFee: newTx.consumedFee, provider: newTx.provider?.name || '', providerFee: newTx.providerFee, - typeProviderFee: newTx.typeProviderFee + typeProviderFee: newTx.typeProviderFee, + log: newTx.log, + code: newTx.code, } }, index) } @@ -116,6 +118,7 @@ export default function TransactionsTable() { providerFee: tx.providerFee, typeProviderFee: tx.typeProviderFee, log: tx.log, + code: tx.code, } }) || [] } diff --git a/apps/middleman/src/app/detail/TransactionDetail.tsx b/apps/middleman/src/app/detail/TransactionDetail.tsx index 2359a1e9..9fd23ecc 100644 --- a/apps/middleman/src/app/detail/TransactionDetail.tsx +++ b/apps/middleman/src/app/detail/TransactionDetail.tsx @@ -15,6 +15,7 @@ import { BaseQuickInfoTooltip } from '@igniter/ui/components/BaseQuickInfoToolti import Address from '@igniter/ui/components/Address' import { useAddItemToDetail } from '@igniter/ui/components/QuickDetails/Provider' import { MessageType } from '@igniter/commons/constants' +import { failureReasonDisplay } from '@igniter/commons/utils' import { GetNode } from '@/actions/Nodes' import { TransactionsToNodesWithDetails, @@ -83,6 +84,8 @@ export interface TransactionDetailBody { provider: string providerFee?: number | null typeProviderFee?: ProviderFee | null + log?: string | null + code?: number | null } export interface TransactionDetail { @@ -301,10 +304,15 @@ export default function TransactionDetail({ provider, providerFee, typeProviderFee, + log, + code, }: TransactionDetailBody) { const addItemToDetail = useAddItemToDetail() const [isShowingTransactionDetails, setIsShowingTransactionDetails] = useState(false); + const isFailure = status === TransactionStatus.Failure + const failureReason = failureReasonDisplay(isFailure, log, code) + let onClickAddress: ((address: string) => void) | undefined = undefined if (status === TransactionStatus.Success) { @@ -333,6 +341,8 @@ export default function TransactionDetail({ providerFee: tx.providerFee, typeProviderFee: tx.typeProviderFee, operations: JSON.parse(tx.unsignedPayload).body.messages, + log: tx.log, + code: tx.code, } }), provider: node.provider || null, @@ -354,11 +364,10 @@ export default function TransactionDetail({ label: 'Status', value: (
- {status === TransactionStatus.Failure && ( + {isFailure && failureReason && ( + + + {showHeadline &&

{friendly}

} + {full ? ( + + ) : ( + !showHeadline &&

{friendly}

+ )} + {code != null &&

Code: {code}

} +
+ + ) +} \ No newline at end of file