Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ syntax = "proto3";
package meridian.control_plane.v1;

import "buf/validate/validate.proto";
import "google/api/annotations.proto";
import "meridian/control_plane/v1/manifest.proto";
import "meridian/control_plane/v1/manifest_history_service.proto";

Expand All @@ -13,7 +14,12 @@ option go_package = "github.com/meridianhub/meridian/api/proto/meridian/control_
service ApplyManifestService {
// ApplyManifest validates and applies a manifest, returning the execution result.
// In dry_run mode, validation and planning are performed but no changes are committed.
rpc ApplyManifest(ApplyManifestRequest) returns (ApplyManifestResponse);
rpc ApplyManifest(ApplyManifestRequest) returns (ApplyManifestResponse) {
option (google.api.http) = {
post: "/v1/manifests/apply"
body: "*"
};
}
}

// ApplyManifestRequest contains the manifest to apply and apply options.
Expand Down
19 changes: 16 additions & 3 deletions api/proto/meridian/control_plane/v1/manifest_history_service.proto
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ syntax = "proto3";
package meridian.control_plane.v1;

import "buf/validate/validate.proto";
import "google/api/annotations.proto";
import "google/protobuf/timestamp.proto";
import "meridian/control_plane/v1/manifest.proto";

Expand All @@ -17,14 +18,26 @@ option go_package = "github.com/meridianhub/meridian/api/proto/meridian/control_
// with a forward-only audit trail.
service ManifestHistoryService {
// GetCurrentManifest retrieves the most recently applied manifest for the tenant.
rpc GetCurrentManifest(GetCurrentManifestRequest) returns (GetCurrentManifestResponse);
rpc GetCurrentManifest(GetCurrentManifestRequest) returns (GetCurrentManifestResponse) {
option (google.api.http) = {
get: "/v1/manifests/current"
};
}

// GetManifestVersion retrieves a specific manifest version by its version string.
rpc GetManifestVersion(GetManifestVersionRequest) returns (GetManifestVersionResponse);
rpc GetManifestVersion(GetManifestVersionRequest) returns (GetManifestVersionResponse) {
option (google.api.http) = {
get: "/v1/manifests/versions/{version}"
};
}

// ListManifestVersions returns a paginated list of manifest versions, ordered
// by applied_at descending (most recent first).
rpc ListManifestVersions(ListManifestVersionsRequest) returns (ListManifestVersionsResponse);
rpc ListManifestVersions(ListManifestVersionsRequest) returns (ListManifestVersionsResponse) {
option (google.api.http) = {
get: "/v1/manifests/versions"
};
}
}

// ========================================
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/api/clients.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { MappingService } from './gen/meridian/mapping/v1/mapping_pb'
import { ForecastingService } from './gen/meridian/forecasting/v1/forecasting_pb'
import { ManifestHistoryService } from './gen/meridian/control_plane/v1/manifest_history_service_pb'
import { ApplyManifestService } from './gen/meridian/control_plane/v1/apply_manifest_service_pb'
import { AuditService } from './gen/meridian/audit/v1/audit_service_pb'

export function createServiceClients(transport: Transport) {
return {
Expand All @@ -39,6 +40,7 @@ export function createServiceClients(transport: Transport) {
forecasting: createClient(ForecastingService, transport),
manifestHistory: createClient(ManifestHistoryService, transport),
manifestApplier: createClient(ApplyManifestService, transport),
audit: createClient(AuditService, transport),
}
}

Expand Down
161 changes: 101 additions & 60 deletions frontend/src/components/layout/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,24 +31,44 @@ interface NavItem {
feature?: string
}

const TENANT_NAV_ITEMS: NavItem[] = [
{ label: 'Dashboard', href: '/', icon: LayoutDashboard, feature: 'dashboard' },
{ label: 'Accounts', href: '/accounts', icon: Wallet, feature: 'accounts' },
{ label: 'Internal Accounts', href: '/internal-accounts', icon: Building, feature: 'internal-accounts' },
{ label: 'Payments', href: '/payments', icon: ArrowLeftRight, feature: 'payments' },
{ label: 'Transactions', href: '/transactions', icon: Activity },
{ label: 'Positions', href: '/positions', icon: TrendingUp, feature: 'positions' },
{ label: 'Ledger', href: '/ledger', icon: BookOpen, feature: 'ledger' },
{ label: 'Parties', href: '/parties', icon: Users, feature: 'parties' },
{ label: 'Reconciliation', href: '/reconciliation', icon: CheckSquare, feature: 'reconciliation' },
{ label: 'Starlark Config', href: '/starlark-config', icon: Code, feature: 'sagas' },
{ label: 'Market Data', href: '/market-data', icon: LineChart, feature: 'market-data' },
{ label: 'Forecasting', href: '/forecasting', icon: BarChart3, feature: 'forecasting' },
{ label: 'Reference Data', href: '/reference-data', icon: Database, feature: 'reference-data' },
{ label: 'Gateway Mappings', href: '/gateway-mappings', icon: Map, feature: 'mappings' },
{ label: 'Manifests', href: '/manifests', icon: FileJson, feature: 'manifests' },
{ label: 'MCP Config', href: '/mcp-config', icon: Bot, feature: 'mcp-config' },
{ label: 'Audit Log', href: '/audit-log', icon: ClipboardList, feature: 'audit' },
interface NavGroup {
label: string
items: NavItem[]
}

const TENANT_NAV_GROUPS: NavGroup[] = [
{
label: 'Operations',
items: [
{ label: 'Dashboard', href: '/', icon: LayoutDashboard, feature: 'dashboard' },
{ label: 'Accounts', href: '/accounts', icon: Wallet, feature: 'accounts' },
{ label: 'Internal Accounts', href: '/internal-accounts', icon: Building, feature: 'internal-accounts' },
{ label: 'Payments', href: '/payments', icon: ArrowLeftRight, feature: 'payments' },
{ label: 'Transactions', href: '/transactions', icon: Activity },
{ label: 'Positions', href: '/positions', icon: TrendingUp, feature: 'positions' },
{ label: 'Ledger', href: '/ledger', icon: BookOpen, feature: 'ledger' },
{ label: 'Parties', href: '/parties', icon: Users, feature: 'parties' },
{ label: 'Reconciliation', href: '/reconciliation', icon: CheckSquare, feature: 'reconciliation' },
],
},
{
label: 'Configuration',
items: [
{ label: 'Starlark Config', href: '/starlark-config', icon: Code, feature: 'sagas' },
{ label: 'Market Data', href: '/market-data', icon: LineChart, feature: 'market-data' },
{ label: 'Forecasting', href: '/forecasting', icon: BarChart3, feature: 'forecasting' },
{ label: 'Reference Data', href: '/reference-data', icon: Database, feature: 'reference-data' },
{ label: 'Gateway Mappings', href: '/gateway-mappings', icon: Map, feature: 'mappings' },
{ label: 'Manifests', href: '/manifests', icon: FileJson, feature: 'manifests' },
{ label: 'MCP Config', href: '/mcp-config', icon: Bot, feature: 'mcp-config' },
],
},
{
label: 'Admin',
items: [
{ label: 'Audit Log', href: '/audit-log', icon: ClipboardList, feature: 'audit' },
],
},
]

const PLATFORM_NAV_ITEMS: NavItem[] = [
Expand All @@ -69,11 +89,11 @@ export function Sidebar({ lens, currentPath = '/', isOpen = false, id, onClose }
const { isFeatureEnabled } = useTenantFeatures()
const { isPlatformAdmin } = useTenantContext()

const visibleTenantItems = TENANT_NAV_ITEMS.filter((item) => {
function isItemVisible(item: NavItem): boolean {
if (!item.feature) return true
if (isPlatformAdmin) return true
return isFeatureEnabled(item.feature)
})
}

return (
<>
Expand All @@ -94,53 +114,74 @@ export function Sidebar({ lens, currentPath = '/', isOpen = false, id, onClose }
)}
>
<nav aria-label="Main navigation" className="min-h-0 flex-1 overflow-y-auto py-4">
<ul role="list" className="space-y-1 px-2">
{visibleTenantItems.map((item) => {
const Icon = item.icon
const isActive = currentPath === item.href
<ul role="list" className="px-2">
{TENANT_NAV_GROUPS.map((group) => {
const visibleItems = group.items.filter(isItemVisible)
if (visibleItems.length === 0) return null

return (
<li key={item.href}>
<Link
to={item.href}
aria-current={isActive ? 'page' : undefined}
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-gray-700 text-white'
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
)}
>
<Icon className="size-4 shrink-0" />
{item.label}
</Link>
<li key={group.label}>
<div className="mb-1 mt-4 first:mt-0 px-3 text-[10px] font-semibold uppercase tracking-wider text-gray-500">
{group.label}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
claude[bot] marked this conversation as resolved.
</div>
<ul role="list" className="space-y-0.5">
{visibleItems.map((item) => {
const Icon = item.icon
const isActive = currentPath === item.href
return (
<li key={item.href}>
<Link
to={item.href}
aria-current={isActive ? 'page' : undefined}
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-gray-700 text-white'
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
)}
>
<Icon className="size-4 shrink-0" />
{item.label}
</Link>
</li>
)
})}
</ul>
</li>
)
})}

{showPlatformItems && (
<>
<li role="separator" className="my-2 border-t border-gray-700" />
{PLATFORM_NAV_ITEMS.map((item) => {
const Icon = item.icon
const isActive = currentPath === item.href
return (
<li key={item.href}>
<Link
to={item.href}
aria-current={isActive ? 'page' : undefined}
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-gray-700 text-white'
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
)}
>
<Icon className="size-4 shrink-0" />
{item.label}
</Link>
</li>
)
})}
<li role="separator" className="my-3 border-t border-gray-700" />
<li>
<div className="mb-1 px-3 text-[10px] font-semibold uppercase tracking-wider text-gray-500">
Platform
</div>
<ul role="list" className="space-y-0.5">
{PLATFORM_NAV_ITEMS.map((item) => {
const Icon = item.icon
const isActive = currentPath === item.href
return (
<li key={item.href}>
<Link
to={item.href}
aria-current={isActive ? 'page' : undefined}
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-gray-700 text-white'
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
)}
>
<Icon className="size-4 shrink-0" />
{item.label}
</Link>
</li>
)
})}
</ul>
</li>
</>
)}
</ul>
Expand Down
1 change: 1 addition & 0 deletions frontend/src/features/accounts/hooks/use-accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export function useAccountsTable() {
status: toAccountStatus(a.accountStatus),
instrumentCode: a.instrumentCode || '',
availableBalance: '',
partyId: a.orgPartyId || undefined,
createdAt: a.createdAt ?? undefined,
updatedAt: a.updatedAt ?? undefined,
}))
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/features/accounts/pages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { ColumnDef } from '@tanstack/react-table'
import { DataTable } from '@/shared/data-table'
import { StatusBadge } from '@/shared/status-badge'
import { TimeDisplay } from '@/shared/time-display'
import { EntityLink } from '@/shared'
import { Button } from '@/components/ui/button'
import { AccountStatus } from '@/api/gen/meridian/current_account/v1/current_account_pb'
import { CreateAccountDialog } from './create-account-dialog'
Expand Down Expand Up @@ -39,6 +40,13 @@ export function AccountsPage() {
accessorKey: 'instrumentCode',
header: 'Instrument',
},
{
accessorKey: 'partyId',
header: 'Party',
cell: ({ row }) => row.original.partyId
? <EntityLink type="party" id={row.original.partyId} />
: <span className="text-muted-foreground">—</span>,
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
{
accessorKey: 'createdAt',
header: 'Created',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,10 +119,11 @@ describe('OverviewTab', () => {
})
})

it('renders party status', async () => {
it('renders party status via StatusBadge', async () => {
renderTab()
await waitFor(() => {
expect(screen.getByText('PARTY_STATUS_ACTIVE')).toBeInTheDocument()
// StatusBadge replaces underscores with spaces
expect(screen.getByText('PARTY STATUS ACTIVE')).toBeInTheDocument()
})
})

Expand Down
31 changes: 29 additions & 2 deletions frontend/src/features/parties/pages/tabs/overview-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,36 @@ import * as React from 'react'
import { useQuery } from '@tanstack/react-query'
import { useClients } from '@/api/context'
import { TimeDisplay } from '@/shared/time-display'
import { StatusBadge } from '@/shared/status-badge'
import { Skeleton } from '@/components/ui/skeleton'
import { EmptyState } from '@/components/ui/empty-state'

const PARTY_TYPE_LABELS: Record<number, string> = {
0: 'UNSPECIFIED',
1: 'INDIVIDUAL',
2: 'ORGANIZATION',
3: 'GOVERNMENT',
}

const PARTY_STATUS_LABELS: Record<number, string> = {
0: 'UNSPECIFIED',
1: 'ACTIVE',
2: 'SUSPENDED',
3: 'CLOSED',
}

function formatPartyType(value: unknown): string {
if (typeof value === 'string') return value
Comment thread
claude[bot] marked this conversation as resolved.
Outdated
if (typeof value === 'number') return PARTY_TYPE_LABELS[value] ?? String(value)
return String(value ?? '')
}

function formatPartyStatus(value: unknown): string {
if (typeof value === 'string') return value
if (typeof value === 'number') return PARTY_STATUS_LABELS[value] ?? String(value)
return String(value ?? '')
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

interface OverviewTabProps {
partyId: string
}
Expand Down Expand Up @@ -38,8 +65,8 @@ export function OverviewTab({ partyId }: OverviewTabProps) {
{ label: 'Party ID', value: party.partyId },
{ label: 'Legal Name', value: party.legalName },
{ label: 'Display Name', value: party.displayName || '—' },
{ label: 'Type', value: party.partyType },
{ label: 'Status', value: party.status },
{ label: 'Type', value: formatPartyType(party.partyType) },
{ label: 'Status', value: <StatusBadge status={formatPartyStatus(party.status)} /> },
{ label: 'External Reference', value: party.externalReference || '—' },
{ label: 'Created', value: party.createdAt ? <TimeDisplay timestamp={party.createdAt} /> : '—' },
{ label: 'Updated', value: party.updatedAt ? <TimeDisplay timestamp={party.updatedAt} /> : '—' },
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/features/payments/pages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,12 @@ export function PaymentsPage({ onRowNavigate }: PaymentsPageProps = {}) {
columns={columns}
filters={FILTER_CONFIGS}
onRowClick={handleRowClick}
emptyState={
<div className="flex flex-col items-center gap-2 py-12 text-muted-foreground">
<span className="text-sm font-medium">No payments yet</span>
<span className="text-xs">Payments will appear here once initiated.</span>
</div>
}
/>
</div>
)
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/features/positions/pages/detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ function BalanceView({ log }: BalanceViewProps) {
for (const entry of entries) {
const rawAmount = entry.amount?.amount
if (rawAmount === undefined || rawAmount === null) continue
const amt = typeof rawAmount === 'string' ? BigInt(rawAmount) : rawAmount
const amt = typeof rawAmount === 'bigint' ? rawAmount : BigInt(rawAmount)
const signed = entry.direction === 'CREDIT' ? amt : -amt

provisionalTotal += signed
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/features/reconciliation/pages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,12 @@ export function ReconciliationPage() {
queryFn={queryFn}
columns={columns}
pageSize={25}
emptyState={
<div data-testid="empty-state" className="flex flex-col items-center gap-2 py-12 text-muted-foreground">
<span className="text-sm font-medium">No reconciliation runs yet</span>
<span className="text-xs">Start a reconciliation to see results here.</span>
</div>
}
filters={[
{
field: 'status',
Expand Down
Loading
Loading