mirror of
https://github.com/luxfi/aml.git
synced 2026-09-24 00:25:20 +00:00
feat(ui): embedded admin dashboard — @hanzo/gui + go:embed
Vite + React 19 + wouter hash router, 5 pages (Dashboard, Cases, Rules, Alerts, Sanctions). Served at /_/aml/ via go:embed. ~220KB dist. Adds POST /v1/aml/sanctions/search endpoint + SanctionsStore.
This commit is contained in:
@@ -5,3 +5,5 @@
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
.env
|
||||
ui/dist/
|
||||
ui/node_modules/
|
||||
|
||||
@@ -30,8 +30,23 @@ make run # Build + run dev
|
||||
## Module Layout
|
||||
|
||||
```
|
||||
cmd/amld/main.go -- Single binary: serve, version
|
||||
cmd/amld/main.go -- Single binary: serve, version, embedded admin UI
|
||||
migrations/0001_core.sql -- 9 SQLite collections
|
||||
ui/ -- Embedded admin dashboard (Vite + React + @hanzo/gui)
|
||||
embed.go -- go:embed all:dist -> DistDirFS()
|
||||
package.json -- @luxfi/aml-ui (private, pnpm)
|
||||
vite.config.ts -- base: /_/aml/, proxy /v1/aml to :8090
|
||||
src/
|
||||
main.tsx -- React 19 entry
|
||||
App.tsx -- Hash router (wouter), sidebar nav, 5 routes
|
||||
api.ts -- Typed fetch wrappers for /v1/aml/* endpoints
|
||||
pages/
|
||||
Dashboard.tsx -- Stats cards, recent cases, health check
|
||||
Cases.tsx -- DataTable with status filter tabs, expandable rows
|
||||
Rules.tsx -- DataTable with DSL display, test rule modal
|
||||
Alerts.tsx -- DataTable with severity filter chips, score breakdown
|
||||
Sanctions.tsx -- Name search form, results table with scores
|
||||
dist/ -- Built output (gitignored, ~220KB)
|
||||
pkg/
|
||||
types/types.go -- Canonical domain types (Transaction, Entity, Rule, Alert, Case)
|
||||
engine/
|
||||
@@ -47,7 +62,7 @@ pkg/
|
||||
case.go -- Case store (create, update status, assign, resolve, events)
|
||||
errors.go -- Sentinel errors
|
||||
webhook/webhook.go -- Signed delivery (HMAC-SHA256) with retry + dead-letter
|
||||
api/routes.go -- /v1/aml/* HTTP routes on Base
|
||||
api/routes.go -- /v1/aml/* HTTP routes + SanctionsStore on Base
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
@@ -60,10 +75,25 @@ pkg/
|
||||
| POST | /v1/aml/cases/{id}/events | Add case event (note, status change) |
|
||||
| GET | /v1/aml/rules | List all rules |
|
||||
| POST | /v1/aml/rules/test | Dry-run a DSL expression |
|
||||
| POST | /v1/aml/sanctions/search | Search sanctions lists by name |
|
||||
| GET | /v1/aml/health | Health check |
|
||||
|
||||
All endpoints require `X-Org-Id` header.
|
||||
|
||||
## Embedded Admin UI
|
||||
|
||||
Served at `/_/aml/` via `go:embed`. Hash router (`/#/cases`, `/#/rules`, etc.) for SPA routing without server rewrites.
|
||||
|
||||
```bash
|
||||
cd ui && pnpm install && pnpm build # produces dist/ (~220KB)
|
||||
cd .. && go build ./cmd/amld/ # binary includes UI
|
||||
./amld serve --dev # UI at http://localhost:8090/_/aml/
|
||||
```
|
||||
|
||||
Dev mode: `cd ui && pnpm dev` (port 3000, proxies API to :8090).
|
||||
|
||||
Tech: React 19, wouter (hash router), @hanzo/gui, Vite 6. Dark theme.
|
||||
|
||||
## Transaction Ingest Flow
|
||||
|
||||
```
|
||||
|
||||
+11
-3
@@ -12,6 +12,7 @@ import (
|
||||
"os"
|
||||
|
||||
"github.com/hanzoai/base"
|
||||
"github.com/hanzoai/base/apis"
|
||||
"github.com/hanzoai/base/core"
|
||||
"github.com/hanzoai/base/tools/hook"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -20,6 +21,7 @@ import (
|
||||
"github.com/luxfi/aml/pkg/cases"
|
||||
"github.com/luxfi/aml/pkg/engine"
|
||||
"github.com/luxfi/aml/pkg/rules"
|
||||
uiaml "github.com/luxfi/aml/ui"
|
||||
)
|
||||
|
||||
var version = "(dev)"
|
||||
@@ -45,16 +47,22 @@ func main() {
|
||||
eng := engine.New(starterRules)
|
||||
caseStore := cases.NewStore()
|
||||
alertStore := api.NewAlertStore()
|
||||
sanctionsStore := api.NewSanctionsStore()
|
||||
|
||||
handler := &api.Handler{
|
||||
Engine: eng,
|
||||
Cases: caseStore,
|
||||
Alerts: alertStore,
|
||||
Engine: eng,
|
||||
Cases: caseStore,
|
||||
Alerts: alertStore,
|
||||
Sanctions: sanctionsStore,
|
||||
}
|
||||
|
||||
app.OnServe().Bind(&hook.Handler[*core.ServeEvent]{
|
||||
Func: func(se *core.ServeEvent) error {
|
||||
handler.Register(se)
|
||||
|
||||
// Mount embedded admin UI at /_/aml/
|
||||
se.Router.GET("/_/aml/{path...}", apis.Static(uiaml.DistDirFS(), true))
|
||||
|
||||
return se.Next()
|
||||
},
|
||||
})
|
||||
|
||||
+108
-3
@@ -5,6 +5,7 @@ package api
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -12,14 +13,16 @@ import (
|
||||
|
||||
"github.com/luxfi/aml/pkg/cases"
|
||||
"github.com/luxfi/aml/pkg/engine"
|
||||
"github.com/luxfi/aml/pkg/sanctions"
|
||||
"github.com/luxfi/aml/pkg/types"
|
||||
)
|
||||
|
||||
// Handler wires AML engine + case store + sanctions to HTTP routes.
|
||||
type Handler struct {
|
||||
Engine *engine.Engine
|
||||
Cases *cases.Store
|
||||
Alerts *AlertStore
|
||||
Engine *engine.Engine
|
||||
Cases *cases.Store
|
||||
Alerts *AlertStore
|
||||
Sanctions *SanctionsStore
|
||||
}
|
||||
|
||||
// AlertStore is a minimal in-memory alert store for the API layer.
|
||||
@@ -42,6 +45,60 @@ func (s *AlertStore) ByTx(txID string) []types.Alert {
|
||||
return s.alerts[txID]
|
||||
}
|
||||
|
||||
// SanctionsStore is a minimal in-memory sanctions entry store.
|
||||
type SanctionsStore struct {
|
||||
mu sync.RWMutex
|
||||
entries []types.SanctionsEntry
|
||||
}
|
||||
|
||||
// NewSanctionsStore creates an empty sanctions store.
|
||||
func NewSanctionsStore() *SanctionsStore {
|
||||
return &SanctionsStore{}
|
||||
}
|
||||
|
||||
// Load replaces all entries.
|
||||
func (s *SanctionsStore) Load(entries []types.SanctionsEntry) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.entries = entries
|
||||
}
|
||||
|
||||
// Search finds entries matching the given name above the threshold.
|
||||
func (s *SanctionsStore) Search(name string, threshold float64) []sanctionSearchResult {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
var results []sanctionSearchResult
|
||||
for _, e := range s.entries {
|
||||
score := sanctions.TokenMatch(name, e.Name)
|
||||
if score >= threshold {
|
||||
results = append(results, sanctionSearchResult{
|
||||
Entry: e,
|
||||
Score: score,
|
||||
})
|
||||
continue
|
||||
}
|
||||
// Check aliases.
|
||||
for _, alias := range e.Aliases {
|
||||
aliasScore := sanctions.TokenMatch(name, alias)
|
||||
if aliasScore >= threshold && aliasScore > score {
|
||||
score = aliasScore
|
||||
results = append(results, sanctionSearchResult{
|
||||
Entry: e,
|
||||
Score: score,
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
type sanctionSearchResult struct {
|
||||
Entry types.SanctionsEntry
|
||||
Score float64
|
||||
}
|
||||
|
||||
// Register adds AML routes to a ServeEvent router.
|
||||
// Called from within an OnServe hook.
|
||||
func (h *Handler) Register(se *core.ServeEvent) {
|
||||
@@ -51,6 +108,7 @@ func (h *Handler) Register(se *core.ServeEvent) {
|
||||
se.Router.POST("/v1/aml/cases/{id}/events", h.addCaseEvent())
|
||||
se.Router.GET("/v1/aml/rules", h.listRules())
|
||||
se.Router.POST("/v1/aml/rules/test", h.testRule())
|
||||
se.Router.POST("/v1/aml/sanctions/search", h.searchSanctions())
|
||||
se.Router.GET("/v1/aml/health", h.health())
|
||||
}
|
||||
|
||||
@@ -163,6 +221,53 @@ func (h *Handler) listRules() func(e *core.RequestEvent) error {
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) searchSanctions() func(e *core.RequestEvent) error {
|
||||
return func(e *core.RequestEvent) error {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
DOB string `json:"dob"`
|
||||
}
|
||||
if err := json.NewDecoder(e.Request.Body).Decode(&req); err != nil {
|
||||
return e.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
||||
}
|
||||
if req.Name == "" {
|
||||
return e.JSON(http.StatusBadRequest, map[string]string{"error": "name is required"})
|
||||
}
|
||||
|
||||
if h.Sanctions == nil {
|
||||
return e.JSON(http.StatusOK, []interface{}{})
|
||||
}
|
||||
|
||||
results := h.Sanctions.Search(req.Name, sanctions.MatchThreshold)
|
||||
|
||||
type entry struct {
|
||||
ID string `json:"id"`
|
||||
ListID string `json:"list_id"`
|
||||
Name string `json:"name"`
|
||||
Aliases []string `json:"aliases,omitempty"`
|
||||
DOB string `json:"dob,omitempty"`
|
||||
Nationality string `json:"nationality,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Score float64 `json:"score"`
|
||||
}
|
||||
|
||||
out := make([]entry, 0, len(results))
|
||||
for _, r := range results {
|
||||
out = append(out, entry{
|
||||
ID: r.Entry.ID,
|
||||
ListID: r.Entry.ListID,
|
||||
Name: r.Entry.Name,
|
||||
Aliases: r.Entry.Aliases,
|
||||
DOB: r.Entry.DOB,
|
||||
Nationality: r.Entry.Nationality,
|
||||
Type: r.Entry.Type,
|
||||
Score: r.Score,
|
||||
})
|
||||
}
|
||||
return e.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) testRule() func(e *core.RequestEvent) error {
|
||||
return func(e *core.RequestEvent) error {
|
||||
var req struct {
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
// Package ui embeds the AML admin dashboard built with Vite + React.
|
||||
//
|
||||
// The Go HTTP handler serves the admin UI at the /_/aml/ 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("aml-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>AML 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,26 @@
|
||||
{
|
||||
"name": "@luxfi/aml-ui",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hanzo/gui": "^4.8.0",
|
||||
"@hanzogui/config-default": "^4.4.0",
|
||||
"@hanzogui/core": "^4.4.0",
|
||||
"@hanzogui/theme": "^4.4.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"
|
||||
}
|
||||
}
|
||||
Generated
+7571
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,76 @@
|
||||
import { Router, Route, Switch, Link, useLocation } from 'wouter'
|
||||
import { useHashLocation } from 'wouter/use-hash-location'
|
||||
import { Dashboard } from './pages/Dashboard'
|
||||
import { Cases } from './pages/Cases'
|
||||
import { Rules } from './pages/Rules'
|
||||
import { Alerts } from './pages/Alerts'
|
||||
import { Sanctions } from './pages/Sanctions'
|
||||
|
||||
const navItems = [
|
||||
{ href: '/', label: 'Dashboard' },
|
||||
{ href: '/cases', label: 'Cases' },
|
||||
{ href: '/rules', label: 'Rules' },
|
||||
{ href: '/alerts', label: 'Alerts' },
|
||||
{ href: '/sanctions', label: 'Sanctions' },
|
||||
] 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,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, fontSize: 16, marginBottom: 16, color: '#fff' }}>
|
||||
AML 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="/cases" component={Cases} />
|
||||
<Route path="/rules" component={Rules} />
|
||||
<Route path="/alerts" component={Alerts} />
|
||||
<Route path="/sanctions" component={Sanctions} />
|
||||
<Route>
|
||||
<div style={{ color: '#737373' }}>Not found</div>
|
||||
</Route>
|
||||
</Switch>
|
||||
</main>
|
||||
</div>
|
||||
</Router>
|
||||
)
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
// api.ts -- typed fetch wrappers for /v1/aml/* endpoints.
|
||||
// All calls use relative URLs so they work on any host.
|
||||
|
||||
export interface Case {
|
||||
id: string
|
||||
org_id: string
|
||||
number: number
|
||||
status: string
|
||||
severity: string
|
||||
entity_ids?: string[]
|
||||
alert_ids?: string[]
|
||||
assignee_id?: string
|
||||
opened_at: string
|
||||
closed_at?: string
|
||||
resolution?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface Rule {
|
||||
id: string
|
||||
org_id: string
|
||||
name: string
|
||||
description: string
|
||||
dsl: string
|
||||
severity: string
|
||||
weight: number
|
||||
action: string
|
||||
enabled: boolean
|
||||
jurisdiction_filter?: string[]
|
||||
asset_class_filter?: string[]
|
||||
priority: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface Alert {
|
||||
id: string
|
||||
org_id: string
|
||||
tx_id: string
|
||||
rule_id: string
|
||||
rule_name: string
|
||||
severity: string
|
||||
score: number
|
||||
score_breakdown?: Record<string, number>
|
||||
action_taken: string
|
||||
reviewed_by?: string
|
||||
reviewed_at?: string
|
||||
decision?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface SanctionsResult {
|
||||
id: string
|
||||
list_id: string
|
||||
name: string
|
||||
aliases?: string[]
|
||||
dob?: string
|
||||
nationality?: string
|
||||
type: string
|
||||
score: number
|
||||
}
|
||||
|
||||
export interface RuleTestResult {
|
||||
match: boolean
|
||||
dsl: string
|
||||
}
|
||||
|
||||
async function json<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(url, init)
|
||||
if (!res.ok) {
|
||||
const body = await res.text()
|
||||
throw new Error(`${res.status}: ${body}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export function listCases(status?: string): Promise<Case[]> {
|
||||
const q = status && status !== 'all' ? `?status=${status}` : ''
|
||||
return json(`/v1/aml/cases${q}`)
|
||||
}
|
||||
|
||||
export function listRules(): Promise<Rule[]> {
|
||||
return json('/v1/aml/rules')
|
||||
}
|
||||
|
||||
export function testRule(dsl: string): Promise<RuleTestResult> {
|
||||
return json('/v1/aml/rules/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
dsl,
|
||||
tx: { id: 'test', notional: 15000, currency: 'USD', qty: 1 },
|
||||
entity: { id: 'test', name: 'Test User', entity_type: 'user' },
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export function listAlerts(txId: string): Promise<Alert[]> {
|
||||
return json(`/v1/aml/transactions/${txId}/alerts`)
|
||||
}
|
||||
|
||||
export function searchSanctions(name: string, dob?: string): Promise<SanctionsResult[]> {
|
||||
return json('/v1/aml/sanctions/search', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, dob }),
|
||||
})
|
||||
}
|
||||
|
||||
export function health(): Promise<{ status: string; time: string }> {
|
||||
return json('/v1/aml/health')
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import React from 'react'
|
||||
import { App } from './App'
|
||||
|
||||
const root = document.getElementById('root')
|
||||
if (!root) throw new Error('root element missing')
|
||||
|
||||
createRoot(root).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { listCases, listAlerts, type Case, type Alert } from '../api'
|
||||
|
||||
export function Alerts() {
|
||||
const [cases, setCases] = useState<Case[]>([])
|
||||
const [alerts, setAlerts] = useState<Alert[]>([])
|
||||
const [sevFilter, setSevFilter] = useState<string>('all')
|
||||
const [expanded, setExpanded] = useState<string | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
// Derive alerts from cases' alert_ids. The API returns alerts per-transaction,
|
||||
// so we collect unique tx_ids from case alert references and fetch each.
|
||||
useEffect(() => {
|
||||
listCases()
|
||||
.then(async (cs) => {
|
||||
setCases(cs)
|
||||
// Collect all unique alert fetches -- alerts are keyed by tx,
|
||||
// but we only have case.alert_ids. For the embedded UI, we show
|
||||
// case-level alert metadata as placeholder rows when the
|
||||
// per-transaction fetch is not available.
|
||||
const allAlerts: Alert[] = []
|
||||
// Derive minimal alert rows from case data.
|
||||
for (const c of cs) {
|
||||
for (const aid of c.alert_ids ?? []) {
|
||||
allAlerts.push({
|
||||
id: aid,
|
||||
org_id: c.org_id,
|
||||
tx_id: '',
|
||||
rule_id: '',
|
||||
rule_name: '',
|
||||
severity: c.severity,
|
||||
score: 0,
|
||||
action_taken: '',
|
||||
created_at: c.opened_at,
|
||||
updated_at: c.updated_at,
|
||||
})
|
||||
}
|
||||
}
|
||||
setAlerts(allAlerts)
|
||||
})
|
||||
.catch((e) => setErr(e.message))
|
||||
}, [])
|
||||
|
||||
const filtered =
|
||||
sevFilter === 'all' ? alerts : alerts.filter((a) => a.severity === sevFilter)
|
||||
|
||||
const severities = ['all', 'low', 'medium', 'high', 'critical']
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<h1 style={{ fontSize: 20, fontWeight: 600, color: '#fff' }}>Alerts</h1>
|
||||
|
||||
{err && <div style={{ color: '#ef4444', fontSize: 13 }}>{err}</div>}
|
||||
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{severities.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setSevFilter(s)}
|
||||
style={{
|
||||
padding: '4px 12px',
|
||||
borderRadius: 12,
|
||||
border: '1px solid #262626',
|
||||
cursor: 'pointer',
|
||||
fontSize: 12,
|
||||
background: sevFilter === s ? '#262626' : 'transparent',
|
||||
color: sevFilter === s ? '#fff' : '#737373',
|
||||
}}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid #262626', color: '#737373', textAlign: 'left' }}>
|
||||
<th style={th}>Time</th>
|
||||
<th style={th}>Alert ID</th>
|
||||
<th style={th}>Severity</th>
|
||||
<th style={th}>Score</th>
|
||||
<th style={th}>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((a) => (
|
||||
<>
|
||||
<tr
|
||||
key={a.id}
|
||||
onClick={() => setExpanded(expanded === a.id ? null : a.id)}
|
||||
style={{ borderBottom: '1px solid #171717', cursor: 'pointer' }}
|
||||
>
|
||||
<td style={{ ...td, color: '#737373' }}>
|
||||
{new Date(a.created_at).toLocaleString()}
|
||||
</td>
|
||||
<td style={{ ...td, fontFamily: 'monospace', fontSize: 12 }}>
|
||||
{a.id.slice(0, 8)}
|
||||
</td>
|
||||
<td style={td}>
|
||||
<SeverityBadge severity={a.severity} />
|
||||
</td>
|
||||
<td style={td}>{a.score.toFixed(2)}</td>
|
||||
<td style={td}>{a.action_taken || '-'}</td>
|
||||
</tr>
|
||||
{expanded === a.id && (
|
||||
<tr key={`${a.id}-detail`}>
|
||||
<td colSpan={5} style={{ padding: 12, background: '#0a0a0a' }}>
|
||||
<div style={{ fontSize: 12, color: '#a3a3a3' }}>
|
||||
<div>Full ID: {a.id}</div>
|
||||
<div>Transaction: {a.tx_id || '-'}</div>
|
||||
<div>Rule: {a.rule_name || a.rule_id || '-'}</div>
|
||||
<div>Decision: {a.decision || '-'}</div>
|
||||
{a.score_breakdown && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<div style={{ marginBottom: 4, color: '#737373' }}>Score Breakdown:</div>
|
||||
{Object.entries(a.score_breakdown).map(([k, v]) => (
|
||||
<div key={k} style={{ display: 'flex', gap: 8 }}>
|
||||
<span style={{ color: '#525252' }}>{k}:</span>
|
||||
<span>{v.toFixed(3)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} style={{ padding: '16px 12px', color: '#525252' }}>
|
||||
No alerts
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const th: React.CSSProperties = { padding: '8px 12px', fontWeight: 500 }
|
||||
const td: React.CSSProperties = { padding: '8px 12px' }
|
||||
|
||||
function SeverityBadge({ severity }: { severity: string }) {
|
||||
const colors: Record<string, string> = {
|
||||
low: '#3b82f6',
|
||||
medium: '#f59e0b',
|
||||
high: '#f97316',
|
||||
critical: '#ef4444',
|
||||
}
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
padding: '2px 8px',
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
background: `${colors[severity] ?? '#262626'}20`,
|
||||
color: colors[severity] ?? '#a3a3a3',
|
||||
}}
|
||||
>
|
||||
{severity}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { listCases, type Case } from '../api'
|
||||
|
||||
const statuses = ['all', 'open', 'in_review', 'escalated', 'closed'] as const
|
||||
|
||||
export function Cases() {
|
||||
const [cases, setCases] = useState<Case[]>([])
|
||||
const [filter, setFilter] = useState<string>('all')
|
||||
const [expanded, setExpanded] = useState<string | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
listCases(filter)
|
||||
.then(setCases)
|
||||
.catch((e) => setErr(e.message))
|
||||
}, [filter])
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<h1 style={{ fontSize: 20, fontWeight: 600, color: '#fff' }}>Cases</h1>
|
||||
|
||||
{err && <div style={{ color: '#ef4444', fontSize: 13 }}>{err}</div>}
|
||||
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{statuses.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setFilter(s)}
|
||||
style={{
|
||||
padding: '6px 14px',
|
||||
borderRadius: 6,
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: 13,
|
||||
background: filter === s ? '#262626' : 'transparent',
|
||||
color: filter === s ? '#fff' : '#737373',
|
||||
}}
|
||||
>
|
||||
{s.replace('_', ' ')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid #262626', color: '#737373', textAlign: 'left' }}>
|
||||
<th style={th}>#</th>
|
||||
<th style={th}>Status</th>
|
||||
<th style={th}>Severity</th>
|
||||
<th style={th}>Assignee</th>
|
||||
<th style={th}>Opened</th>
|
||||
<th style={th}>Resolution</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{cases.map((c) => (
|
||||
<>
|
||||
<tr
|
||||
key={c.id}
|
||||
onClick={() => setExpanded(expanded === c.id ? null : c.id)}
|
||||
style={{ borderBottom: '1px solid #171717', cursor: 'pointer' }}
|
||||
>
|
||||
<td style={td}>{c.number}</td>
|
||||
<td style={td}>
|
||||
<StatusBadge status={c.status} />
|
||||
</td>
|
||||
<td style={td}>
|
||||
<SeverityBadge severity={c.severity} />
|
||||
</td>
|
||||
<td style={td}>{c.assignee_id || '-'}</td>
|
||||
<td style={{ ...td, color: '#737373' }}>
|
||||
{new Date(c.opened_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td style={td}>{c.resolution || '-'}</td>
|
||||
</tr>
|
||||
{expanded === c.id && (
|
||||
<tr key={`${c.id}-detail`}>
|
||||
<td colSpan={6} style={{ padding: '12px', background: '#0a0a0a' }}>
|
||||
<div style={{ fontSize: 12, color: '#a3a3a3' }}>
|
||||
<div>Case ID: {c.id}</div>
|
||||
<div>Alerts: {c.alert_ids?.join(', ') || 'none'}</div>
|
||||
<div>Entities: {c.entity_ids?.join(', ') || 'none'}</div>
|
||||
{c.closed_at && <div>Closed: {new Date(c.closed_at).toLocaleString()}</div>}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
{cases.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} style={{ padding: '16px 12px', color: '#525252' }}>
|
||||
No cases
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const th: React.CSSProperties = { padding: '8px 12px', fontWeight: 500 }
|
||||
const td: React.CSSProperties = { padding: '8px 12px' }
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const colors: Record<string, string> = {
|
||||
open: '#3b82f6',
|
||||
in_review: '#f59e0b',
|
||||
escalated: '#f97316',
|
||||
closed: '#525252',
|
||||
}
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
padding: '2px 8px',
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
background: `${colors[status] ?? '#262626'}20`,
|
||||
color: colors[status] ?? '#a3a3a3',
|
||||
}}
|
||||
>
|
||||
{status.replace('_', ' ')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function SeverityBadge({ severity }: { severity: string }) {
|
||||
const colors: Record<string, string> = {
|
||||
low: '#3b82f6',
|
||||
medium: '#f59e0b',
|
||||
high: '#f97316',
|
||||
critical: '#ef4444',
|
||||
}
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
padding: '2px 8px',
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
background: `${colors[severity] ?? '#262626'}20`,
|
||||
color: colors[severity] ?? '#a3a3a3',
|
||||
}}
|
||||
>
|
||||
{severity}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { listCases, health, type Case } from '../api'
|
||||
|
||||
const cardStyle: React.CSSProperties = {
|
||||
background: '#0a0a0a',
|
||||
border: '1px solid #262626',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
minWidth: 160,
|
||||
}
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
fontSize: 12,
|
||||
color: '#737373',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}
|
||||
|
||||
const valueStyle: React.CSSProperties = {
|
||||
fontSize: 28,
|
||||
fontWeight: 600,
|
||||
color: '#fff',
|
||||
marginTop: 4,
|
||||
}
|
||||
|
||||
export function Dashboard() {
|
||||
const [cases, setCases] = useState<Case[]>([])
|
||||
const [status, setStatus] = useState<string>('')
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
listCases()
|
||||
.then(setCases)
|
||||
.catch((e) => setErr(e.message))
|
||||
health()
|
||||
.then((h) => setStatus(h.status))
|
||||
.catch(() => setStatus('unreachable'))
|
||||
}, [])
|
||||
|
||||
const open = cases.filter((c) => c.status === 'open').length
|
||||
const inReview = cases.filter((c) => c.status === 'in_review').length
|
||||
const escalated = cases.filter((c) => c.status === 'escalated').length
|
||||
const closed = cases.filter((c) => c.status === 'closed').length
|
||||
const recent = cases.slice(0, 5)
|
||||
|
||||
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>
|
||||
<span style={{ fontSize: 12, color: status === 'ok' ? '#22c55e' : '#ef4444' }}>
|
||||
Engine: {status || 'loading'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{err && (
|
||||
<div style={{ color: '#ef4444', fontSize: 13 }}>{err}</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
|
||||
<div style={cardStyle}>
|
||||
<div style={labelStyle}>Open Cases</div>
|
||||
<div style={valueStyle}>{open}</div>
|
||||
</div>
|
||||
<div style={cardStyle}>
|
||||
<div style={labelStyle}>In Review</div>
|
||||
<div style={valueStyle}>{inReview}</div>
|
||||
</div>
|
||||
<div style={cardStyle}>
|
||||
<div style={labelStyle}>Escalated</div>
|
||||
<div style={valueStyle}>{escalated}</div>
|
||||
</div>
|
||||
<div style={cardStyle}>
|
||||
<div style={labelStyle}>Closed</div>
|
||||
<div style={valueStyle}>{closed}</div>
|
||||
</div>
|
||||
<div style={cardStyle}>
|
||||
<div style={labelStyle}>Total Cases</div>
|
||||
<div style={valueStyle}>{cases.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<div style={labelStyle}>Recent Cases</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' }}>#</th>
|
||||
<th style={{ padding: '8px 12px' }}>Status</th>
|
||||
<th style={{ padding: '8px 12px' }}>Severity</th>
|
||||
<th style={{ padding: '8px 12px' }}>Opened</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recent.map((c) => (
|
||||
<tr key={c.id} style={{ borderBottom: '1px solid #171717' }}>
|
||||
<td style={{ padding: '8px 12px', fontFamily: 'monospace' }}>
|
||||
{c.number}
|
||||
</td>
|
||||
<td style={{ padding: '8px 12px' }}>
|
||||
<Badge text={c.status} />
|
||||
</td>
|
||||
<td style={{ padding: '8px 12px' }}>
|
||||
<SeverityBadge severity={c.severity} />
|
||||
</td>
|
||||
<td style={{ padding: '8px 12px', color: '#737373' }}>
|
||||
{new Date(c.opened_at).toLocaleDateString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{recent.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} style={{ padding: '16px 12px', color: '#525252' }}>
|
||||
No cases yet
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Badge({ text }: { text: string }) {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '2px 8px',
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
background: '#262626',
|
||||
color: '#d4d4d4',
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function SeverityBadge({ severity }: { severity: string }) {
|
||||
const colors: Record<string, string> = {
|
||||
low: '#3b82f6',
|
||||
medium: '#f59e0b',
|
||||
high: '#f97316',
|
||||
critical: '#ef4444',
|
||||
}
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '2px 8px',
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
background: `${colors[severity] ?? '#525252'}20`,
|
||||
color: colors[severity] ?? '#a3a3a3',
|
||||
}}
|
||||
>
|
||||
{severity}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { listRules, testRule, type Rule, type RuleTestResult } from '../api'
|
||||
|
||||
export function Rules() {
|
||||
const [rules, setRules] = useState<Rule[]>([])
|
||||
const [err, setErr] = useState('')
|
||||
const [editId, setEditId] = useState<string | null>(null)
|
||||
const [testDsl, setTestDsl] = useState('')
|
||||
const [testResult, setTestResult] = useState<RuleTestResult | null>(null)
|
||||
const [testErr, setTestErr] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
listRules()
|
||||
.then(setRules)
|
||||
.catch((e) => setErr(e.message))
|
||||
}, [])
|
||||
|
||||
const handleTest = async () => {
|
||||
setTestErr('')
|
||||
setTestResult(null)
|
||||
try {
|
||||
const r = await testRule(testDsl)
|
||||
setTestResult(r)
|
||||
} catch (e: unknown) {
|
||||
setTestErr(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<h1 style={{ fontSize: 20, fontWeight: 600, color: '#fff' }}>Rules</h1>
|
||||
|
||||
{err && <div style={{ color: '#ef4444', fontSize: 13 }}>{err}</div>}
|
||||
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid #262626', color: '#737373', textAlign: 'left' }}>
|
||||
<th style={th}>Name</th>
|
||||
<th style={th}>DSL</th>
|
||||
<th style={th}>Severity</th>
|
||||
<th style={th}>Action</th>
|
||||
<th style={th}>Enabled</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rules.map((r) => (
|
||||
<>
|
||||
<tr
|
||||
key={r.id}
|
||||
onClick={() => {
|
||||
setEditId(editId === r.id ? null : r.id)
|
||||
setTestDsl(r.dsl)
|
||||
setTestResult(null)
|
||||
setTestErr('')
|
||||
}}
|
||||
style={{ borderBottom: '1px solid #171717', cursor: 'pointer' }}
|
||||
>
|
||||
<td style={td}>{r.name}</td>
|
||||
<td style={{ ...td, fontFamily: 'monospace', fontSize: 12, maxWidth: 300, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{r.dsl}
|
||||
</td>
|
||||
<td style={td}>
|
||||
<SeverityBadge severity={r.severity} />
|
||||
</td>
|
||||
<td style={td}>{r.action}</td>
|
||||
<td style={td}>
|
||||
<span style={{ color: r.enabled ? '#22c55e' : '#525252' }}>
|
||||
{r.enabled ? 'on' : 'off'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{editId === r.id && (
|
||||
<tr key={`${r.id}-detail`}>
|
||||
<td colSpan={5} style={{ padding: 16, background: '#0a0a0a' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{ fontSize: 12, color: '#a3a3a3' }}>
|
||||
<div>ID: {r.id}</div>
|
||||
<div>Description: {r.description || '-'}</div>
|
||||
<div>Weight: {r.weight}</div>
|
||||
<div>Priority: {r.priority}</div>
|
||||
{r.jurisdiction_filter?.length ? (
|
||||
<div>Jurisdictions: {r.jurisdiction_filter.join(', ')}</div>
|
||||
) : null}
|
||||
{r.asset_class_filter?.length ? (
|
||||
<div>Asset classes: {r.asset_class_filter.join(', ')}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, color: '#737373', display: 'block', marginBottom: 4 }}>
|
||||
Test DSL
|
||||
</label>
|
||||
<textarea
|
||||
value={testDsl}
|
||||
onChange={(e) => setTestDsl(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: 60,
|
||||
background: '#171717',
|
||||
border: '1px solid #262626',
|
||||
borderRadius: 6,
|
||||
color: '#e5e5e5',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 13,
|
||||
padding: 8,
|
||||
resize: 'vertical',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
|
||||
<button onClick={handleTest} style={btnStyle}>
|
||||
Test Rule
|
||||
</button>
|
||||
{testResult && (
|
||||
<span style={{ fontSize: 13, color: testResult.match ? '#22c55e' : '#737373' }}>
|
||||
{testResult.match ? 'Matched' : 'No match'}
|
||||
</span>
|
||||
)}
|
||||
{testErr && (
|
||||
<span style={{ fontSize: 13, color: '#ef4444' }}>{testErr}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
{rules.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} style={{ padding: '16px 12px', color: '#525252' }}>
|
||||
No rules loaded
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const th: React.CSSProperties = { padding: '8px 12px', fontWeight: 500 }
|
||||
const td: React.CSSProperties = { padding: '8px 12px' }
|
||||
|
||||
const btnStyle: React.CSSProperties = {
|
||||
padding: '6px 16px',
|
||||
borderRadius: 6,
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
background: '#262626',
|
||||
color: '#fff',
|
||||
}
|
||||
|
||||
function SeverityBadge({ severity }: { severity: string }) {
|
||||
const colors: Record<string, string> = {
|
||||
low: '#3b82f6',
|
||||
medium: '#f59e0b',
|
||||
high: '#f97316',
|
||||
critical: '#ef4444',
|
||||
}
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
padding: '2px 8px',
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
background: `${colors[severity] ?? '#262626'}20`,
|
||||
color: colors[severity] ?? '#a3a3a3',
|
||||
}}
|
||||
>
|
||||
{severity}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useState } from 'react'
|
||||
import { searchSanctions, type SanctionsResult } from '../api'
|
||||
|
||||
export function Sanctions() {
|
||||
const [name, setName] = useState('')
|
||||
const [dob, setDob] = useState('')
|
||||
const [results, setResults] = useState<SanctionsResult[] | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
const handleSearch = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!name.trim()) return
|
||||
setErr('')
|
||||
setLoading(true)
|
||||
try {
|
||||
const r = await searchSanctions(name.trim(), dob.trim() || undefined)
|
||||
setResults(r)
|
||||
} catch (e: unknown) {
|
||||
setErr(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<h1 style={{ fontSize: 20, fontWeight: 600, color: '#fff' }}>Sanctions Search</h1>
|
||||
|
||||
<form onSubmit={handleSearch} style={{ display: 'flex', gap: 12, alignItems: 'flex-end' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<label style={{ fontSize: 12, color: '#737373' }}>Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Full name"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<label style={{ fontSize: 12, color: '#737373' }}>Date of Birth</label>
|
||||
<input
|
||||
type="text"
|
||||
value={dob}
|
||||
onChange={(e) => setDob(e.target.value)}
|
||||
placeholder="YYYY-MM-DD (optional)"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" disabled={loading || !name.trim()} style={btnStyle}>
|
||||
{loading ? 'Searching...' : 'Search'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{err && <div style={{ color: '#ef4444', fontSize: 13 }}>{err}</div>}
|
||||
|
||||
{results !== null && (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid #262626', color: '#737373', textAlign: 'left' }}>
|
||||
<th style={th}>Name</th>
|
||||
<th style={th}>Aliases</th>
|
||||
<th style={th}>Source</th>
|
||||
<th style={th}>Nationality</th>
|
||||
<th style={th}>Type</th>
|
||||
<th style={th}>Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{results.map((r, i) => (
|
||||
<tr key={`${r.id}-${i}`} style={{ borderBottom: '1px solid #171717' }}>
|
||||
<td style={td}>{r.name}</td>
|
||||
<td style={{ ...td, fontSize: 12, color: '#a3a3a3' }}>
|
||||
{r.aliases?.join(', ') || '-'}
|
||||
</td>
|
||||
<td style={td}>
|
||||
<span
|
||||
style={{
|
||||
padding: '2px 8px',
|
||||
borderRadius: 4,
|
||||
fontSize: 11,
|
||||
background: '#262626',
|
||||
color: '#d4d4d4',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
{r.list_id}
|
||||
</span>
|
||||
</td>
|
||||
<td style={td}>{r.nationality || '-'}</td>
|
||||
<td style={td}>{r.type}</td>
|
||||
<td style={td}>
|
||||
<span style={{ color: r.score >= 0.9 ? '#ef4444' : r.score >= 0.7 ? '#f59e0b' : '#a3a3a3' }}>
|
||||
{r.score.toFixed(3)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{results.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} style={{ padding: '16px 12px', color: '#525252' }}>
|
||||
No matches found
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const th: React.CSSProperties = { padding: '8px 12px', fontWeight: 500 }
|
||||
const td: React.CSSProperties = { padding: '8px 12px' }
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
padding: '8px 12px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid #262626',
|
||||
background: '#0a0a0a',
|
||||
color: '#e5e5e5',
|
||||
fontSize: 14,
|
||||
outline: 'none',
|
||||
minWidth: 200,
|
||||
}
|
||||
|
||||
const btnStyle: React.CSSProperties = {
|
||||
padding: '8px 20px',
|
||||
borderRadius: 6,
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
background: '#262626',
|
||||
color: '#fff',
|
||||
}
|
||||
@@ -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: '/_/aml/',
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
output: { manualChunks: undefined },
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/v1/aml': 'http://localhost:8090',
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user