Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions apps/middleman-workflows/src/activities/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<VerifyOutcome<{ success: boolean; code: number; gasUsed: string }>> {
async verifyTxHash(transactionId: number): Promise<VerifyOutcome<{ success: boolean; code: number; gasUsed: string; rawLog?: string }>> {
const txn = await dal.transaction.getTransaction(transactionId)
if (!txn?.hash) {
throw new Error('verifyTxHash: tx missing hash')
Expand All @@ -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 },
}
},

Expand Down Expand Up @@ -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)
Expand Down
8 changes: 6 additions & 2 deletions apps/middleman/src/actions/NotificationChannels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
6 changes: 3 additions & 3 deletions apps/middleman/src/app/admin/setup/providersForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,10 @@ const ProvidersForm: React.FC<ProvidersFormProps> = ({
name="providers"
render={() => (
<FormItem>
<div className="rounded-md border">
<div className="rounded-md border max-h-[60vh] overflow-y-auto">
<table className="w-full">
<thead>
<tr className="border-b text-left text-sm text-muted-foreground">
<thead className="sticky top-0 z-10">
<tr className="border-b text-left text-sm text-muted-foreground bg-background">
<th className="p-3 w-10"></th>
<th className="p-3">Name</th>
<th className="p-3">Identity</th>
Expand Down
59 changes: 37 additions & 22 deletions apps/middleman/src/app/app/(lists)/suppliers/ActivitiesSection.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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
Expand Down Expand Up @@ -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 (
<div className="flex flex-col gap-3">
{/* Section heading — matches RecentChanges / Services Overview style.
Badge shows PENDING count only; settled-linger rows don't inflate it. */}
<h3 className="text-lg font-semibold">
In progress
{count > 0 && (
<span className="text-sm font-normal text-text-tertiary ml-2">· {count}</span>
)}
</h3>

{/* 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. */}
<Table containerClassName="max-h-[260px]">
<TableHeader>
{rows.length > 0 && (
<Input
placeholder="Search by supplier, owner, or provider…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="max-w-xs"
/>
)}
<Table containerClassName="max-h-[420px]">
{/* 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. */}
<TableHeader className="[&_th]:sticky [&_th]:top-0 [&_th]:z-10 [&_th]:bg-(--bg-root) [&_th]:border-b [&_th]:border-border-primary">
<TableRow className="bg-transparent">
{/* Column order: Tx Hash · Submitted · Supplier · Owner · Provider · Amount · Op Funds · Status */}
<TableHead className={clsx(HEAD_CLASS, TX_HASH_COL_CLASS)}>Tx Hash</TableHead>
Expand All @@ -103,7 +108,17 @@ export default function ActivitiesSection() {
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => {
{filteredRows.length === 0 && (
<TableRow>
<TableCell
colSpan={8}
className="h-24 text-center text-text-secondary"
>
{search.trim() ? 'No matches.' : 'No activity yet.'}
</TableCell>
</TableRow>
)}
{filteredRows.map((row) => {
const statusLabel = getStatusLabel(row)
const statusClass = getStatusClass(row)
const submittedStr = row.createdAt
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
68 changes: 68 additions & 0 deletions apps/middleman/src/app/app/(lists)/suppliers/SuppliersTabs.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Tabs value={tab} onValueChange={onTabChange}>
<TabsList>
<TabsTrigger value="suppliers">Suppliers</TabsTrigger>
<TabsTrigger value="activity">
Activity
<TabsBadge count={pendingCount} variant="warning" />
</TabsTrigger>
<TabsTrigger value="overview">Overview</TabsTrigger>
</TabsList>
<TabsContent value="suppliers">
<NodesTable />
</TabsContent>
<TabsContent value="activity">
<ActivitiesSection />
</TabsContent>
<TabsContent value="overview">
<ChainOverview />
</TabsContent>
</Tabs>
)
}
10 changes: 4 additions & 6 deletions apps/middleman/src/app/app/(lists)/suppliers/page.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -42,12 +40,12 @@ export default async function Page() {
/>
<PageContent>
<ProviderStats />
<ChainOverview />
<Suspense>
<RecentChanges />
</Suspense>
<ActivitiesSection />
<NodesTable />
<Suspense>
<SuppliersTabs />
</Suspense>
</PageContent>
</>
);
Expand Down
23 changes: 23 additions & 0 deletions apps/middleman/src/app/app/(lists)/transactions/table/columns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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<Transaction> & CsvColumnDef<Transaction>)[] = [
Expand Down Expand Up @@ -67,6 +71,23 @@ export const columns: (ColumnDef<Transaction> & CsvColumnDef<Transaction>)[] = [
},
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 <span className="text-muted-foreground">-</span>;
}
// Friendly text in the cell; click to open the full raw log (copyable) inline.
return <FailureReasonPopover friendly={text} full={log?.trim() || ''} code={code} />;
},
// 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",
Expand Down Expand Up @@ -140,6 +161,8 @@ export const columns: (ColumnDef<Transaction> & CsvColumnDef<Transaction>)[] = [
provider: row.original.provider,
providerFee: row.original.providerFee,
typeProviderFee: row.original.typeProviderFee,
log: row.original.log,
code: row.original.code,
}
})
}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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,
}
}) || []
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -27,13 +38,15 @@ export function NotificationHistorySection() {

return (
<NotificationHistory
eventTypeOptions={EVENT_TYPE_OPTIONS}
channelOptions={CHANNEL_OPTIONS}
onOpenEvent={openEvent}
labelFor={(type) => EVENT_LABELS[type] ?? 'Notification'}
summaryFor={(type, metadata) =>
describeEvent(type, (metadata ?? {}) as Record<string, unknown>)
}
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
}}
Expand Down
Loading
Loading