feat(v1.3.0): drop embedded SPA — per-brand FE Deployment owns the UI

The Go binary is now a data-layer service only. The `/` catch-all that
served an embedded SPA shell is gone; v1.3.0 keeps just the endpoints
the FE depends on:

  - /v1/*       — indexer + blockscout-compat REST APIs
  - /health     — health probe
  - /envs.js    — runtime brand/chain config (window.ENV)
  - /icon.svg, /logo.svg — per-host brand SVGs (overlay-friendly)
  - /icon-{slug}.svg, /logo-{slug}.svg — extra named brand assets

Every other path returns 404 so the IngressRoute hands it off to the
per-brand Blockscout FE Deployment (ghcr.io/{brand-org}/explore-{env}).

Concrete deltas:
  - frontend.go: drop handleSPA, writeIndex, f.index; replace with
    handleNamedBrandAsset + brandAssetNames discovery
  - frontend_test.go: TestFrontendNoSPA + TestFrontendBrandAssetsServed
    replace the index-html overlay tests
  - Dockerfile: drop Next.js frontend build stage; runtime image is just
    the Go binary + brand SVGs (no embedded HTML)
  - static/index.html: removed (60-byte placeholder)

The per-brand FE Deployments + IngressRoute patches that complete this
split live in the cluster (lux-mainnet + lux-testnet namespaces). Source
of the FE images: luxfi/explore via Dockerfile.branded.
This commit is contained in:
2026-06-10 13:21:01 -07:00
parent 73749c70f4
commit 3959357be3
4 changed files with 136 additions and 489 deletions
+9 -63
View File
@@ -1,60 +1,18 @@
# Unified Lux explorer image. Single Go binary, embedded SPA, indexer +
# graph as libraries. Deploy with a chains.yaml (or env vars) and persist
# /data; brand swap by remounting chains.yaml + assets, no rebuild needed.
# Lux Explorer data-layer image (v1.3.0+).
#
# As of v1.3.0 the Go binary is API-only — it serves /v1/* (indexer +
# blockscout-compat REST), /health, /envs.js, and per-host brand SVGs.
# Every other path returns 404 and the IngressRoute routes it to the
# per-brand Blockscout FE Deployment (ghcr.io/{brand-org}/explore-{env}).
# Built from luxfi/explore via Dockerfile.branded — see ~/work/lux/explore.
ARG GO_VERSION=1.26
ARG NODE_VERSION=22
ARG EXPLORE_REPO=https://github.com/luxfi/explore.git
ARG EXPLORE_REF=main
ARG INDEXER_REPO=https://github.com/luxfi/indexer.git
ARG INDEXER_REF=main
ARG GRAPH_REPO=https://github.com/luxfi/graph.git
ARG GRAPH_REF=main
# ---- Stage 1: build the SPA ----
#
# luxfi/explore is a Next.js `output: 'standalone'` SSR app, not a static
# export — so `pnpm build` does NOT produce /app/out/. The unified
# explorer needs static assets to embed via go:embed; until upstream
# explore ships `output: 'export'`, we ship a substantive runtime SPA
# shell from explorer/static/. The shell wires /envs.js (window.ENV)
# to render the chain list client-side. When upstream explore lands
# static-export support, this stage gains `pnpm build && cp .next/static
# /app/out/_next/static` and the shell can hydrate.
#
# Build-arg defaults shape the optional Next.js static export attempt
# (only used if explore.git lands `output: 'export'` upstream). Real
# branding comes from the runtime chains.yaml + brand assets mount on
# the explorer Deployment — no rebuild needed to swap.
FROM node:${NODE_VERSION}-alpine AS frontend
ARG EXPLORE_REPO
ARG EXPLORE_REF
ARG NETWORK_ID=96369
ARG NETWORK_NAME="Lux Network"
ARG NETWORK_CURRENCY_SYMBOL=LUX
RUN apk add --no-cache git
WORKDIR /app
# Best-effort: clone explore and try to produce a static export. If
# anything fails (missing pnpm-lockfile entry, missing env vars, SSR-only
# Next config), don't fail the image — explorer/static/ still ships.
RUN git clone --depth=1 --branch=${EXPLORE_REF} ${EXPLORE_REPO} . || true
ENV NEXT_PUBLIC_API_BASE_PATH=/v1/explorer
ENV NEXT_PUBLIC_NETWORK_ID=${NETWORK_ID}
ENV NEXT_PUBLIC_NETWORK_NAME=${NETWORK_NAME}
ENV NEXT_PUBLIC_NETWORK_CURRENCY_NAME=${NETWORK_CURRENCY_SYMBOL}
ENV NEXT_PUBLIC_NETWORK_CURRENCY_SYMBOL=${NETWORK_CURRENCY_SYMBOL}
ENV NEXT_PUBLIC_NETWORK_CURRENCY_DECIMALS=18
ENV NODE_OPTIONS=--max-old-space-size=8192
RUN if [ -f package.json ]; then \
corepack enable && pnpm install --frozen-lockfile --ignore-scripts && pnpm build || true; \
fi
# /app/out is created only if static export succeeded. The builder stage
# below COPYs it over explorer/static/ — but only if non-empty. The
# fallback path is the local explorer/static/index.html (substantive
# 372-byte SPA shell with id="root" + /envs.js bootstrap).
RUN mkdir -p /app/out
# ---- Stage 2: clone indexer + graph siblings, build the unified binary ----
# ---- Stage 1: clone indexer + graph siblings, build the unified binary ----
FROM golang:${GO_VERSION}-alpine AS builder
ARG INDEXER_REPO
ARG INDEXER_REF
@@ -69,18 +27,6 @@ RUN git clone --depth=1 --branch=${INDEXER_REF} ${INDEXER_REPO} indexer && \
WORKDIR /src/explorer
COPY . .
# explorer/static/ already contains the substantive SPA shell. Only
# overlay frontend /app/out/ if it has the static-exported assets (an
# index.html that's NOT the 60-byte stub). When upstream explore ships
# static-export, the cp succeeds and overlays the real assets.
RUN --mount=type=bind,from=frontend,source=/app/out,target=/tmp/frontend-out \
sh -eu -c '\
if [ -s /tmp/frontend-out/index.html ] && [ "$(wc -c </tmp/frontend-out/index.html)" -gt 100 ]; then \
echo "[frontend] overlaying static export onto explorer/static/"; \
cp -R /tmp/frontend-out/. /src/explorer/static/; \
else \
echo "[frontend] static export not produced, keeping committed explorer/static/ SPA shell"; \
fi'
# proxy.golang.org caches inconsistently for hanzoai/[email protected]
# (different POPs serve different zip hashes). -mod=mod populates go.sum
@@ -91,7 +37,7 @@ RUN rm -f go.sum && CGO_ENABLED=1 CGO_CFLAGS="-D_LARGEFILE64_SOURCE" GOSUMDB=off
-ldflags="-s -w -X main.version=${VERSION}" \
-o /out/explorer .
# ---- Stage 3: runtime ----
# ---- Stage 2: runtime ----
FROM alpine:3.21
RUN apk add --no-cache ca-certificates sqlite-libs wget
COPY --from=builder /out/explorer /usr/local/bin/explorer
+71 -86
View File
@@ -13,29 +13,35 @@ import (
"sync"
)
// staticFS is the embedded SPA. The Dockerfile populates static/ from a
// luxfi/explore build before the Go build runs; for local dev a placeholder
// index.html is committed so go:embed always succeeds.
// staticFS embeds named brand assets only — icon.svg and logo.svg
// fallbacks. As of v1.3.0 the Go binary no longer serves the Blockscout
// SPA; each brand's FE runs as its own per-(brand, env) Deployment
// (ghcr.io/{org}/explore-{env}:vX.Y.Z) and the IngressRoute routes the
// catch-all to that FE Service. The Go binary keeps /v1/* (indexer +
// blockscout-compat APIs), /health, /envs.js, and the per-host brand
// SVG endpoints — every other path returns 404.
//
//go:embed all:static
var staticFS embed.FS
// Frontend serves the embedded SPA, runtime config (/envs.js), and per-host
// brand assets (/icon.svg, /logo.svg). Brand assets read from disk on every
// request so a deploy can swap them without rebuilding the binary.
// Frontend serves runtime config (/envs.js) and per-host brand assets
// (/icon.svg, /logo.svg). Brand assets read from disk on every request so a
// deploy can swap them without rebuilding the binary.
//
// SPA override: if cfg.StaticDir (or $EXPLORER_STATIC_DIR) points at an
// existing directory, file lookups prefer the on-disk copy over the
// embedded FS. This lets a ConfigMap-mounted SPA replace the embedded
// 60-byte placeholder without a binary rebuild — useful when the
// upstream `explore` Next.js app hasn't shipped a static export yet but
// we want a richer in-cluster default than `<div id="root"></div>`.
// Static directory override: if cfg.StaticDir (or $EXPLORER_STATIC_DIR)
// points at an existing directory, brand-asset lookups prefer the on-disk
// copy over the embedded FS. This lets a ConfigMap-mounted SVG bundle
// override the in-binary defaults without rebuilding.
//
// SPA serving was removed in v1.3.0 — the catch-all `/` route is gone and
// any path not explicitly registered returns 404. The per-brand
// Blockscout FE Deployment (ghcr.io/{brand-org}/explore-{env}) owns the
// SPA surface.
type Frontend struct {
cfg Config
registry *ChainRegistry
root fs.FS // embedded SPA (always present)
overlay fs.FS // disk overlay (nil if cfg.StaticDir not set)
index []byte // resolved index.html (overlay > embedded > builtin)
root fs.FS // embedded brand-asset FS (always present)
overlay fs.FS // disk overlay (nil if cfg.StaticDir not set)
}
// NewFrontend returns a frontend handler bound to a config and chain registry.
@@ -55,12 +61,6 @@ func NewFrontend(cfg Config, r *ChainRegistry) (*Frontend, error) {
f.overlay = os.DirFS(dir)
}
}
if data := f.readStatic("index.html"); data != nil {
f.index = data
} else {
f.index = []byte("<!doctype html><title>Explorer</title>")
}
return f, nil
}
@@ -78,84 +78,69 @@ func (f *Frontend) readStatic(name string) []byte {
return nil
}
// Mount installs / (SPA), /envs.js, /icon.svg, /logo.svg on mux.
// Mount installs the data-layer endpoints — /envs.js, /icon.svg,
// /logo.svg, plus any extra brand SVGs present in the overlay (e.g.
// /icon-zoo.svg, /logo-lux.svg). No catch-all SPA route: any other path
// returns 404 so the IngressRoute layer routes it to the per-brand
// Blockscout FE Deployment.
func (f *Frontend) Mount(mux *http.ServeMux) {
mux.HandleFunc("GET /envs.js", f.handleEnvs)
mux.HandleFunc("GET /icon.svg", f.handleIcon)
mux.HandleFunc("GET /logo.svg", f.handleLogo)
mux.HandleFunc("/", f.handleSPA)
for _, name := range f.brandAssetNames() {
mux.HandleFunc("GET /"+name, f.handleNamedBrandAsset(name))
}
}
// handleSPA serves embedded static assets and falls back to index.html for
// any path the SPA owns (client-side routing).
//
// HEAD-method note: the bundled SPA opens EventSource (Server-Sent Events)
// channels for live block / token / tx streams and pre-flights each URL
// with a HEAD request:
//
// fetch(url, {method: 'HEAD'}).then(r => r.ok ? openEventSource() : disable());
//
// If we serve a 200 (because of the SPA fallback to index.html), the SPA
// proceeds to open the EventSource — which then immediately aborts because
// the response body is text/html, not text/event-stream. Browser logs
// "EventSource's response has a MIME type ('text/html') that is not
// 'text/event-stream'" for every channel on every page load — noisy and
// disables the channel anyway.
//
// Fix: return 404 for HEAD requests on the SPA-fallback path. Real assets
// (anything in `f.root`) still serve normally on HEAD; only the index.html
// fallback gets the 404 so the SSE pre-flight cleanly fails and the SPA
// disables that channel on its own. Visible browser load is unchanged
// because the SPA only HEAD-probes its SSE endpoints, never the SPA URL
// itself.
func (f *Frontend) handleSPA(w http.ResponseWriter, r *http.Request) {
p := strings.TrimPrefix(r.URL.Path, "/")
if p == "" {
if r.Method == http.MethodHead {
http.NotFound(w, r)
// brandAssetNames lists every per-brand SVG/PNG file the overlay (or the
// embedded FS) carries. Used to register one explicit handler per file at
// startup so the rest of the path-space cleanly 404s.
func (f *Frontend) brandAssetNames() []string {
seen := map[string]struct{}{}
var out []string
collect := func(dir fs.FS) {
if dir == nil {
return
}
f.writeIndex(w)
return
}
// Overlay wins if it has the file (any non-directory entry); falls
// back to embedded; falls back to index.html (SPA-routing).
if f.overlay != nil {
if file, err := f.overlay.Open(p); err == nil {
st, statErr := file.Stat()
file.Close()
if statErr == nil && !st.IsDir() {
http.ServeFileFS(w, r, f.overlay, p)
return
entries, err := fs.ReadDir(dir, ".")
if err != nil {
return
}
for _, e := range entries {
n := e.Name()
if e.IsDir() || n == "icon.svg" || n == "logo.svg" {
continue
}
switch strings.ToLower(filepath.Ext(n)) {
case ".svg", ".png", ".jpg", ".jpeg", ".webp", ".ico":
default:
continue
}
if _, ok := seen[n]; ok {
continue
}
seen[n] = struct{}{}
out = append(out, n)
}
}
file, err := f.root.Open(p)
if err != nil {
if r.Method == http.MethodHead {
http.NotFound(w, r)
return
}
f.writeIndex(w)
return
}
defer file.Close()
stat, err := file.Stat()
if err != nil || stat.IsDir() {
if r.Method == http.MethodHead {
http.NotFound(w, r)
return
}
f.writeIndex(w)
return
}
http.ServeFileFS(w, r, f.root, p)
collect(f.overlay)
collect(f.root)
return out
}
func (f *Frontend) writeIndex(w http.ResponseWriter) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
w.Write(f.index)
// handleNamedBrandAsset returns a handler that serves a specific named
// file from the overlay (preferred) or the embedded FS. 404 if neither
// has it.
func (f *Frontend) handleNamedBrandAsset(name string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if data := f.readStatic(name); data != nil {
w.Header().Set("Content-Type", contentTypeFor(name))
w.Header().Set("Cache-Control", "public, max-age=300")
w.Write(data)
return
}
http.NotFound(w, r)
}
}
// handleEnvs returns runtime config the SPA reads instead of build-time env
+56 -67
View File
@@ -9,69 +9,44 @@ import (
"testing"
)
// TestFrontendStaticOverlay verifies that NewFrontend prefers the on-disk
// static dir over the embedded FS when cfg.StaticDir points at a real
// directory. The overlay use-case: ConfigMap-mounting a richer SPA shell
// into an existing image without rebuilding.
func TestFrontendStaticOverlay(t *testing.T) {
dir := t.TempDir()
want := "<!doctype html><title>OVERLAY</title>"
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte(want), 0644); err != nil {
t.Fatal(err)
}
cfg := Config{StaticDir: dir, BrandDefault: Brand{Name: "Test"}}
f, err := NewFrontend(cfg, NewChainRegistry())
if err != nil {
t.Fatal(err)
}
mux := http.NewServeMux()
f.Mount(mux)
req := httptest.NewRequest("GET", "/", nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
got := rec.Body.String()
if !strings.Contains(got, "OVERLAY") {
t.Fatalf("expected overlay index.html, got %q", got)
}
}
// TestFrontendNoOverlay verifies that with no StaticDir, the embedded
// index.html is served (current behaviour — pre-overlay).
func TestFrontendNoOverlay(t *testing.T) {
// TestFrontendNoSPA verifies the v1.3.0 contract — the Go binary does
// NOT serve a SPA. Any non-data-layer path returns 404 so the
// IngressRoute hands it off to the per-brand Blockscout FE Deployment.
// Compare to the pre-v1.3.0 behaviour where `/` returned an embedded
// index.html.
func TestFrontendNoSPA(t *testing.T) {
cfg := Config{BrandDefault: Brand{Name: "Test"}}
f, err := NewFrontend(cfg, NewChainRegistry())
if err != nil {
t.Fatal(err)
}
if f.overlay != nil {
t.Fatal("expected nil overlay when StaticDir empty")
}
mux := http.NewServeMux()
f.Mount(mux)
req := httptest.NewRequest("GET", "/", nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
got := rec.Body.String()
if !strings.Contains(got, "doctype html") {
t.Fatalf("expected fallback index.html, got %q", got)
for _, p := range []string{"/", "/block/1", "/_next/static/chunks/foo.js", "/some/random/path"} {
req := httptest.NewRequest("GET", p, nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != 404 {
t.Errorf("path %q: got HTTP %d, want 404 (FE Deployment owns this surface now)", p, rec.Code)
}
}
}
// TestFrontendOverlayMissingFileFallsBack covers the case where the
// overlay dir exists but a particular file is missing — the request
// falls through to the embedded FS (and ultimately to index.html for
// SPA-routing).
func TestFrontendOverlayMissingFileFallsBack(t *testing.T) {
// TestFrontendBrandAssetsServed verifies the data-layer endpoints still
// work — /envs.js, /icon.svg, /logo.svg, and any extra named asset (e.g.
// /icon-zoo.svg) the overlay carries. These are what the FE depends on
// from the Go binary.
func TestFrontendBrandAssetsServed(t *testing.T) {
dir := t.TempDir()
// Only index.html in overlay; no other assets.
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<!doctype html>OVERLAY"), 0644); err != nil {
if err := os.WriteFile(filepath.Join(dir, "icon.svg"), []byte("<svg/>"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "logo.svg"), []byte("<svg id=logo/>"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "icon-zoo.svg"), []byte("<svg id=zoo/>"), 0644); err != nil {
t.Fatal(err)
}
@@ -84,24 +59,32 @@ func TestFrontendOverlayMissingFileFallsBack(t *testing.T) {
mux := http.NewServeMux()
f.Mount(mux)
// Request a path the overlay doesn't have. With SPA-routing, the
// handler returns index.html — which IS in the overlay.
req := httptest.NewRequest("GET", "/some/spa/route", nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
got := rec.Body.String()
if !strings.Contains(got, "OVERLAY") {
t.Fatalf("expected overlay index.html (SPA fallback), got %q", got)
for _, tc := range []struct {
path string
want string
}{
{"/icon.svg", "<svg/>"},
{"/logo.svg", "<svg id=logo/>"},
{"/icon-zoo.svg", "<svg id=zoo/>"},
} {
req := httptest.NewRequest("GET", tc.path, nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Errorf("path %q: got HTTP %d, want 200", tc.path, rec.Code)
}
if got := rec.Body.String(); got != tc.want {
t.Errorf("path %q: got body %q, want %q", tc.path, got, tc.want)
}
}
}
// TestFrontendEnvStaticDir verifies $EXPLORER_STATIC_DIR is the env-var
// equivalent of cfg.StaticDir. Empty cfg + env-var-set overlay should
// still pick up the overlay.
func TestFrontendEnvStaticDir(t *testing.T) {
// TestFrontendOverlayFromEnv verifies $EXPLORER_STATIC_DIR is the env
// equivalent of cfg.StaticDir. The brand asset must be served from the
// overlay even with an empty cfg.
func TestFrontendOverlayFromEnv(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("ENV-OVERLAY"), 0644); err != nil {
if err := os.WriteFile(filepath.Join(dir, "icon.svg"), []byte("<svg id=env/>"), 0644); err != nil {
t.Fatal(err)
}
t.Setenv("EXPLORER_STATIC_DIR", dir)
@@ -112,10 +95,16 @@ func TestFrontendEnvStaticDir(t *testing.T) {
t.Fatal(err)
}
if f.overlay == nil {
t.Fatal("expected overlay populated from env var")
t.Fatal("expected overlay populated from $EXPLORER_STATIC_DIR")
}
if !strings.Contains(string(f.index), "ENV-OVERLAY") {
t.Fatalf("expected env-overlay index, got %q", string(f.index))
mux := http.NewServeMux()
f.Mount(mux)
req := httptest.NewRequest("GET", "/icon.svg", nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if !strings.Contains(rec.Body.String(), "id=env") {
t.Fatalf("expected env-overlay icon, got %q", rec.Body.String())
}
}
-273
View File
@@ -1,273 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Lux Explorer</title>
<link rel="icon" type="image/svg+xml" href="/icon.svg">
<script src="/envs.js"></script>
<style>
:root{--bg:#0b1220;--card:#111a2c;--fg:#e6edf3;--mut:#7d8590;--ac:#5cf;--brd:#1f2a3d}
*{box-sizing:border-box;margin:0;padding:0}
body{font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;background:var(--bg);color:var(--fg)}
a{color:var(--ac);text-decoration:none}
a:hover{text-decoration:underline}
header{display:flex;align-items:center;gap:16px;padding:14px 24px;border-bottom:1px solid var(--brd);background:#0a0f1c}
header img{width:28px;height:28px}
header h1{font-size:16px;font-weight:600}
header .chip{margin-left:auto;padding:4px 10px;border:1px solid var(--brd);border-radius:6px;color:var(--mut);font-size:12px}
header select{background:#0a0f1c;color:var(--fg);border:1px solid var(--brd);padding:6px 10px;border-radius:6px;font-size:13px}
main{max-width:1200px;margin:0 auto;padding:24px}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:14px;margin-bottom:24px}
.card{background:var(--card);border:1px solid var(--brd);border-radius:8px;padding:16px}
.card h3{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--mut);margin-bottom:8px}
.card .v{font-size:22px;font-weight:600}
section{background:var(--card);border:1px solid var(--brd);border-radius:8px;margin-bottom:24px;overflow:hidden}
section h2{padding:14px 18px;border-bottom:1px solid var(--brd);font-size:13px;text-transform:uppercase;letter-spacing:.06em;color:var(--mut);font-weight:600}
table{width:100%;border-collapse:collapse}
th,td{padding:10px 18px;text-align:left;font-size:13px;border-bottom:1px solid var(--brd)}
th{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--mut);font-weight:600;background:#0a0f1c}
td.mono{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12px}
td.r{text-align:right}
.err{color:#f85149;padding:16px;font-size:13px}
.age{color:var(--mut);font-size:12px}
</style>
</head>
<body>
<header>
<img id="logo" src="/logo.svg" alt="">
<h1 id="brand">Explorer</h1>
<select id="network" title="Network"></select>
<select id="chain" title="Chain"></select>
<span class="chip" id="chip"></span>
</header>
<main>
<div class="grid" id="kpi"></div>
<section><h2>Latest Blocks</h2><div id="blocks"></div></section>
<section><h2>Latest Transactions</h2><div id="txs"></div></section>
</main>
<script>
(() => {
const ENV = window.ENV || {};
const CHAINS = (ENV.VITE_CHAINS || []).filter(c => c.type === 'evm' || !c.type);
// Client-side brand inference from hostname. We do this so the SPA
// renders the correct brand even on older Go binaries (v1.2.18) where
// the host→chain matcher hasn't been updated to recognize all our
// explorer.{brand}-test.network shapes — when the server returns the
// default Lux brand for what is actually a Zoo host, we override.
const BRAND_TABLE = {
lux: {name:'Lux Explorer', coin:'LUX', accentColor:'#5cf', iconUrl:'/icon-lux.svg', logoUrl:'/logo-lux.svg'},
cchain: {name:'Lux C-Chain', coin:'LUX', accentColor:'#5cf', iconUrl:'/icon-lux.svg', logoUrl:'/logo-lux.svg'},
zoo: {name:'Zoo Explorer', coin:'ZOO', accentColor:'#FCF006', iconUrl:'/icon-zoo.svg', logoUrl:'/logo-zoo.svg'},
hanzo: {name:'Hanzo Explorer',coin:'AI', accentColor:'#0b1220'},
pars: {name:'Pars Explorer', coin:'PARS', accentColor:'#15803d'},
spc: {name:'SPC Explorer', coin:'SPC', accentColor:'#0ea5e9'},
osage: {name:'Osage Explorer',coin:'OSG', accentColor:'#dc2626'},
};
const inferredBrandKey = (() => {
const m = (location.host || '').toLowerCase()
.match(/^(?:explore|explorer|api-explore|api-explorer)[.-]([a-z][a-z0-9]+)(?:-(?:test|testnet|dev|devnet|local))?\./);
return m ? m[1] : null;
})();
const SERVER_BRAND = ENV.VITE_BRAND || {};
const INFERRED_BRAND = inferredBrandKey && BRAND_TABLE[inferredBrandKey];
// Merge: server-supplied brand wins on name/coin/accent (those come
// from chains.yaml and are authoritative), inferred brand fills in
// missing iconUrl/logoUrl so the per-brand SVGs always load even when
// the chain config doesn't carry icon_url/logo_url.
const BRAND = {
name: SERVER_BRAND.name && SERVER_BRAND.name !== 'Lux Explorer' ? SERVER_BRAND.name : (INFERRED_BRAND?.name || SERVER_BRAND.name || 'Explorer'),
coin: SERVER_BRAND.coin || INFERRED_BRAND?.coin || '',
accentColor: SERVER_BRAND.accentColor || INFERRED_BRAND?.accentColor || '#5cf',
iconUrl: (SERVER_BRAND.iconUrl && SERVER_BRAND.iconUrl !== '/icon.svg') ? SERVER_BRAND.iconUrl : (INFERRED_BRAND?.iconUrl || '/icon.svg'),
logoUrl: (SERVER_BRAND.logoUrl && SERVER_BRAND.logoUrl !== '/logo.svg') ? SERVER_BRAND.logoUrl : (INFERRED_BRAND?.logoUrl || '/logo.svg'),
};
// Network host derivation. Prefer server-supplied values (v1.2.20+ go
// binary fills VITE_CURRENT_NETWORK and VITE_NETWORK_HOSTS), fall back
// to client-side parsing so the network switcher still works on older
// images (v1.2.18) where /envs.js doesn't ship the fields.
const networkFromHost = (host) => {
host = (host || '').toLowerCase().split(':')[0];
if (!host || host === 'localhost' || /^[0-9.]+$/.test(host)) return null;
if (host.endsWith('.svc.cluster.local') || host.endsWith('.local')) return null;
const m = host.match(/^(explore|explorer|api-explore|api-explorer)([.-])([a-z][a-z0-9]+)(?:-(test|testnet|dev|devnet|local))?(\.[^.]+(?:\.[^.]+)*)$/);
if (!m) return null;
const [, , sep, brand, envTag, tail] = m;
const env = envTag || '';
// sep==='.' → brand-owned host (explore.zoo.network); preserve tail
// on mainnet. sep==='-' → lux.network-prefix host
// (explore-zoo.lux.network); force tail to .network on mainnet
// since brand-owned mainnet aliases are always *.network.
const mainnetTail = (sep === '.' && env === '') ? tail : '.network';
// Normalize env shorthand (test → testnet) so the dropdown label
// matches the server-side current value.
const envCanon = env ? (env === 'test' ? 'testnet' : env === 'dev' ? 'devnet' : env) : 'mainnet';
return {
current: envCanon,
hosts: {
mainnet: 'explore.' + brand + mainnetTail,
testnet: 'explorer.' + brand + '-test.network',
},
};
};
const fallback = networkFromHost(location.host) || {current:'',hosts:{}};
const NETWORK_HOSTS = ENV.VITE_NETWORK_HOSTS && Object.keys(ENV.VITE_NETWORK_HOSTS).length
? ENV.VITE_NETWORK_HOSTS : fallback.hosts;
const CURRENT_NETWORK = ENV.VITE_CURRENT_NETWORK || fallback.current;
const PATH_BASE = '/v1/indexer';
const $ = s => document.querySelector(s);
const fmt = n => (n == null || n === '') ? '–' : Number(n).toLocaleString();
const short = h => h ? h.slice(0,10) + '…' + h.slice(-6) : '';
const age = t => {
if (!t) return '';
const s = (Date.now() - new Date(t).getTime())/1000;
if (s < 60) return Math.round(s)+'s ago';
if (s < 3600) return Math.round(s/60)+'m ago';
if (s < 86400) return Math.round(s/3600)+'h ago';
return Math.round(s/86400)+'d ago';
};
// Pick chain from URL hash (#cchain), localStorage, host-inferred
// brand, server default, or first chain. Inferred-brand pin is
// checked before stored so a fresh visit to explore.zoo.network
// opens on Zoo, not whatever was last cached.
const slugFromHash = location.hash.replace(/^#/, '').trim();
const stored = localStorage.getItem('explorer.slug');
const def = (CHAINS.find(c=>c.default) || CHAINS[0] || {}).slug;
let current = CHAINS.find(c=>c.slug === slugFromHash)
|| (inferredBrandKey && CHAINS.find(c=>c.slug === inferredBrandKey))
|| CHAINS.find(c=>c.slug === stored)
|| CHAINS.find(c=>c.slug === def)
|| CHAINS[0];
// Render branding.
if (BRAND.name) $('#brand').textContent = BRAND.name;
if (BRAND.iconUrl) $('#logo').src = BRAND.iconUrl;
if (BRAND.accentColor) document.documentElement.style.setProperty('--ac', BRAND.accentColor);
// Network picker — hidden unless we have at least two siblings.
const netSel = $('#network');
const labels = Object.keys(NETWORK_HOSTS);
if (labels.length >= 2 && CURRENT_NETWORK) {
labels.sort();
labels.forEach(label => {
const o = document.createElement('option');
o.value = label;
o.textContent = label.charAt(0).toUpperCase() + label.slice(1);
if (label === CURRENT_NETWORK) o.selected = true;
netSel.appendChild(o);
});
netSel.onchange = () => {
const target = NETWORK_HOSTS[netSel.value];
if (!target || target === location.host) return;
// Preserve protocol + path + hash so the user lands on the same
// chain/page on the sibling network.
location.href = location.protocol + '//' + target + location.pathname + location.hash;
};
} else {
netSel.style.display = 'none';
}
// Chain picker.
const sel = $('#chain');
CHAINS.forEach(c => {
const o = document.createElement('option');
o.value = c.slug;
o.textContent = c.name + ' (' + (c.coin || '') + ')';
if (current && c.slug === current.slug) o.selected = true;
sel.appendChild(o);
});
sel.onchange = () => {
current = CHAINS.find(c => c.slug === sel.value) || current;
localStorage.setItem('explorer.slug', current.slug);
location.hash = current.slug;
render();
};
async function jget(path){
const r = await fetch(path, {headers:{accept:'application/json'}});
if (!r.ok) throw new Error(path + ' → ' + r.status);
return r.json();
}
async function loadStats(slug){
return jget(PATH_BASE + '/' + slug + '/stats');
}
async function loadBlocks(slug){
return jget(PATH_BASE + '/' + slug + '/blocks?items_count=10');
}
async function loadTxs(slug){
return jget(PATH_BASE + '/' + slug + '/transactions?items_count=10');
}
function renderKpi(stats, chain){
const items = [
['Chain ID', chain.chainId || '–'],
['Blocks', fmt(stats.total_blocks)],
['Transactions', fmt(stats.total_transactions)],
['Addresses', fmt(stats.total_addresses)],
['Avg block', stats.average_block_time ? stats.average_block_time + 'ms' : '–'],
['Gas used today', fmt(stats.gas_used_today)],
];
$('#kpi').innerHTML = items.map(([k,v]) =>
'<div class="card"><h3>'+k+'</h3><div class="v">'+v+'</div></div>'
).join('');
}
function renderBlocks(blocks){
const rows = (blocks.items || []).map(b => {
const ts = b.timestamp ? new Date(b.timestamp).toISOString() : '';
return '<tr>'
+ '<td class="r">'+fmt(b.height)+'</td>'
+ '<td class="mono">'+short(b.hash)+'</td>'
+ '<td class="r">'+fmt(b.tx_count)+'</td>'
+ '<td class="age">'+age(b.timestamp)+'</td>'
+ '</tr>';
}).join('');
$('#blocks').innerHTML = rows
? '<table><thead><tr><th class="r">#</th><th>Hash</th><th class="r">Txs</th><th>Age</th></tr></thead><tbody>'+rows+'</tbody></table>'
: '<div class="err">No blocks indexed yet.</div>';
}
function renderTxs(txs){
const rows = (txs.items || []).map(t => {
return '<tr>'
+ '<td class="mono">'+short(t.hash)+'</td>'
+ '<td class="mono">'+short(t.from && t.from.hash)+'</td>'
+ '<td class="mono">'+short(t.to && t.to.hash)+'</td>'
+ '<td class="age">'+age(t.timestamp)+'</td>'
+ '</tr>';
}).join('');
$('#txs').innerHTML = rows
? '<table><thead><tr><th>Hash</th><th>From</th><th>To</th><th>Age</th></tr></thead><tbody>'+rows+'</tbody></table>'
: '<div class="err">No transactions indexed yet.</div>';
}
async function render(){
if (!current){
document.body.innerHTML = '<div class="err" style="padding:32px">No chains configured. Check window.ENV.</div>';
return;
}
$('#chip').textContent = (current.coin || '') + ' • ' + (current.chainId || '');
document.title = current.name + ' — ' + (BRAND.name || 'Explorer');
try {
const [s, b, t] = await Promise.allSettled([
loadStats(current.slug),
loadBlocks(current.slug),
loadTxs(current.slug),
]);
renderKpi(s.value || {}, current);
renderBlocks(b.value || {});
renderTxs(t.value || {});
} catch (e) {
$('#kpi').innerHTML = '<div class="err">'+e.message+'</div>';
}
}
render();
setInterval(render, 15000);
})();
</script>
</body>
</html>