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/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..6fcfde9b --- /dev/null +++ b/apps/middleman/src/app/app/(lists)/suppliers/SuppliersTabs.tsx @@ -0,0 +1,68 @@ +'use client' + +import * as React from 'react' +import { usePathname, useRouter, useSearchParams } from 'next/navigation' +import { useQuery } from '@tanstack/react-query' + +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' + +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' + + // 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) + 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..d9a15e0f 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,8 @@ 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 { 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' @@ -27,6 +29,8 @@ export type Transaction = { provider: string, providerFee?: number | null, typeProviderFee?: ProviderFee | null, + log?: string | null, + code?: number | null, }; export const columns: (ColumnDef & CsvColumnDef)[] = [ @@ -67,6 +71,23 @@ export const columns: (ColumnDef & CsvColumnDef)[] = [ }, csvFormatterFn: ({status}) => status.charAt(0).toUpperCase() + status.slice(1), }, + { + id: "failureReason", + header: "Failure Reason", + cell: ({ row }) => { + const { status, log, code } = row.original; + const text = failureReasonDisplay(status === TransactionStatus.Failure, log, code); + if (text === null) { + return -; + } + // 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) ?? '', + }, { accessorKey: "hash", header: "Tx Hash", @@ -140,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 2d6f9645..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) } @@ -115,6 +117,8 @@ export default function TransactionsTable() { provider: tx.provider?.name || 'Height Pending', providerFee: tx.providerFee, typeProviderFee: tx.typeProviderFee, + log: tx.log, + code: tx.code, } }) || [] } 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..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"; @@ -80,6 +81,12 @@ 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. + // Same gate as SidebarTriggerGate via the shared isInternalPath helper. + if (!isInternalPath(pathname)) 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..0f29b293 --- /dev/null +++ b/apps/middleman/src/app/components/SidebarTriggerGate.tsx @@ -0,0 +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 — same gate as Sidebar.tsx via the +// shared isInternalPath helper. +export default function SidebarTriggerGate() { + const pathname = usePathname(); + if (!isInternalPath(pathname)) return null; + + return ; +} \ No newline at end of file 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 && (