mirror of
https://github.com/luxfi/mpc.git
synced 2026-09-24 00:26:24 +00:00
feat(ui): embedded MPC admin dashboard — @hanzo/gui + go:embed
Vite + React SPA with 9 pages (Dashboard, Wallets, Vaults, Policies, Settlements, Bridge, Payments, Audit, Settings) served at /_/mpc/ via go:embed. Replaces the separate Next.js dashboard pod — the UI now ships inside the mpcd binary. Uses @tanstack/react-query, wouter hash router, dark theme, inline styles. Build output: 272KB.
This commit is contained in:
+6
-1
@@ -32,11 +32,16 @@ logs/
|
||||
# Docker build artifacts
|
||||
.docker-build/
|
||||
|
||||
# Dashboard build
|
||||
# Dashboard build (legacy Next.js)
|
||||
dashboard/node_modules/
|
||||
dashboard/.next/
|
||||
dashboard/package-lock.json
|
||||
|
||||
# Embedded UI build
|
||||
ui/node_modules/
|
||||
ui/dist/
|
||||
ui/pnpm-lock.yaml
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
|
||||
@@ -29,9 +29,12 @@ import (
|
||||
"github.com/urfave/cli/v3"
|
||||
|
||||
"github.com/hanzoai/base"
|
||||
"github.com/hanzoai/base/apis"
|
||||
"github.com/hanzoai/base/core"
|
||||
"github.com/luxfi/hsm"
|
||||
|
||||
uimpc "github.com/luxfi/mpc/ui"
|
||||
|
||||
mpcapi "github.com/luxfi/mpc/pkg/api"
|
||||
"github.com/luxfi/mpc/pkg/backup"
|
||||
"github.com/luxfi/mpc/pkg/db"
|
||||
@@ -712,6 +715,9 @@ func runNodeConsensus(ctx context.Context, c *cli.Command) error {
|
||||
os.Args = []string{"mpcd", "serve", "--http", apiListenAddr}
|
||||
baseApp := base.New()
|
||||
baseApp.OnServe().BindFunc(func(e *core.ServeEvent) error {
|
||||
// Embedded admin UI at /_/mpc/
|
||||
e.Router.GET("/_/mpc/{path...}", apis.Static(uimpc.DistDirFS(), true))
|
||||
|
||||
e.Router.Any("/{path...}", func(re *core.RequestEvent) error {
|
||||
apiServer.Handler().ServeHTTP(re.Response, re.Request)
|
||||
return nil
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
FROM node:20-alpine AS deps
|
||||
WORKDIR /app
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ARG NEXT_PUBLIC_API_URL=https://mpc.lux.network/api/v1
|
||||
ARG NEXT_PUBLIC_IAM_URL=https://lux.id
|
||||
ARG NEXT_PUBLIC_IAM_CLIENT_ID=lux-mpc
|
||||
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
||||
ENV NEXT_PUBLIC_IAM_URL=$NEXT_PUBLIC_IAM_URL
|
||||
ENV NEXT_PUBLIC_IAM_CLIENT_ID=$NEXT_PUBLIC_IAM_CLIENT_ID
|
||||
RUN pnpm build
|
||||
|
||||
FROM node:20-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
CMD ["node", "server.js"]
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
// Package ui embeds the MPC admin dashboard built with Vite + React.
|
||||
//
|
||||
// The Go HTTP handler serves the admin UI at the /_/mpc/ path.
|
||||
// Run `pnpm --dir ui build` in this directory to produce dist/; the
|
||||
// //go:embed directive below picks up the resulting static assets.
|
||||
package ui
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
//go:embed all:dist
|
||||
var distFS embed.FS
|
||||
|
||||
// DistDirFS returns a rooted fs.FS pointing at dist/ so it can be passed
|
||||
// directly to the Base router's Static handler.
|
||||
func DistDirFS() fs.FS {
|
||||
sub, err := fs.Sub(distFS, "dist")
|
||||
if err != nil {
|
||||
panic("mpc-ui: dist/ missing — run `pnpm --dir ui build` first: " + err.Error())
|
||||
}
|
||||
return sub
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>MPC Admin</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body, #root { height: 100%; width: 100%; background: #000; color: #e5e5e5; font-family: system-ui, -apple-system, sans-serif; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@luxfi/mpc-ui",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"wouter": "^3.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Router, Route, Switch, Link, useLocation } from 'wouter'
|
||||
import { useHashLocation } from 'wouter/use-hash-location'
|
||||
import { Dashboard } from './pages/Dashboard'
|
||||
import { Wallets } from './pages/Wallets'
|
||||
import { Vaults } from './pages/Vaults'
|
||||
import { Policies } from './pages/Policies'
|
||||
import { Settlements } from './pages/Settlements'
|
||||
import { Bridge } from './pages/Bridge'
|
||||
import { Payments } from './pages/Payments'
|
||||
import { Audit } from './pages/Audit'
|
||||
import { Settings } from './pages/Settings'
|
||||
|
||||
const navItems = [
|
||||
{ href: '/', label: 'Dashboard' },
|
||||
{ href: '/wallets', label: 'Wallets' },
|
||||
{ href: '/vaults', label: 'Vaults' },
|
||||
{ href: '/policies', label: 'Policies' },
|
||||
{ href: '/settlements', label: 'Settlements' },
|
||||
{ href: '/bridge', label: 'Bridge' },
|
||||
{ href: '/payments', label: 'Payments' },
|
||||
{ href: '/audit', label: 'Audit' },
|
||||
{ href: '/settings', label: 'Settings' },
|
||||
] as const
|
||||
|
||||
function NavLink({ href, label }: { href: string; label: string }) {
|
||||
const [location] = useLocation()
|
||||
const active = href === '/' ? location === '/' : location.startsWith(href)
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
style={{
|
||||
display: 'block',
|
||||
padding: '6px 12px',
|
||||
borderRadius: 6,
|
||||
color: active ? '#fff' : '#a3a3a3',
|
||||
background: active ? '#262626' : 'transparent',
|
||||
textDecoration: 'none',
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<Router hook={useHashLocation}>
|
||||
<div style={{ display: 'flex', height: '100vh', background: '#000', color: '#e5e5e5' }}>
|
||||
<aside
|
||||
style={{
|
||||
width: 200,
|
||||
borderRight: '1px solid #262626',
|
||||
padding: 16,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, fontSize: 16, marginBottom: 16, color: '#fff' }}>
|
||||
MPC Admin
|
||||
</div>
|
||||
<nav style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{navItems.map((n) => (
|
||||
<NavLink key={n.href} href={n.href} label={n.label} />
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
<main style={{ flex: 1, overflow: 'auto', padding: 24 }}>
|
||||
<Switch>
|
||||
<Route path="/" component={Dashboard} />
|
||||
<Route path="/wallets" component={Wallets} />
|
||||
<Route path="/vaults" component={Vaults} />
|
||||
<Route path="/policies" component={Policies} />
|
||||
<Route path="/settlements" component={Settlements} />
|
||||
<Route path="/bridge" component={Bridge} />
|
||||
<Route path="/payments" component={Payments} />
|
||||
<Route path="/audit" component={Audit} />
|
||||
<Route path="/settings" component={Settings} />
|
||||
<Route>
|
||||
<div style={{ color: '#737373' }}>Not found</div>
|
||||
</Route>
|
||||
</Switch>
|
||||
</main>
|
||||
</div>
|
||||
</Router>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
const colors: Record<string, { bg: string; fg: string }> = {
|
||||
running: { bg: '#22c55e20', fg: '#22c55e' },
|
||||
active: { bg: '#22c55e20', fg: '#22c55e' },
|
||||
enabled: { bg: '#22c55e20', fg: '#22c55e' },
|
||||
finalized: { bg: '#22c55e20', fg: '#22c55e' },
|
||||
verified: { bg: '#22c55e20', fg: '#22c55e' },
|
||||
signed: { bg: '#22c55e20', fg: '#22c55e' },
|
||||
paid: { bg: '#22c55e20', fg: '#22c55e' },
|
||||
pending: { bg: '#f59e0b20', fg: '#f59e0b' },
|
||||
pending_approval: { bg: '#f59e0b20', fg: '#f59e0b' },
|
||||
pending_sign: { bg: '#f59e0b20', fg: '#f59e0b' },
|
||||
hsm_signing: { bg: '#f59e0b20', fg: '#f59e0b' },
|
||||
confirming: { bg: '#f59e0b20', fg: '#f59e0b' },
|
||||
broadcast: { bg: '#3b82f620', fg: '#3b82f6' },
|
||||
settling: { bg: '#3b82f620', fg: '#3b82f6' },
|
||||
signing: { bg: '#3b82f620', fg: '#3b82f6' },
|
||||
degraded: { bg: '#f9731620', fg: '#f97316' },
|
||||
resharing: { bg: '#f9731620', fg: '#f97316' },
|
||||
failed: { bg: '#ef444420', fg: '#ef4444' },
|
||||
rejected: { bg: '#ef444420', fg: '#ef4444' },
|
||||
disabled: { bg: '#52525220', fg: '#737373' },
|
||||
archived: { bg: '#52525220', fg: '#737373' },
|
||||
expired: { bg: '#52525220', fg: '#737373' },
|
||||
cancelled: { bg: '#52525220', fg: '#737373' },
|
||||
}
|
||||
|
||||
const fallback = { bg: '#26262620', fg: '#a3a3a3' }
|
||||
|
||||
export function StatusBadge({ status }: { status: string }) {
|
||||
const c = colors[status] ?? fallback
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '2px 8px',
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
background: c.bg,
|
||||
color: c.fg,
|
||||
}}
|
||||
>
|
||||
{status.replace(/_/g, ' ')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { api } from '../lib/api'
|
||||
|
||||
export function useStatus() {
|
||||
return useQuery({ queryKey: ['mpc', 'status'], queryFn: api.getStatus })
|
||||
}
|
||||
|
||||
export function useInfo() {
|
||||
return useQuery({ queryKey: ['mpc', 'info'], queryFn: api.getInfo })
|
||||
}
|
||||
|
||||
export function useVaults() {
|
||||
return useQuery({ queryKey: ['mpc', 'vaults'], queryFn: api.listVaults })
|
||||
}
|
||||
|
||||
export function useWallets(vaultId: string) {
|
||||
return useQuery({
|
||||
queryKey: ['mpc', 'wallets', vaultId],
|
||||
queryFn: () => api.listWallets(vaultId),
|
||||
enabled: !!vaultId,
|
||||
})
|
||||
}
|
||||
|
||||
export function useTransactions(filters?: { status?: string; chain?: string }) {
|
||||
return useQuery({
|
||||
queryKey: ['mpc', 'transactions', filters],
|
||||
queryFn: () => api.listTransactions(filters),
|
||||
})
|
||||
}
|
||||
|
||||
export function usePolicies() {
|
||||
return useQuery({ queryKey: ['mpc', 'policies'], queryFn: api.listPolicies })
|
||||
}
|
||||
|
||||
export function useSettlements(filters?: { status?: string }) {
|
||||
return useQuery({
|
||||
queryKey: ['mpc', 'settlements', filters],
|
||||
queryFn: () => api.listSettlements(filters),
|
||||
})
|
||||
}
|
||||
|
||||
export function useBridgeConfig() {
|
||||
return useQuery({ queryKey: ['mpc', 'bridge', 'config'], queryFn: api.getBridgeConfig })
|
||||
}
|
||||
|
||||
export function useBridgeNetworks() {
|
||||
return useQuery({ queryKey: ['mpc', 'bridge', 'networks'], queryFn: api.listBridgeNetworks })
|
||||
}
|
||||
|
||||
export function usePaymentRequests() {
|
||||
return useQuery({ queryKey: ['mpc', 'payments'], queryFn: api.listPaymentRequests })
|
||||
}
|
||||
|
||||
export function useAudit() {
|
||||
return useQuery({ queryKey: ['mpc', 'audit'], queryFn: api.listAudit })
|
||||
}
|
||||
|
||||
export function useWalletBackups(walletId: string) {
|
||||
return useQuery({
|
||||
queryKey: ['mpc', 'backup', walletId],
|
||||
queryFn: () => api.getWalletBackups(walletId),
|
||||
enabled: !!walletId,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
// Typed API client for MPC backend — all paths relative to /v1/mpc
|
||||
|
||||
const BASE = '/v1/mpc'
|
||||
|
||||
export class APIError extends Error {
|
||||
constructor(public status: number, public body: { error: string }) {
|
||||
super(body.error)
|
||||
this.name = 'APIError'
|
||||
}
|
||||
}
|
||||
|
||||
function token(): string | null {
|
||||
return localStorage.getItem('mpc_token')
|
||||
}
|
||||
|
||||
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
const t = token()
|
||||
if (t) headers['Authorization'] = `Bearer ${t}`
|
||||
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
|
||||
if (res.status === 204) return undefined as T
|
||||
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new APIError(res.status, data)
|
||||
return data as T
|
||||
}
|
||||
|
||||
function get<T>(path: string): Promise<T> { return request<T>('GET', path) }
|
||||
function post<T>(path: string, body?: unknown): Promise<T> { return request<T>('POST', path, body) }
|
||||
function patch<T>(path: string, body?: unknown): Promise<T> { return request<T>('PATCH', path, body) }
|
||||
function del<T>(path: string): Promise<T> { return request<T>('DELETE', path) }
|
||||
|
||||
// --- Types (matching Go backend models) ---
|
||||
|
||||
export interface ClusterStatus {
|
||||
node_id: string
|
||||
mode: string
|
||||
expected_peers: number
|
||||
connected_peers: number
|
||||
ready: boolean
|
||||
threshold: number
|
||||
version: string
|
||||
}
|
||||
|
||||
export interface InfoResponse {
|
||||
name: string
|
||||
version: string
|
||||
supported_chains: string[]
|
||||
key_types: string[]
|
||||
protocols: string[]
|
||||
}
|
||||
|
||||
export interface Vault {
|
||||
id: string
|
||||
org_id: string
|
||||
name: string
|
||||
description?: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface Wallet {
|
||||
id: string
|
||||
vault_id: string
|
||||
org_id: string
|
||||
wallet_id: string
|
||||
name?: string | null
|
||||
key_type: string
|
||||
protocol?: string
|
||||
ecdsa_pubkey?: string | null
|
||||
eddsa_pubkey?: string | null
|
||||
eth_address?: string | null
|
||||
btc_address?: string | null
|
||||
sol_address?: string | null
|
||||
threshold: number
|
||||
participants: string[]
|
||||
version: number
|
||||
status: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface Transaction {
|
||||
id: string
|
||||
org_id: string
|
||||
wallet_id?: string | null
|
||||
tx_type: string
|
||||
chain: string
|
||||
to_address?: string | null
|
||||
amount?: string | null
|
||||
token?: string | null
|
||||
tx_hash?: string | null
|
||||
status: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface Policy {
|
||||
id: string
|
||||
org_id: string
|
||||
vault_id?: string | null
|
||||
name: string
|
||||
priority: number
|
||||
action: string
|
||||
conditions: Record<string, unknown>
|
||||
required_approvers: number
|
||||
approver_roles: string[]
|
||||
enabled: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface Settlement {
|
||||
id: string
|
||||
org_id: string
|
||||
intent_id: string
|
||||
match_id?: string | null
|
||||
settlement_tx_hash?: string | null
|
||||
hsm_signatures?: { signer_id: string; key_id: string; signature: string; provider: string; signed_at: string }[]
|
||||
transfer_agency_verified: boolean
|
||||
status: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface PaymentRequest {
|
||||
id: string
|
||||
org_id: string
|
||||
merchant_name?: string | null
|
||||
recipient_address: string
|
||||
chain: string
|
||||
token?: string | null
|
||||
amount: string
|
||||
status: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface AuditEntry {
|
||||
id: number
|
||||
org_id: string
|
||||
user_id?: string | null
|
||||
action: string
|
||||
resource_type?: string | null
|
||||
resource_id?: string | null
|
||||
ip_address?: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface WalletBackup {
|
||||
id: string
|
||||
wallet_id: string
|
||||
backup_id: string
|
||||
threshold: number
|
||||
total_shards: number
|
||||
shards?: { index: number; destination: string; created_at: string; verified_at?: string | null }[]
|
||||
status: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface BridgeNetwork {
|
||||
chain: string
|
||||
name: string
|
||||
type: string
|
||||
deposit: boolean
|
||||
withdrawal: boolean
|
||||
}
|
||||
|
||||
export interface BridgeConfig {
|
||||
signingWalletId?: string
|
||||
feeRateBps: number
|
||||
depositsEnabled: boolean
|
||||
withdrawalsEnabled: boolean
|
||||
}
|
||||
|
||||
// --- API methods ---
|
||||
|
||||
export const api = {
|
||||
// Status
|
||||
getStatus: () => get<ClusterStatus>('/status'),
|
||||
getInfo: () => get<InfoResponse>('/info'),
|
||||
|
||||
// Vaults
|
||||
listVaults: () => get<Vault[]>('/vaults'),
|
||||
createVault: (req: { name: string; description?: string }) => post<Vault>('/vaults', req),
|
||||
deleteVault: (id: string) => del(`/vaults/${id}`),
|
||||
|
||||
// Wallets
|
||||
listWallets: (vaultId: string) => get<Wallet[]>(`/vaults/${vaultId}/wallets`),
|
||||
createWallet: (vaultId: string, req?: { name?: string; key_type?: string; protocol?: string }) =>
|
||||
post<Wallet>(`/vaults/${vaultId}/wallets`, req),
|
||||
getWalletBackups: (walletId: string) => get<WalletBackup[]>(`/wallets/${walletId}/backup`),
|
||||
|
||||
// Transactions
|
||||
listTransactions: (filters?: { status?: string; chain?: string }) => {
|
||||
const params = new URLSearchParams()
|
||||
if (filters?.status) params.set('status', filters.status)
|
||||
if (filters?.chain) params.set('chain', filters.chain)
|
||||
const qs = params.toString()
|
||||
return get<Transaction[]>(`/transactions${qs ? `?${qs}` : ''}`)
|
||||
},
|
||||
|
||||
// Policies
|
||||
listPolicies: () => get<Policy[]>('/policies'),
|
||||
createPolicy: (req: {
|
||||
name: string
|
||||
action: string
|
||||
priority: number
|
||||
conditions: Record<string, unknown>
|
||||
required_approvers: number
|
||||
approver_roles: string[]
|
||||
}) => post<Policy>('/policies', req),
|
||||
updatePolicy: (id: string, req: { enabled?: boolean }) => patch<Policy>(`/policies/${id}`, req),
|
||||
deletePolicy: (id: string) => del(`/policies/${id}`),
|
||||
|
||||
// Settlements
|
||||
listSettlements: (filters?: { status?: string }) => {
|
||||
const params = new URLSearchParams()
|
||||
if (filters?.status) params.set('status', filters.status)
|
||||
const qs = params.toString()
|
||||
return get<Settlement[]>(`/settlements${qs ? `?${qs}` : ''}`)
|
||||
},
|
||||
|
||||
// Bridge
|
||||
getBridgeConfig: () => get<BridgeConfig>('/bridge/config'),
|
||||
listBridgeNetworks: () => get<BridgeNetwork[]>('/bridge/networks'),
|
||||
|
||||
// Payments
|
||||
listPaymentRequests: () => get<PaymentRequest[]>('/payment-requests'),
|
||||
|
||||
// Audit
|
||||
listAudit: () => get<AuditEntry[]>('/audit'),
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import React from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { App } from './App'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const root = document.getElementById('root')
|
||||
if (!root) throw new Error('root element missing')
|
||||
|
||||
createRoot(root).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useAudit } from '../hooks/use-mpc'
|
||||
|
||||
export function Audit() {
|
||||
const { data: entries, error } = useAudit()
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
<div>
|
||||
<h1 style={{ fontSize: 20, fontWeight: 600, color: '#fff' }}>Audit Log</h1>
|
||||
<p style={{ fontSize: 13, color: '#737373', marginTop: 4 }}>
|
||||
Chronological signing event log.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && <div style={{ color: '#ef4444', fontSize: 13 }}>{(error as Error).message}</div>}
|
||||
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid #262626', color: '#737373', textAlign: 'left' }}>
|
||||
<th style={{ padding: '8px 12px' }}>Action</th>
|
||||
<th style={{ padding: '8px 12px' }}>Resource</th>
|
||||
<th style={{ padding: '8px 12px' }}>User</th>
|
||||
<th style={{ padding: '8px 12px' }}>IP</th>
|
||||
<th style={{ padding: '8px 12px' }}>Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries?.map((entry) => (
|
||||
<tr key={entry.id} style={{ borderBottom: '1px solid #171717' }}>
|
||||
<td style={{ padding: '8px 12px', fontWeight: 500 }}>{entry.action}</td>
|
||||
<td style={{ padding: '8px 12px', color: '#a3a3a3' }}>
|
||||
{entry.resource_type ? (
|
||||
<>
|
||||
{entry.resource_type}
|
||||
{entry.resource_id && (
|
||||
<span style={{ fontFamily: 'monospace', fontSize: 11, marginLeft: 4 }}>
|
||||
{entry.resource_id.slice(0, 12)}...
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : '--'}
|
||||
</td>
|
||||
<td style={{ padding: '8px 12px', fontFamily: 'monospace', fontSize: 11, color: '#737373' }}>
|
||||
{entry.user_id || '--'}
|
||||
</td>
|
||||
<td style={{ padding: '8px 12px', fontFamily: 'monospace', fontSize: 11, color: '#737373' }}>
|
||||
{entry.ip_address || '--'}
|
||||
</td>
|
||||
<td style={{ padding: '8px 12px', color: '#737373' }}>
|
||||
{new Date(entry.created_at).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{(!entries || entries.length === 0) && (
|
||||
<tr>
|
||||
<td colSpan={5} style={{ padding: '16px 12px', color: '#525252' }}>
|
||||
No audit entries
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useBridgeConfig, useBridgeNetworks } from '../hooks/use-mpc'
|
||||
|
||||
const card: React.CSSProperties = {
|
||||
background: '#0a0a0a',
|
||||
border: '1px solid #262626',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
}
|
||||
|
||||
const label: React.CSSProperties = {
|
||||
fontSize: 12,
|
||||
color: '#737373',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}
|
||||
|
||||
export function Bridge() {
|
||||
const { data: config } = useBridgeConfig()
|
||||
const { data: networks } = useBridgeNetworks()
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
<div>
|
||||
<h1 style={{ fontSize: 20, fontWeight: 600, color: '#fff' }}>Bridge</h1>
|
||||
<p style={{ fontSize: 13, color: '#737373', marginTop: 4 }}>
|
||||
Cross-chain bridge status and transfers.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Config cards */}
|
||||
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
|
||||
<div style={card}>
|
||||
<div style={label}>Signing Wallet</div>
|
||||
<div style={{ marginTop: 4, fontFamily: 'monospace', fontSize: 13 }}>
|
||||
{config?.signingWalletId ? config.signingWalletId.slice(0, 12) + '...' : 'Not configured'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={card}>
|
||||
<div style={label}>Fee Rate</div>
|
||||
<div style={{ marginTop: 4, fontSize: 18, fontWeight: 600, color: '#fff' }}>
|
||||
{config ? (config.feeRateBps / 100).toFixed(2) : '--'}%
|
||||
</div>
|
||||
</div>
|
||||
<div style={card}>
|
||||
<div style={label}>Deposits</div>
|
||||
<div style={{ marginTop: 4, color: config?.depositsEnabled ? '#22c55e' : '#ef4444' }}>
|
||||
{config?.depositsEnabled ? 'Enabled' : 'Disabled'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={card}>
|
||||
<div style={label}>Withdrawals</div>
|
||||
<div style={{ marginTop: 4, color: config?.withdrawalsEnabled ? '#22c55e' : '#ef4444' }}>
|
||||
{config?.withdrawalsEnabled ? 'Enabled' : 'Disabled'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Networks */}
|
||||
<div>
|
||||
<div style={label}>Supported Networks</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, marginTop: 8 }}>
|
||||
{networks?.map((net) => (
|
||||
<div
|
||||
key={net.chain}
|
||||
style={{
|
||||
...card,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 500 }}>{net.name}</span>
|
||||
<span style={{
|
||||
background: '#262626',
|
||||
borderRadius: 4,
|
||||
padding: '2px 8px',
|
||||
fontSize: 11,
|
||||
fontFamily: 'monospace',
|
||||
color: '#737373',
|
||||
}}>
|
||||
{net.type}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 16, fontSize: 12 }}>
|
||||
<span style={{ color: net.deposit ? '#22c55e' : '#525252' }}>
|
||||
Deposit: {net.deposit ? 'ON' : 'OFF'}
|
||||
</span>
|
||||
<span style={{ color: net.withdrawal ? '#22c55e' : '#525252' }}>
|
||||
Withdrawal: {net.withdrawal ? 'ON' : 'OFF'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{(!networks || networks.length === 0) && (
|
||||
<div style={{ color: '#525252', fontSize: 13 }}>No networks configured</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useStatus, useVaults, useTransactions } from '../hooks/use-mpc'
|
||||
import { StatusBadge } from '../components/StatusBadge'
|
||||
|
||||
const card: React.CSSProperties = {
|
||||
background: '#0a0a0a',
|
||||
border: '1px solid #262626',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
minWidth: 140,
|
||||
}
|
||||
|
||||
const label: React.CSSProperties = {
|
||||
fontSize: 12,
|
||||
color: '#737373',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}
|
||||
|
||||
const value: React.CSSProperties = {
|
||||
fontSize: 28,
|
||||
fontWeight: 600,
|
||||
color: '#fff',
|
||||
marginTop: 4,
|
||||
fontFamily: 'monospace',
|
||||
}
|
||||
|
||||
export function Dashboard() {
|
||||
const { data: cluster } = useStatus()
|
||||
const { data: vaults } = useVaults()
|
||||
const { data: txList } = useTransactions()
|
||||
|
||||
const pending = txList?.filter((t) => t.status === 'pending' || t.status === 'pending_approval').length ?? 0
|
||||
const recent = txList?.slice(0, 8) ?? []
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between' }}>
|
||||
<h1 style={{ fontSize: 20, fontWeight: 600, color: '#fff' }}>Dashboard</h1>
|
||||
{cluster && (
|
||||
<StatusBadge status={cluster.ready ? 'running' : 'degraded'} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stat cards */}
|
||||
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
|
||||
<div style={card}>
|
||||
<div style={label}>Vaults</div>
|
||||
<div style={value}>{vaults?.length ?? '--'}</div>
|
||||
</div>
|
||||
<div style={card}>
|
||||
<div style={label}>Pending TX</div>
|
||||
<div style={value}>{pending}</div>
|
||||
</div>
|
||||
<div style={card}>
|
||||
<div style={label}>Peers</div>
|
||||
<div style={value}>
|
||||
{cluster ? `${cluster.connected_peers}/${cluster.expected_peers}` : '--'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={card}>
|
||||
<div style={label}>Threshold</div>
|
||||
<div style={value}>
|
||||
{cluster ? `${cluster.threshold}-of-${cluster.expected_peers}` : '--'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={card}>
|
||||
<div style={label}>Version</div>
|
||||
<div style={{ ...value, fontSize: 16 }}>
|
||||
{cluster?.version ?? '--'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cluster info */}
|
||||
{cluster && (
|
||||
<div style={card}>
|
||||
<div style={label}>Cluster Node</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 8 }}>
|
||||
<span style={{
|
||||
width: 8, height: 8, borderRadius: '50%',
|
||||
background: cluster.ready ? '#22c55e' : '#f59e0b',
|
||||
}} />
|
||||
<span style={{ fontFamily: 'monospace', fontSize: 14, color: '#fff' }}>{cluster.node_id}</span>
|
||||
<span style={{ fontSize: 12, color: '#737373', marginLeft: 'auto' }}>{cluster.mode}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent transactions */}
|
||||
<div>
|
||||
<div style={label}>Recent Transactions</div>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: 8, fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid #262626', color: '#737373', textAlign: 'left' }}>
|
||||
<th style={{ padding: '8px 12px' }}>Type</th>
|
||||
<th style={{ padding: '8px 12px' }}>Chain</th>
|
||||
<th style={{ padding: '8px 12px' }}>Amount</th>
|
||||
<th style={{ padding: '8px 12px' }}>Status</th>
|
||||
<th style={{ padding: '8px 12px' }}>Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recent.map((tx) => (
|
||||
<tr key={tx.id} style={{ borderBottom: '1px solid #171717' }}>
|
||||
<td style={{ padding: '8px 12px', textTransform: 'capitalize' }}>{tx.tx_type}</td>
|
||||
<td style={{ padding: '8px 12px' }}>{tx.chain}</td>
|
||||
<td style={{ padding: '8px 12px', fontFamily: 'monospace' }}>{tx.amount ?? '--'}</td>
|
||||
<td style={{ padding: '8px 12px' }}><StatusBadge status={tx.status} /></td>
|
||||
<td style={{ padding: '8px 12px', color: '#737373' }}>
|
||||
{new Date(tx.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{recent.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} style={{ padding: '16px 12px', color: '#525252' }}>
|
||||
No recent transactions
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { usePaymentRequests } from '../hooks/use-mpc'
|
||||
import { StatusBadge } from '../components/StatusBadge'
|
||||
|
||||
export function Payments() {
|
||||
const { data: payments, error } = usePaymentRequests()
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
<div>
|
||||
<h1 style={{ fontSize: 20, fontWeight: 600, color: '#fff' }}>Payments</h1>
|
||||
<p style={{ fontSize: 13, color: '#737373', marginTop: 4 }}>
|
||||
Deposit/withdrawal lifecycle with MPC co-sign status.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && <div style={{ color: '#ef4444', fontSize: 13 }}>{(error as Error).message}</div>}
|
||||
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid #262626', color: '#737373', textAlign: 'left' }}>
|
||||
<th style={{ padding: '8px 12px' }}>Merchant</th>
|
||||
<th style={{ padding: '8px 12px' }}>Amount</th>
|
||||
<th style={{ padding: '8px 12px' }}>Chain</th>
|
||||
<th style={{ padding: '8px 12px' }}>Recipient</th>
|
||||
<th style={{ padding: '8px 12px' }}>Status</th>
|
||||
<th style={{ padding: '8px 12px' }}>Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{payments?.map((pr) => (
|
||||
<tr key={pr.id} style={{ borderBottom: '1px solid #171717' }}>
|
||||
<td style={{ padding: '8px 12px', fontWeight: 500 }}>{pr.merchant_name || '--'}</td>
|
||||
<td style={{ padding: '8px 12px', fontFamily: 'monospace' }}>
|
||||
{pr.amount} {pr.token || ''}
|
||||
</td>
|
||||
<td style={{ padding: '8px 12px', textTransform: 'capitalize' }}>{pr.chain}</td>
|
||||
<td style={{ padding: '8px 12px', fontFamily: 'monospace', fontSize: 11 }}>
|
||||
{pr.recipient_address.slice(0, 10)}...{pr.recipient_address.slice(-6)}
|
||||
</td>
|
||||
<td style={{ padding: '8px 12px' }}><StatusBadge status={pr.status} /></td>
|
||||
<td style={{ padding: '8px 12px', color: '#737373' }}>
|
||||
{new Date(pr.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{(!payments || payments.length === 0) && (
|
||||
<tr>
|
||||
<td colSpan={6} style={{ padding: '16px 12px', color: '#525252' }}>
|
||||
No payment requests
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { usePolicies } from '../hooks/use-mpc'
|
||||
import { api } from '../lib/api'
|
||||
import { StatusBadge } from '../components/StatusBadge'
|
||||
|
||||
const card: React.CSSProperties = {
|
||||
background: '#0a0a0a',
|
||||
border: '1px solid #262626',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
}
|
||||
|
||||
const input: React.CSSProperties = {
|
||||
background: '#0a0a0a',
|
||||
border: '1px solid #262626',
|
||||
borderRadius: 6,
|
||||
padding: '6px 12px',
|
||||
color: '#e5e5e5',
|
||||
fontSize: 13,
|
||||
width: '100%',
|
||||
}
|
||||
|
||||
const actionOptions = ['approve', 'deny', 'require_approval']
|
||||
|
||||
export function Policies() {
|
||||
const queryClient = useQueryClient()
|
||||
const { data: policies } = usePolicies()
|
||||
const [name, setName] = useState('')
|
||||
const [action, setAction] = useState('require_approval')
|
||||
const [priority, setPriority] = useState(0)
|
||||
const [requiredApprovers, setRequiredApprovers] = useState(1)
|
||||
const [approverRoles, setApproverRoles] = useState('admin,owner')
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
async function handleCreate(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!name.trim()) return
|
||||
setCreating(true)
|
||||
setErr('')
|
||||
try {
|
||||
await api.createPolicy({
|
||||
name,
|
||||
action,
|
||||
priority,
|
||||
conditions: {},
|
||||
required_approvers: requiredApprovers,
|
||||
approver_roles: approverRoles.split(',').map((r) => r.trim()).filter(Boolean),
|
||||
})
|
||||
setName('')
|
||||
setPriority(0)
|
||||
setRequiredApprovers(1)
|
||||
queryClient.invalidateQueries({ queryKey: ['mpc', 'policies'] })
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : 'Failed to create policy')
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggle(id: string, enabled: boolean) {
|
||||
try {
|
||||
await api.updatePolicy(id, { enabled: !enabled })
|
||||
queryClient.invalidateQueries({ queryKey: ['mpc', 'policies'] })
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : 'Failed to update policy')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
try {
|
||||
await api.deletePolicy(id)
|
||||
queryClient.invalidateQueries({ queryKey: ['mpc', 'policies'] })
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : 'Failed to delete policy')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
<div>
|
||||
<h1 style={{ fontSize: 20, fontWeight: 600, color: '#fff' }}>Policies</h1>
|
||||
<p style={{ fontSize: 13, color: '#737373', marginTop: 4 }}>
|
||||
Signing policies: auto-approve, requires-N, blocked.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{err && <div style={{ color: '#ef4444', fontSize: 13 }}>{err}</div>}
|
||||
|
||||
{/* Create form */}
|
||||
<form onSubmit={handleCreate} style={card}>
|
||||
<div style={{ fontSize: 12, color: '#737373', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 12 }}>
|
||||
Create Policy
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))', gap: 8 }}>
|
||||
<input style={input} placeholder="Policy name" required value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<select style={input} value={action} onChange={(e) => setAction(e.target.value)}>
|
||||
{actionOptions.map((a) => <option key={a} value={a}>{a.replace(/_/g, ' ')}</option>)}
|
||||
</select>
|
||||
<input style={input} type="number" placeholder="Priority" value={priority} onChange={(e) => setPriority(Number(e.target.value))} />
|
||||
<input style={input} type="number" min={1} placeholder="Approvers" value={requiredApprovers} onChange={(e) => setRequiredApprovers(Number(e.target.value))} />
|
||||
</div>
|
||||
<div style={{ marginTop: 8, display: 'flex', gap: 8 }}>
|
||||
<input style={{ ...input, flex: 1 }} placeholder="Approver roles (comma-separated)" value={approverRoles} onChange={(e) => setApproverRoles(e.target.value)} />
|
||||
<button
|
||||
type="submit"
|
||||
disabled={creating || !name.trim()}
|
||||
style={{ padding: '6px 16px', borderRadius: 6, fontSize: 13, fontWeight: 500, cursor: 'pointer', border: 'none', background: '#fff', color: '#000', opacity: creating || !name.trim() ? 0.5 : 1 }}
|
||||
>
|
||||
{creating ? 'Creating...' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Table */}
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid #262626', color: '#737373', textAlign: 'left' }}>
|
||||
<th style={{ padding: '8px 12px' }}>Name</th>
|
||||
<th style={{ padding: '8px 12px' }}>Action</th>
|
||||
<th style={{ padding: '8px 12px' }}>Priority</th>
|
||||
<th style={{ padding: '8px 12px' }}>Approvers</th>
|
||||
<th style={{ padding: '8px 12px' }}>Status</th>
|
||||
<th style={{ padding: '8px 12px', textAlign: 'right' }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{policies?.map((p) => (
|
||||
<tr key={p.id} style={{ borderBottom: '1px solid #171717' }}>
|
||||
<td style={{ padding: '8px 12px', fontWeight: 500 }}>{p.name}</td>
|
||||
<td style={{ padding: '8px 12px', textTransform: 'capitalize' }}>{p.action.replace(/_/g, ' ')}</td>
|
||||
<td style={{ padding: '8px 12px', fontFamily: 'monospace' }}>{p.priority}</td>
|
||||
<td style={{ padding: '8px 12px' }}>{p.required_approvers} ({p.approver_roles.join(', ')})</td>
|
||||
<td style={{ padding: '8px 12px' }}><StatusBadge status={p.enabled ? 'enabled' : 'disabled'} /></td>
|
||||
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
|
||||
<button
|
||||
onClick={() => handleToggle(p.id, p.enabled)}
|
||||
style={{ background: 'none', border: 'none', color: '#a3a3a3', cursor: 'pointer', fontSize: 12, marginRight: 12 }}
|
||||
>
|
||||
{p.enabled ? 'Disable' : 'Enable'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(p.id)}
|
||||
style={{ background: 'none', border: 'none', color: '#ef4444', cursor: 'pointer', fontSize: 12 }}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{(!policies || policies.length === 0) && (
|
||||
<tr>
|
||||
<td colSpan={6} style={{ padding: '16px 12px', color: '#525252' }}>
|
||||
No policies configured
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useInfo, useStatus } from '../hooks/use-mpc'
|
||||
import { StatusBadge } from '../components/StatusBadge'
|
||||
|
||||
const card: React.CSSProperties = {
|
||||
background: '#0a0a0a',
|
||||
border: '1px solid #262626',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
}
|
||||
|
||||
const label: React.CSSProperties = {
|
||||
fontSize: 12,
|
||||
color: '#737373',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}
|
||||
|
||||
export function Settings() {
|
||||
const { data: info } = useInfo()
|
||||
const { data: cluster } = useStatus()
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
<div>
|
||||
<h1 style={{ fontSize: 20, fontWeight: 600, color: '#fff' }}>Settings</h1>
|
||||
<p style={{ fontSize: 13, color: '#737373', marginTop: 4 }}>
|
||||
Node configuration, HSM status, and threshold config.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Service info */}
|
||||
<div style={card}>
|
||||
<div style={{ ...label, marginBottom: 12 }}>Service Info</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: 12 }}>
|
||||
<div>
|
||||
<div style={label}>Service</div>
|
||||
<div style={{ fontSize: 14, marginTop: 2 }}>{info?.name ?? '--'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={label}>Version</div>
|
||||
<div style={{ fontSize: 14, fontFamily: 'monospace', marginTop: 2 }}>{info?.version ?? '--'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={label}>Key Types</div>
|
||||
<div style={{ fontSize: 14, fontFamily: 'monospace', marginTop: 2 }}>
|
||||
{info?.key_types?.join(', ') ?? '--'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={label}>Protocols</div>
|
||||
<div style={{ fontSize: 14, marginTop: 2 }}>
|
||||
{info?.protocols?.join(', ') ?? '--'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Supported chains */}
|
||||
<div style={card}>
|
||||
<div style={{ ...label, marginBottom: 12 }}>Supported Chains</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{info?.supported_chains?.map((chain) => (
|
||||
<span
|
||||
key={chain}
|
||||
style={{
|
||||
background: '#262626',
|
||||
borderRadius: 4,
|
||||
padding: '4px 10px',
|
||||
fontSize: 12,
|
||||
color: '#d4d4d4',
|
||||
}}
|
||||
>
|
||||
{chain}
|
||||
</span>
|
||||
))}
|
||||
{(!info?.supported_chains || info.supported_chains.length === 0) && (
|
||||
<span style={{ color: '#525252', fontSize: 13 }}>--</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cluster config */}
|
||||
<div style={card}>
|
||||
<div style={{ ...label, marginBottom: 12 }}>Cluster Configuration</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: 12 }}>
|
||||
<div>
|
||||
<div style={label}>Node ID</div>
|
||||
<div style={{ fontSize: 14, fontFamily: 'monospace', marginTop: 2 }}>{cluster?.node_id ?? '--'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={label}>Mode</div>
|
||||
<div style={{ fontSize: 14, marginTop: 2 }}>{cluster?.mode ?? '--'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={label}>Threshold</div>
|
||||
<div style={{ fontSize: 14, fontFamily: 'monospace', marginTop: 2 }}>
|
||||
{cluster ? `${cluster.threshold}-of-${cluster.expected_peers}` : '--'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={label}>Peers</div>
|
||||
<div style={{ fontSize: 14, fontFamily: 'monospace', marginTop: 2 }}>
|
||||
{cluster ? `${cluster.connected_peers}/${cluster.expected_peers}` : '--'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={label}>Status</div>
|
||||
<div style={{ marginTop: 2 }}>
|
||||
{cluster ? <StatusBadge status={cluster.ready ? 'running' : 'degraded'} /> : '--'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useState } from 'react'
|
||||
import { useSettlements } from '../hooks/use-mpc'
|
||||
import { StatusBadge } from '../components/StatusBadge'
|
||||
|
||||
const statusOptions = ['all', 'pending', 'hsm_signing', 'broadcast', 'confirming', 'finalized', 'verified', 'failed']
|
||||
|
||||
export function Settlements() {
|
||||
const [statusFilter, setStatusFilter] = useState('all')
|
||||
const filters = statusFilter !== 'all' ? { status: statusFilter } : undefined
|
||||
const { data: settlements, error } = useSettlements(filters)
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
<div>
|
||||
<h1 style={{ fontSize: 20, fontWeight: 600, color: '#fff' }}>Settlements</h1>
|
||||
<p style={{ fontSize: 13, color: '#737373', marginTop: 4 }}>
|
||||
Intent → sign → broadcast → confirm lifecycle.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && <div style={{ color: '#ef4444', fontSize: 13 }}>{(error as Error).message}</div>}
|
||||
|
||||
{/* Filter */}
|
||||
<div>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
style={{
|
||||
background: '#0a0a0a',
|
||||
border: '1px solid #262626',
|
||||
borderRadius: 6,
|
||||
padding: '6px 12px',
|
||||
color: '#e5e5e5',
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{statusOptions.map((s) => (
|
||||
<option key={s} value={s}>{s === 'all' ? 'All Status' : s.replace(/_/g, ' ')}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid #262626', color: '#737373', textAlign: 'left' }}>
|
||||
<th style={{ padding: '8px 12px' }}>Settlement ID</th>
|
||||
<th style={{ padding: '8px 12px' }}>Intent</th>
|
||||
<th style={{ padding: '8px 12px' }}>HSM Sigs</th>
|
||||
<th style={{ padding: '8px 12px' }}>Verified</th>
|
||||
<th style={{ padding: '8px 12px' }}>Status</th>
|
||||
<th style={{ padding: '8px 12px' }}>Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{settlements?.map((s) => (
|
||||
<tr key={s.id} style={{ borderBottom: '1px solid #171717' }}>
|
||||
<td style={{ padding: '8px 12px', fontFamily: 'monospace', fontSize: 11 }}>{s.id.slice(0, 12)}...</td>
|
||||
<td style={{ padding: '8px 12px', fontFamily: 'monospace', fontSize: 11 }}>{s.intent_id.slice(0, 12)}...</td>
|
||||
<td style={{ padding: '8px 12px' }}>{s.hsm_signatures?.length ?? 0}</td>
|
||||
<td style={{ padding: '8px 12px' }}>
|
||||
<span style={{ color: s.transfer_agency_verified ? '#22c55e' : '#737373' }}>
|
||||
{s.transfer_agency_verified ? 'Yes' : 'No'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '8px 12px' }}><StatusBadge status={s.status} /></td>
|
||||
<td style={{ padding: '8px 12px', color: '#737373' }}>
|
||||
{new Date(s.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{(!settlements || settlements.length === 0) && (
|
||||
<tr>
|
||||
<td colSpan={6} style={{ padding: '16px 12px', color: '#525252' }}>
|
||||
No settlements found
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useState } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useVaults } from '../hooks/use-mpc'
|
||||
import { api } from '../lib/api'
|
||||
|
||||
const card: React.CSSProperties = {
|
||||
background: '#0a0a0a',
|
||||
border: '1px solid #262626',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
}
|
||||
|
||||
const input: React.CSSProperties = {
|
||||
width: '100%',
|
||||
background: '#0a0a0a',
|
||||
border: '1px solid #262626',
|
||||
borderRadius: 6,
|
||||
padding: '6px 12px',
|
||||
color: '#e5e5e5',
|
||||
fontSize: 13,
|
||||
}
|
||||
|
||||
const btn: React.CSSProperties = {
|
||||
padding: '6px 16px',
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
border: 'none',
|
||||
}
|
||||
|
||||
export function Vaults() {
|
||||
const queryClient = useQueryClient()
|
||||
const { data: vaults } = useVaults()
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
async function handleCreate() {
|
||||
if (!name.trim()) return
|
||||
setCreating(true)
|
||||
setErr('')
|
||||
try {
|
||||
await api.createVault({ name, description })
|
||||
setName('')
|
||||
setDescription('')
|
||||
queryClient.invalidateQueries({ queryKey: ['mpc', 'vaults'] })
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : 'Failed to create vault')
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
<div>
|
||||
<h1 style={{ fontSize: 20, fontWeight: 600, color: '#fff' }}>Vaults</h1>
|
||||
<p style={{ fontSize: 13, color: '#737373', marginTop: 4 }}>
|
||||
Cold storage vaults with Shamir backup status.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{err && <div style={{ color: '#ef4444', fontSize: 13 }}>{err}</div>}
|
||||
|
||||
{/* Create form */}
|
||||
<div style={card}>
|
||||
<div style={{ fontSize: 12, color: '#737373', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 12 }}>
|
||||
Create Vault
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<input
|
||||
style={{ ...input, maxWidth: 240 }}
|
||||
placeholder="Vault name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
style={{ ...input, maxWidth: 320 }}
|
||||
placeholder="Description (optional)"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
style={{ ...btn, background: '#fff', color: '#000', opacity: creating || !name.trim() ? 0.5 : 1 }}
|
||||
disabled={creating || !name.trim()}
|
||||
onClick={handleCreate}
|
||||
>
|
||||
{creating ? 'Creating...' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vault grid */}
|
||||
{!vaults || vaults.length === 0 ? (
|
||||
<div style={{ ...card, textAlign: 'center', color: '#525252' }}>
|
||||
No vaults yet.
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 12 }}>
|
||||
{vaults.map((v) => (
|
||||
<div key={v.id} style={card}>
|
||||
<div style={{ fontWeight: 500, color: '#fff' }}>{v.name}</div>
|
||||
{v.description && (
|
||||
<div style={{ fontSize: 12, color: '#737373', marginTop: 4 }}>{v.description}</div>
|
||||
)}
|
||||
<div style={{ fontSize: 11, color: '#525252', marginTop: 8, fontFamily: 'monospace' }}>
|
||||
{v.id.slice(0, 16)}...
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#525252', marginTop: 2 }}>
|
||||
Created {new Date(v.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useState } from 'react'
|
||||
import { useVaults, useWallets } from '../hooks/use-mpc'
|
||||
import { StatusBadge } from '../components/StatusBadge'
|
||||
|
||||
const card: React.CSSProperties = {
|
||||
background: '#0a0a0a',
|
||||
border: '1px solid #262626',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
}
|
||||
|
||||
const label: React.CSSProperties = {
|
||||
fontSize: 12,
|
||||
color: '#737373',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}
|
||||
|
||||
export function Wallets() {
|
||||
const { data: vaults } = useVaults()
|
||||
const [selectedVault, setSelectedVault] = useState('')
|
||||
|
||||
// Select first vault by default
|
||||
const vaultId = selectedVault || vaults?.[0]?.id || ''
|
||||
const { data: wallets } = useWallets(vaultId)
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
<div>
|
||||
<h1 style={{ fontSize: 20, fontWeight: 600, color: '#fff' }}>Wallets</h1>
|
||||
<p style={{ fontSize: 13, color: '#737373', marginTop: 4 }}>
|
||||
MPC wallets grouped by vault. Each wallet holds threshold key shares.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Vault selector */}
|
||||
{vaults && vaults.length > 0 && (
|
||||
<div>
|
||||
<div style={label}>Vault</div>
|
||||
<select
|
||||
value={vaultId}
|
||||
onChange={(e) => setSelectedVault(e.target.value)}
|
||||
style={{
|
||||
marginTop: 4,
|
||||
background: '#0a0a0a',
|
||||
border: '1px solid #262626',
|
||||
borderRadius: 6,
|
||||
padding: '6px 12px',
|
||||
color: '#e5e5e5',
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{vaults.map((v) => (
|
||||
<option key={v.id} value={v.id}>{v.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Wallet table */}
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid #262626', color: '#737373', textAlign: 'left' }}>
|
||||
<th style={{ padding: '8px 12px' }}>Name</th>
|
||||
<th style={{ padding: '8px 12px' }}>Key Type</th>
|
||||
<th style={{ padding: '8px 12px' }}>Protocol</th>
|
||||
<th style={{ padding: '8px 12px' }}>Threshold</th>
|
||||
<th style={{ padding: '8px 12px' }}>ETH Address</th>
|
||||
<th style={{ padding: '8px 12px' }}>Status</th>
|
||||
<th style={{ padding: '8px 12px' }}>Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{wallets?.map((w) => (
|
||||
<tr key={w.id} style={{ borderBottom: '1px solid #171717' }}>
|
||||
<td style={{ padding: '8px 12px', fontWeight: 500 }}>{w.name || w.wallet_id.slice(0, 12)}</td>
|
||||
<td style={{ padding: '8px 12px', fontFamily: 'monospace' }}>{w.key_type}</td>
|
||||
<td style={{ padding: '8px 12px' }}>{w.protocol ?? '--'}</td>
|
||||
<td style={{ padding: '8px 12px', fontFamily: 'monospace' }}>
|
||||
{w.threshold}/{w.participants.length}
|
||||
</td>
|
||||
<td style={{ padding: '8px 12px', fontFamily: 'monospace', fontSize: 11 }}>
|
||||
{w.eth_address ? `${w.eth_address.slice(0, 10)}...${w.eth_address.slice(-6)}` : '--'}
|
||||
</td>
|
||||
<td style={{ padding: '8px 12px' }}><StatusBadge status={w.status} /></td>
|
||||
<td style={{ padding: '8px 12px', color: '#737373' }}>
|
||||
{new Date(w.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{(!wallets || wallets.length === 0) && (
|
||||
<tr>
|
||||
<td colSpan={7} style={{ padding: '16px 12px', color: '#525252' }}>
|
||||
{vaultId ? 'No wallets in this vault' : 'No vaults available'}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Wallet detail cards for addresses */}
|
||||
{wallets && wallets.length > 0 && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: 12 }}>
|
||||
{wallets.map((w) => (
|
||||
<div key={w.id} style={card}>
|
||||
<div style={{ fontWeight: 500, fontSize: 14, color: '#fff', marginBottom: 8 }}>
|
||||
{w.name || w.wallet_id.slice(0, 12)}
|
||||
</div>
|
||||
{w.eth_address && (
|
||||
<div style={{ fontSize: 11, color: '#a3a3a3', marginBottom: 4 }}>
|
||||
<span style={label}>ETH</span>{' '}
|
||||
<span style={{ fontFamily: 'monospace' }}>{w.eth_address}</span>
|
||||
</div>
|
||||
)}
|
||||
{w.btc_address && (
|
||||
<div style={{ fontSize: 11, color: '#a3a3a3', marginBottom: 4 }}>
|
||||
<span style={label}>BTC</span>{' '}
|
||||
<span style={{ fontFamily: 'monospace' }}>{w.btc_address}</span>
|
||||
</div>
|
||||
)}
|
||||
{w.sol_address && (
|
||||
<div style={{ fontSize: 11, color: '#a3a3a3' }}>
|
||||
<span style={label}>SOL</span>{' '}
|
||||
<span style={{ fontFamily: 'monospace' }}>{w.sol_address}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: '/_/mpc/',
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
output: { manualChunks: undefined },
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/v1/mpc': 'http://localhost:8081',
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user