mirror of
https://github.com/luxfi/explorer.git
synced 2026-09-24 00:25:12 +00:00
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:
+9
-63
@@ -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
@@ -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
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user