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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
### Added
- DZDP concentration view in geolocation explorer with hero stats, anchor point map, country bar chart, ASN concentration list, and CTA banner (#550)
- View switcher (QA Explorer / DZDP Concentration / DZDP Validators) with URL query param sync (#550)
- DZDP validator explorer view with scatter map, sortable table, tier breakdown charts, and ghost rows CTA (#551)
4 changes: 4 additions & 0 deletions api/handlers/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ type API struct {
BuildCommit string
BuildDate string

// GeoValCache holds the full validator dataset in memory so filtered
// requests (metro, dz_filter) can skip the expensive ClickHouse query.
GeoValCache GeoValidatorCache

// Workflow manager (manages background workflow execution)
Manager *WorkflowManager

Expand Down
106 changes: 88 additions & 18 deletions api/handlers/geo_validators.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,46 @@ import (
"net/http"
"sort"
"strings"
"sync"
"time"

"github.com/ClickHouse/clickhouse-go/v2/lib/proto"
"github.com/malbeclabs/lake/api/handlers/dberror"
"github.com/malbeclabs/lake/api/metrics"
)

// GeoValidatorCache caches the full validator list (with tiers assigned) in
// memory so that filtered requests (metro, dz_filter) avoid the expensive
// ClickHouse CROSS JOIN query. The background page-cache refresh populates
// this every ~30s.
type GeoValidatorCache struct {
mu sync.RWMutex
validators []GeoValidatorItem
updatedAt time.Time
}

const geoValCacheMaxAge = 2 * time.Minute

func (c *GeoValidatorCache) store(validators []GeoValidatorItem) {
c.mu.Lock()
defer c.mu.Unlock()
cp := make([]GeoValidatorItem, len(validators))
copy(cp, validators)
c.validators = cp
c.updatedAt = time.Now()
}

func (c *GeoValidatorCache) load() ([]GeoValidatorItem, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
if c.validators == nil || time.Since(c.updatedAt) > geoValCacheMaxAge {
return nil, false
}
cp := make([]GeoValidatorItem, len(c.validators))
copy(cp, c.validators)
return cp, true
}

type GeoValidatorItem struct {
VotePubkey string `json:"vote_pubkey"`
NodePubkey string `json:"node_pubkey"`
Expand Down Expand Up @@ -90,6 +123,32 @@ func isDefaultGeoValidatorsRequest(r *http.Request) bool {
}

func (a *API) FetchGeoValidatorsData(ctx context.Context, metro, dzFilter string) (*GeoValidatorsResponse, error) {
hasFilters := metro != "" || dzFilter != ""

// For filtered requests, try the in-memory cache first to avoid
// the expensive ClickHouse CROSS JOIN query.
if hasFilters {
if cached, ok := a.GeoValCache.load(); ok {
return buildGeoValidatorsResponse(cached, metro, dzFilter), nil
}
}

allValidators, err := a.queryGeoValidators(ctx)
if err != nil {
return nil, err
}

assignValidatorTiers(allValidators)

// Populate the in-memory cache so subsequent filtered requests are instant.
a.GeoValCache.store(allValidators)

return buildGeoValidatorsResponse(allValidators, metro, dzFilter), nil
}

// queryGeoValidators runs the expensive ClickHouse query that joins DZDP
// location data with gossip nodes, vote accounts, and metros.
func (a *API) queryGeoValidators(ctx context.Context) ([]GeoValidatorItem, error) {
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()

Expand Down Expand Up @@ -182,53 +241,64 @@ func (a *API) FetchGeoValidatorsData(ctx context.Context, metro, dzFilter string
rows, err := a.DB.Query(ctx, query)
metrics.RecordClickHouseQuery(time.Since(start), err)
if err != nil {
// Return empty response when DZDP tables aren't available or accessible.
// Return empty result when DZDP tables aren't available or accessible.
// Code 60 = UNKNOWN_TABLE, Code 81 = UNKNOWN_DATABASE, Code 497 = NOT_ENOUGH_PRIVILEGES.
var chErr *proto.Exception
if errors.As(err, &chErr) && (chErr.Code == 60 || chErr.Code == 81 || chErr.Code == 497) {
slog.Warn("geo validators: DZDP tables not available, returning empty response",
"dzdp_db", dzdpDB, "ch_error_code", chErr.Code, "error", err)
return &GeoValidatorsResponse{
Validators: []GeoValidatorItem{},
TierDistribution: []GeoTierDistribution{},
MetroBreakdown: []GeoMetroBreakdown{},
}, nil
return nil, nil
}
return nil, err
}
defer rows.Close()

var allValidators []GeoValidatorItem
var validators []GeoValidatorItem
for rows.Next() {
var v GeoValidatorItem
if err := rows.Scan(&v.VotePubkey, &v.NodePubkey, &v.StakeSol, &v.Commission,
&v.MetroCode, &v.ASN, &v.ASNOrg, &v.CountryCode, &v.Name, &v.Datacenter,
&v.IsDZ, &v.DZDPLat, &v.DZDPLng); err != nil {
return nil, err
}
allValidators = append(allValidators, v)
validators = append(validators, v)
}
if err := rows.Err(); err != nil {
return nil, err
}

// Assign tiers globally (before filtering) so a validator's tier reflects
// its position among all validators, not just the filtered subset.
// Validators are already sorted by stake DESC from the query.
return validators, nil
}

// assignValidatorTiers assigns tier labels based on cumulative stake.
// Validators must be sorted by stake DESC.
func assignValidatorTiers(validators []GeoValidatorItem) {
var globalStake float64
for _, v := range allValidators {
for _, v := range validators {
globalStake += v.StakeSol
}
var cumStake float64
for i := range allValidators {
for i := range validators {
if globalStake > 0 && cumStake/globalStake < 0.333 {
allValidators[i].Tier = "super"
validators[i].Tier = "super"
} else if globalStake > 0 && cumStake/globalStake < 0.666 {
allValidators[i].Tier = "high"
validators[i].Tier = "high"
} else {
allValidators[i].Tier = "mid"
validators[i].Tier = "mid"
}
cumStake += validators[i].StakeSol
}
}

// buildGeoValidatorsResponse applies filters and computes aggregates from the
// full validator list (which must already have tiers assigned).
func buildGeoValidatorsResponse(allValidators []GeoValidatorItem, metro, dzFilter string) *GeoValidatorsResponse {
if allValidators == nil {
return &GeoValidatorsResponse{
Validators: []GeoValidatorItem{},
TierDistribution: []GeoTierDistribution{},
MetroBreakdown: []GeoMetroBreakdown{},
}
cumStake += allValidators[i].StakeSol
}

// Apply filters
Expand Down Expand Up @@ -321,5 +391,5 @@ func (a *API) FetchGeoValidatorsData(ctx context.Context, metro, dzFilter string
resp.MetroBreakdown = []GeoMetroBreakdown{}
}

return resp, nil
return resp
}
24 changes: 12 additions & 12 deletions web/src/components/dzdp-concentration-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,31 +150,31 @@ function AnchorPointMap({ data, metroCoords }: {
}

function CountryBarChart({ data }: { data: GeoConcentrationResponse }) {
const top15 = useMemo(
() => [...data.countries].sort((a, b) => b.stake_pct - a.stake_pct).slice(0, 15)
const sorted = useMemo(
() => [...data.countries].sort((a, b) => b.stake_pct - a.stake_pct)
.map((c) => ({ ...c, fill: c.stake_pct > WARN_COUNTRY_PCT ? '#f59e0b' : '#3b82f6' })),
[data.countries],
)

if (top15.length === 0) {
if (sorted.length === 0) {
return <div className="rounded-lg border border-border bg-card px-5 py-8 text-center text-sm text-muted-foreground">No country data available</div>
}

return (
<div className="rounded-lg border border-border bg-card overflow-hidden">
<div className="px-5 py-3 border-b border-border flex items-center justify-between">
<div className="rounded-lg border border-border bg-card overflow-hidden flex flex-col max-h-[500px]">
<div className="px-5 py-3 border-b border-border flex items-center justify-between flex-shrink-0">
<h3 className="text-sm font-medium">Stake by Country</h3>
<span className="text-[11px] text-muted-foreground">Warning threshold: {WARN_COUNTRY_PCT}%</span>
</div>
<div className="px-5 py-4">
<ResponsiveContainer width="100%" height={top15.length * 28 + 20}>
<BarChart data={top15} layout="vertical" margin={{ top: 0, right: 40, left: 0, bottom: 0 }}>
<div className="px-5 py-4 overflow-y-auto">
<ResponsiveContainer width="100%" height={sorted.length * 28 + 20}>
<BarChart data={sorted} layout="vertical" margin={{ top: 0, right: 40, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" horizontal={false} />
<XAxis type="number" tickLine={false} axisLine={false} tick={{ fontSize: 11 }} tickFormatter={(v: number) => `${v}%`} />
<YAxis dataKey="country_name" type="category" tickLine={false} axisLine={false} tick={{ fontSize: 11 }} width={50} />
<Tooltip cursor={{ fill: 'var(--muted)', opacity: 0.4 }} formatter={(value) => [`${Number(value).toFixed(1)}%`, 'Stake']} />
<Bar dataKey="stake_pct" radius={[0, 3, 3, 0]}>
{top15.map((entry, i) => <Cell key={i} fill={entry.fill} />)}
{sorted.map((entry, i) => <Cell key={i} fill={entry.fill} />)}
</Bar>
</BarChart>
</ResponsiveContainer>
Expand All @@ -191,11 +191,11 @@ function AsnList({ data }: { data: GeoConcentrationResponse }) {
}

return (
<div className="rounded-lg border border-border bg-card overflow-hidden">
<div className="px-5 py-3 border-b border-border">
<div className="rounded-lg border border-border bg-card overflow-hidden flex flex-col max-h-[500px]">
<div className="px-5 py-3 border-b border-border flex-shrink-0">
<h3 className="text-sm font-medium">ASN Concentration</h3>
</div>
<div className="divide-y divide-border">
<div className="divide-y divide-border overflow-y-auto">
{sorted.map((asn) => {
const concentrated = asn.stake_pct > WARN_ASN_PCT
return (
Expand Down
Loading