{ tr(locale, "hero_line_one") }
{ tr(locale, "hero_line_two") }
+ { tr(locale, "hero_description") }
+ @TransactionSearch("", locale) +diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..2a48a7e --- /dev/null +++ b/.air.toml @@ -0,0 +1,29 @@ +#:schema https://json.schemastore.org/any.json + +root = "." +tmp_dir = "tmp" + +[build] +cmd = "go run github.com/a-h/templ/cmd/templ@v0.3.1020 generate && go build -o ./tmp/gl ./cmd/gl && go build -o ./tmp/dashboard ./cmd/dashboard" +entrypoint = ["bash", "./scripts/air-run.sh"] +include_ext = ["go", "templ", "toml"] +exclude_dir = ["tmp", "vendor", ".git"] +exclude_regex = ["_test\\.go$", "_templ\\.go$"] +exclude_unchanged = true +delay = 300 +stop_on_error = true +send_interrupt = true +kill_delay = 1000 + +[log] +time = true + +[misc] +clean_on_exit = true + +[screen] +clear_on_rebuild = false +keep_scroll = true + +[proxy] +enabled = false diff --git a/.gitignore b/.gitignore index c8969f7..a899429 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,9 @@ go.work go.work.sum +# Air live-reload build artifacts +/tmp/ + # env file .env @@ -240,4 +243,3 @@ cython_debug/ # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml hs_err_pid* replay_pid* - diff --git a/Makefile b/Makefile index 0aae601..1072051 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,7 @@ -.PHONY: build generate test +.PHONY: build generate gl-load-test gl-redistribution-load-test load-test test generate: + go run github.com/a-h/templ/cmd/templ@v0.3.1020 generate buf generate ../proto --template ./buf.gen.yaml --path ../proto/base/v1 --path ../proto/ledger/v1 build: generate @@ -8,3 +9,12 @@ build: generate test: go test ./... + +load-test: + go run ./cmd/dashboard-loadtest $(LOADTEST_ARGS) + +gl-load-test: + go run ./cmd/gl-loadtest $(GL_LOADTEST_ARGS) + +gl-redistribution-load-test: + go run ./cmd/gl-redistribution-loadtest $(GL_REDISTRIBUTION_LOADTEST_ARGS) diff --git a/README.md b/README.md index 0e473a7..5913013 100644 --- a/README.md +++ b/README.md @@ -14,4 +14,44 @@ make test make build ``` -Run locally with `go run ./cmd/gl -conf ./gl.cfg.toml`. +Run the ledger API and dashboard as separate processes: + +```bash +go run ./cmd/gl -conf ./gl.cfg.toml +go run ./cmd/dashboard -conf ./dashboard.cfg.toml +``` + +For live reload, install [Air](https://github.com/air-verse/air) and run `air` +from the repository root. The checked-in `.air.toml` rebuilds and restarts both +processes together. + +The read-only network explorer is served on `http://localhost:8080` by default: + +- `/` shows network totals and recent committed journals; +- `/assets` ranks the top positive user holders for recently active assets; +- `/holders` combines a user's available and frozen balance and activity for one asset; +- `/transactions` shows paginated recent transactions, filters them by user/wallet and effect type, and finds journals by exact blockchain transaction hash or internal journal ID; +- `/accounts` rebuilds a ledger account balance and activity history. + +The gRPC service owns schema migrations. The dashboard is read-only, uses its +own `dashboard.cfg.toml`, and does not run migrations. + +Run the dashboard load test against a started dashboard with: + +```bash +make load-test LOADTEST_ARGS='-duration 30s -concurrency 50' +``` + +Use `-paths` to exercise known explorer records as well as the overview, for +example `-paths '/,/transactions?q=KNOWN_HASH,/accounts?class=USER_AVAILABLE&owner_type=user&owner_id=17&asset_id=9'`. + +Run the fixed 10-person, 10,000,000-transfer GL conservation scenario with: + +```bash +make gl-redistribution-load-test +Asset number: 7 +``` + +It starts with one person holding 10,000, uses random transfer values while +protecting a minimum of 45 per person, and verifies that the final ten balances +still sum to exactly 10,000. diff --git a/application/explorer/service.go b/application/explorer/service.go new file mode 100644 index 0000000..d36a946 --- /dev/null +++ b/application/explorer/service.go @@ -0,0 +1,255 @@ +// Package explorer implements the read-only queries used by GL's web explorer. +package explorer + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + "time" + + "gl/domain/ledger" +) + +const ( + recentJournalLimit = 12 + transactionPageSize = 20 + topAssetLimit = 5 + topHoldersPerAsset = 5 +) + +type Stats struct { + JournalCount int64 + EntryCount int64 + AccountCount int64 + LastRecordedAt time.Time +} + +type Holder struct { + Rank int64 + OwnerType string + OwnerID string + AssetID int64 + Balance ledger.Amount +} + +type HolderReference struct { + OwnerType string + OwnerID string + AssetID int64 +} + +type Repository interface { + Stats(context.Context) (Stats, error) + List(context.Context, ledger.JournalFilter) ([]ledger.Journal, error) + GetByTransactionHash(context.Context, string) ([]ledger.Journal, error) + GetByID(context.Context, string) (ledger.Journal, error) + Balance(context.Context, ledger.AccountReference, time.Time) (ledger.Amount, error) + TopHolders(context.Context, int, int) ([]Holder, error) +} + +type Service struct { + repository Repository +} + +type Dashboard struct { + Stats Stats + Journals []ledger.Journal +} + +type Assets struct { + TopHolders []Holder +} + +type TransactionListing struct { + Journals []ledger.Journal + Filter TransactionFilter + HasPrevious bool + HasNext bool +} + +type TransactionFilter struct { + Page int + Wallet string + EffectKind string +} + +type Account struct { + Reference ledger.AccountReference + Balance ledger.Amount + Journals []ledger.Journal +} + +type HolderAccount struct { + Reference HolderReference + Balance ledger.Amount + Journals []ledger.Journal +} + +func NewService(repository Repository) *Service { + return &Service{repository: repository} +} + +func (s *Service) Dashboard(ctx context.Context) (Dashboard, error) { + stats, err := s.repository.Stats(ctx) + if err != nil { + return Dashboard{}, fmt.Errorf("read explorer stats: %w", err) + } + journals, err := s.repository.List(ctx, ledger.JournalFilter{Limit: recentJournalLimit}) + if err != nil { + return Dashboard{}, fmt.Errorf("read recent journals: %w", err) + } + return Dashboard{Stats: stats, Journals: journals}, nil +} + +func (s *Service) Assets(ctx context.Context) (Assets, error) { + holders, err := s.repository.TopHolders(ctx, topAssetLimit, topHoldersPerAsset) + if err != nil { + return Assets{}, fmt.Errorf("read top asset holders: %w", err) + } + return Assets{TopHolders: holders}, nil +} + +func (s *Service) Transactions(ctx context.Context, filter TransactionFilter) (TransactionListing, error) { + filter.Wallet = strings.TrimSpace(filter.Wallet) + filter.EffectKind = strings.TrimSpace(filter.EffectKind) + if filter.Page < 1 { + filter.Page = 1 + } + if filter.Page > 1_000_000 { + return TransactionListing{}, fmt.Errorf("transaction page is too large") + } + if len(filter.Wallet) > 256 || len(filter.EffectKind) > 128 { + return TransactionListing{}, fmt.Errorf("transaction filter is too long") + } + repositoryFilter := ledger.JournalFilter{ + Limit: transactionPageSize + 1, + Offset: (filter.Page - 1) * transactionPageSize, + } + if filter.Wallet != "" { + repositoryFilter.OwnerID = &filter.Wallet + } + if filter.EffectKind != "" { + repositoryFilter.EffectKind = &filter.EffectKind + } + journals, err := s.repository.List(ctx, repositoryFilter) + if err != nil { + return TransactionListing{}, fmt.Errorf("read transaction page: %w", err) + } + listing := TransactionListing{Journals: journals, Filter: filter, HasPrevious: filter.Page > 1} + if len(listing.Journals) > transactionPageSize { + listing.HasNext = true + listing.Journals = listing.Journals[:transactionPageSize] + } + return listing, nil +} + +func (s *Service) Transaction(ctx context.Context, reference string) ([]ledger.Journal, error) { + reference = strings.TrimSpace(reference) + if reference == "" { + return nil, fmt.Errorf("transaction reference is required") + } + if len(reference) > 256 { + return nil, fmt.Errorf("transaction reference is too long") + } + journals, err := s.repository.GetByTransactionHash(ctx, reference) + if err != nil { + return nil, fmt.Errorf("read transaction: %w", err) + } + if len(journals) != 0 { + return journals, nil + } + if !isJournalID(reference) { + return nil, ledger.ErrNotFound + } + journal, err := s.repository.GetByID(ctx, reference) + if err != nil { + if errors.Is(err, ledger.ErrNotFound) { + return nil, ledger.ErrNotFound + } + return nil, fmt.Errorf("read internal transaction: %w", err) + } + return []ledger.Journal{journal}, nil +} + +func isJournalID(value string) bool { + if len(value) != 36 { + return false + } + for index, character := range value { + switch index { + case 8, 13, 18, 23: + if character != '-' { + return false + } + default: + if !((character >= '0' && character <= '9') || (character >= 'a' && character <= 'f') || (character >= 'A' && character <= 'F')) { + return false + } + } + } + return true +} + +func (s *Service) Account(ctx context.Context, reference ledger.AccountReference) (Account, error) { + if err := reference.Validate(); err != nil { + return Account{}, fmt.Errorf("invalid account: %w", err) + } + balance, err := s.repository.Balance(ctx, reference, time.Time{}) + if err != nil { + return Account{}, fmt.Errorf("read account balance: %w", err) + } + journals, err := s.repository.List(ctx, ledger.JournalFilter{ + Account: &reference, + AssetID: &reference.AssetID, + Limit: 50, + }) + if err != nil { + return Account{}, fmt.Errorf("read account journals: %w", err) + } + return Account{Reference: reference, Balance: balance, Journals: journals}, nil +} + +func (s *Service) Holder(ctx context.Context, reference HolderReference) (HolderAccount, error) { + if strings.TrimSpace(reference.OwnerType) == "" || strings.TrimSpace(reference.OwnerID) == "" || reference.AssetID <= 0 { + return HolderAccount{}, fmt.Errorf("invalid holder reference") + } + + result := HolderAccount{Reference: reference} + seen := make(map[string]struct{}) + for _, class := range []ledger.AccountClass{ledger.AccountClassUserAvailable, ledger.AccountClassUserFrozen} { + account := ledger.AccountReference{Class: class, OwnerType: reference.OwnerType, OwnerID: reference.OwnerID, AssetID: reference.AssetID} + balance, err := s.repository.Balance(ctx, account, time.Time{}) + if err != nil { + return HolderAccount{}, fmt.Errorf("read holder balance: %w", err) + } + result.Balance = result.Balance.Add(balance) + + journals, err := s.repository.List(ctx, ledger.JournalFilter{Account: &account, AssetID: &reference.AssetID, Limit: 50}) + if err != nil { + return HolderAccount{}, fmt.Errorf("read holder journals: %w", err) + } + for _, journal := range journals { + if _, exists := seen[journal.ID]; exists { + continue + } + seen[journal.ID] = struct{}{} + result.Journals = append(result.Journals, journal) + } + } + sort.Slice(result.Journals, func(left, right int) bool { + if result.Journals[left].RecordedAt.Equal(result.Journals[right].RecordedAt) { + return result.Journals[left].ID > result.Journals[right].ID + } + return result.Journals[left].RecordedAt.After(result.Journals[right].RecordedAt) + }) + if len(result.Journals) > 50 { + result.Journals = result.Journals[:50] + } + return result, nil +} + +func IsNotFound(err error) bool { + return errors.Is(err, ledger.ErrNotFound) +} diff --git a/application/explorer/service_test.go b/application/explorer/service_test.go new file mode 100644 index 0000000..da7d873 --- /dev/null +++ b/application/explorer/service_test.go @@ -0,0 +1,131 @@ +package explorer + +import ( + "context" + "fmt" + "testing" + "time" + + "gl/domain/ledger" +) + +type repositoryStub struct { + stats Stats + journals []ledger.Journal + transactions []ledger.Journal + journal ledger.Journal + journalErr error + holders []Holder + balance ledger.Amount + lastFilter *ledger.JournalFilter +} + +func (r repositoryStub) Stats(context.Context) (Stats, error) { return r.stats, nil } +func (r repositoryStub) List(_ context.Context, filter ledger.JournalFilter) ([]ledger.Journal, error) { + if r.lastFilter != nil { + *r.lastFilter = filter + } + return r.journals, nil +} +func (r repositoryStub) GetByTransactionHash(context.Context, string) ([]ledger.Journal, error) { + return r.transactions, nil +} +func (r repositoryStub) GetByID(context.Context, string) (ledger.Journal, error) { + return r.journal, r.journalErr +} +func (r repositoryStub) Balance(context.Context, ledger.AccountReference, time.Time) (ledger.Amount, error) { + return r.balance, nil +} +func (r repositoryStub) TopHolders(context.Context, int, int) ([]Holder, error) { + return r.holders, nil +} + +func TestAssetsIncludesTopHolders(t *testing.T) { + balance, err := ledger.ParseAmount("125.5") + if err != nil { + t.Fatal(err) + } + service := NewService(repositoryStub{holders: []Holder{{Rank: 1, OwnerType: "user", OwnerID: "42", AssetID: 7, Balance: balance}}}) + + result, err := service.Assets(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(result.TopHolders) != 1 || result.TopHolders[0].Balance.String() != "125.5" { + t.Fatalf("unexpected top holders: %+v", result.TopHolders) + } +} + +func TestTransactionsPaginatesWithLookahead(t *testing.T) { + journals := make([]ledger.Journal, transactionPageSize+1) + for index := range journals { + journals[index].ID = fmt.Sprintf("journal-%d", index) + } + var repositoryFilter ledger.JournalFilter + service := NewService(repositoryStub{journals: journals, lastFilter: &repositoryFilter}) + + result, err := service.Transactions(context.Background(), TransactionFilter{Page: 2, Wallet: " wallet-42 ", EffectKind: " transfer "}) + if err != nil { + t.Fatal(err) + } + if result.Filter.Page != 2 || result.Filter.Wallet != "wallet-42" || result.Filter.EffectKind != "transfer" || !result.HasPrevious || !result.HasNext || len(result.Journals) != transactionPageSize { + t.Fatalf("unexpected transaction page: %+v", result) + } + if repositoryFilter.OwnerID == nil || *repositoryFilter.OwnerID != "wallet-42" || repositoryFilter.EffectKind == nil || *repositoryFilter.EffectKind != "transfer" || repositoryFilter.Offset != transactionPageSize { + t.Fatalf("unexpected repository filter: %+v", repositoryFilter) + } +} + +func TestHolderCombinesAvailableAndFrozenBalances(t *testing.T) { + balance, err := ledger.ParseAmount("10.25") + if err != nil { + t.Fatal(err) + } + service := NewService(repositoryStub{balance: balance, journals: []ledger.Journal{{ID: "journal-1"}}}) + + result, err := service.Holder(context.Background(), HolderReference{OwnerType: "user", OwnerID: "42", AssetID: 7}) + if err != nil { + t.Fatal(err) + } + if result.Balance.String() != "20.5" || len(result.Journals) != 1 { + t.Fatalf("unexpected aggregate holder account: %+v", result) + } +} + +func TestTransactionRequiresAResult(t *testing.T) { + service := NewService(repositoryStub{}) + if _, err := service.Transaction(context.Background(), "hash"); !IsNotFound(err) { + t.Fatalf("expected not found, got %v", err) + } +} + +func TestTransactionFallsBackToInternalJournalID(t *testing.T) { + const journalID = "5c1e31b0-0000-4000-8000-0000084accc8" + service := NewService(repositoryStub{journal: ledger.Journal{ID: journalID}}) + + result, err := service.Transaction(context.Background(), journalID) + if err != nil { + t.Fatal(err) + } + if len(result) != 1 || result[0].ID != journalID { + t.Fatalf("unexpected internal transaction result: %+v", result) + } +} + +func TestAccountReturnsBalanceAndHistory(t *testing.T) { + balance, err := ledger.ParseAmount("12.5") + if err != nil { + t.Fatal(err) + } + reference := ledger.AccountReference{ + Class: ledger.AccountClassUserAvailable, OwnerType: "user", OwnerID: "42", AssetID: 7, + } + service := NewService(repositoryStub{balance: balance, journals: []ledger.Journal{{ID: "journal-1"}}}) + result, err := service.Account(context.Background(), reference) + if err != nil { + t.Fatal(err) + } + if result.Balance.String() != "12.5" || len(result.Journals) != 1 { + t.Fatalf("unexpected account result: %+v", result) + } +} diff --git a/cmd/dashboard-loadtest/main.go b/cmd/dashboard-loadtest/main.go new file mode 100644 index 0000000..c6fd75c --- /dev/null +++ b/cmd/dashboard-loadtest/main.go @@ -0,0 +1,165 @@ +// Command dashboard-loadtest runs a concurrent read-only HTTP load test against +// a deployed GL dashboard. +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "net/http" + "net/url" + "os" + "os/signal" + "sort" + "strings" + "sync" + "sync/atomic" + "syscall" + "time" +) + +type result struct { + Duration time.Duration + Requests int64 + Errors int64 + Non2xx int64 + Latency []time.Duration +} + +func main() { + baseURL := flag.String("url", "http://127.0.0.1:8080", "dashboard base URL") + paths := flag.String("paths", "/", "comma-separated request paths") + concurrency := flag.Int("concurrency", 20, "number of concurrent workers") + duration := flag.Duration("duration", 15*time.Second, "test duration") + requestTimeout := flag.Duration("request-timeout", 5*time.Second, "timeout for each request") + flag.Parse() + + if *concurrency < 1 || *duration <= 0 || *requestTimeout <= 0 { + fmt.Fprintln(os.Stderr, "concurrency and durations must be positive") + os.Exit(2) + } + targets, err := targets(*baseURL, *paths) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.MaxIdleConns = *concurrency + transport.MaxIdleConnsPerHost = *concurrency + client := &http.Client{Transport: transport, Timeout: *requestTimeout} + defer transport.CloseIdleConnections() + + signalCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + ctx, cancel := context.WithTimeout(signalCtx, *duration) + defer cancel() + + fmt.Printf("Dashboard load test: %d workers for %s against %s\n", *concurrency, *duration, *baseURL) + started := time.Now() + result := run(ctx, client, targets, *concurrency) + result.Duration = time.Since(started) + printResult(result) + if result.Errors > 0 || result.Non2xx > 0 { + os.Exit(1) + } +} + +func targets(baseURL, pathList string) ([]string, error) { + base, err := url.Parse(baseURL) + if err != nil || base.Scheme == "" || base.Host == "" { + return nil, fmt.Errorf("invalid dashboard URL %q", baseURL) + } + paths := strings.Split(pathList, ",") + result := make([]string, 0, len(paths)) + for _, value := range paths { + value = strings.TrimSpace(value) + if value == "" { + continue + } + reference, parseErr := url.Parse(value) + if parseErr != nil || reference.IsAbs() || !strings.HasPrefix(reference.Path, "/") { + return nil, fmt.Errorf("invalid request path %q", value) + } + result = append(result, base.ResolveReference(reference).String()) + } + if len(result) == 0 { + return nil, errors.New("at least one request path is required") + } + return result, nil +} + +func run(ctx context.Context, client *http.Client, targets []string, concurrency int) result { + var ( + requests int64 + failures int64 + non2xx int64 + sequence uint64 + latency = make([]time.Duration, 0, concurrency*100) + lock sync.Mutex + workers sync.WaitGroup + ) + workers.Add(concurrency) + for range concurrency { + go func() { + defer workers.Done() + for ctx.Err() == nil { + target := targets[(atomic.AddUint64(&sequence, 1)-1)%uint64(len(targets))] + request, err := http.NewRequestWithContext(context.WithoutCancel(ctx), http.MethodGet, target, nil) + if err != nil { + atomic.AddInt64(&failures, 1) + continue + } + started := time.Now() + response, err := client.Do(request) + elapsed := time.Since(started) + if err != nil { + atomic.AddInt64(&requests, 1) + atomic.AddInt64(&failures, 1) + continue + } + _, copyErr := io.Copy(io.Discard, io.LimitReader(response.Body, 2<<20)) + closeErr := response.Body.Close() + atomic.AddInt64(&requests, 1) + if response.StatusCode < 200 || response.StatusCode >= 300 { + atomic.AddInt64(&non2xx, 1) + } + if copyErr != nil || closeErr != nil { + atomic.AddInt64(&failures, 1) + } + lock.Lock() + latency = append(latency, elapsed) + lock.Unlock() + } + }() + } + workers.Wait() + return result{Requests: requests, Errors: failures, Non2xx: non2xx, Latency: latency} +} + +func printResult(value result) { + seconds := value.Duration.Seconds() + requestsPerSecond := float64(value.Requests) + if seconds > 0 { + requestsPerSecond /= seconds + } + fmt.Printf("requests: %d\n", value.Requests) + fmt.Printf("throughput: %.1f req/s\n", requestsPerSecond) + fmt.Printf("latency: p50=%s p95=%s p99=%s\n", percentile(value.Latency, 50), percentile(value.Latency, 95), percentile(value.Latency, 99)) + fmt.Printf("errors: %d transport, %d non-2xx\n", value.Errors, value.Non2xx) +} + +func percentile(values []time.Duration, percent int) time.Duration { + if len(values) == 0 { + return 0 + } + ordered := append([]time.Duration(nil), values...) + sort.Slice(ordered, func(left, right int) bool { return ordered[left] < ordered[right] }) + index := (len(ordered)*percent + 99) / 100 + if index < 1 { + index = 1 + } + return ordered[index-1] +} diff --git a/cmd/dashboard-loadtest/main_test.go b/cmd/dashboard-loadtest/main_test.go new file mode 100644 index 0000000..12d4d23 --- /dev/null +++ b/cmd/dashboard-loadtest/main_test.go @@ -0,0 +1,68 @@ +package main + +import ( + "context" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" +) + +func TestRunSendsConcurrentRequestsAcrossTargets(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { + response.WriteHeader(http.StatusOK) + _, _ = response.Write([]byte("ok")) + })) + defer server.Close() + + requestTargets, err := targets(server.URL, "/,/transactions") + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + result := run(ctx, server.Client(), requestTargets, 4) + + if result.Requests == 0 || result.Errors != 0 || result.Non2xx != 0 { + t.Fatalf("unexpected load result: %+v", result) + } + if len(result.Latency) != int(result.Requests) { + t.Fatalf("expected one latency per request, got %d for %d", len(result.Latency), result.Requests) + } +} + +func TestTargetsRejectsExternalAndRelativePaths(t *testing.T) { + for _, paths := range []string{"relative", "https://example.com/"} { + if _, err := targets("http://localhost:8080", paths); err == nil { + t.Fatalf("expected %q to be rejected", paths) + } + } +} + +func TestPercentileUsesNearestRank(t *testing.T) { + values := []time.Duration{time.Millisecond, 4 * time.Millisecond, 2 * time.Millisecond, 3 * time.Millisecond} + if got := percentile(values, 95); got != 4*time.Millisecond { + t.Fatalf("unexpected p95: %s", got) + } +} + +func TestRunDrainsInflightRequestAfterDuration(t *testing.T) { + var canceled atomic.Bool + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + time.Sleep(20 * time.Millisecond) + if request.Context().Err() != nil { + canceled.Store(true) + } + response.WriteHeader(http.StatusOK) + })) + defer server.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Millisecond) + defer cancel() + result := run(ctx, server.Client(), []string{server.URL}, 1) + + if result.Requests != 1 || result.Errors != 0 || canceled.Load() { + t.Fatalf("in-flight request was not drained cleanly: %+v", result) + } +} diff --git a/cmd/dashboard/main.go b/cmd/dashboard/main.go new file mode 100644 index 0000000..0825463 --- /dev/null +++ b/cmd/dashboard/main.go @@ -0,0 +1,54 @@ +package main + +import ( + "context" + "flag" + "log/slog" + "os" + "os/signal" + "syscall" + + "gl/application/explorer" + "gl/infrastructure/config" + "gl/infrastructure/postgres" + webadapter "gl/interface/web" +) + +func main() { + configPath := flag.String("conf", "./dashboard.cfg.toml", "path to the dashboard TOML configuration file") + flag.Parse() + + cfg, err := config.LoadDashboard(*configPath) + if err != nil { + slog.Error("load dashboard configuration", "error", err) + os.Exit(1) + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + database, err := postgres.Open(ctx, cfg.Database) + if err != nil { + slog.Error("open GL database", "error", err) + os.Exit(1) + } + defer database.Close() + if err := database.Ping(ctx); err != nil { + slog.Error("connect to GL database", "error", err) + os.Exit(1) + } + + repository := postgres.NewJournalRepository(database) + handler := webadapter.NewHandler(explorer.NewService(repository)) + serverConfig := webadapter.ServerConfig{ + Host: cfg.HTTP.Host, + Port: cfg.HTTP.Port, + ReadHeaderTimeout: cfg.HTTP.ReadHeaderTimeout, + ShutdownTimeout: cfg.HTTP.ShutdownTimeout, + } + slog.Info("starting GL explorer", "host", cfg.HTTP.Host, "port", cfg.HTTP.Port) + if err := webadapter.Run(ctx, serverConfig, handler); err != nil { + slog.Error("GL explorer stopped", "error", err) + os.Exit(1) + } +} diff --git a/cmd/gl/main.go b/cmd/gl/main.go index 6d4c5af..8f5eb0a 100644 --- a/cmd/gl/main.go +++ b/cmd/gl/main.go @@ -39,7 +39,8 @@ func main() { os.Exit(1) } - handler := grpcadapter.NewHandler(health.NewService(database), applicationledger.NewService(postgres.NewJournalRepository(database), nil)) + repository := postgres.NewJournalRepository(database) + handler := grpcadapter.NewHandler(health.NewService(database), applicationledger.NewService(repository, nil)) slog.Info("starting GL gRPC service", "host", cfg.GRPC.Host, "port", cfg.GRPC.Port) serverConfig := grpcadapter.ServerConfig{ Host: cfg.GRPC.Host, diff --git a/dashboard.cfg.toml b/dashboard.cfg.toml new file mode 100644 index 0000000..dda9ac5 --- /dev/null +++ b/dashboard.cfg.toml @@ -0,0 +1,15 @@ +environment = "local" + +[http] +host = "0.0.0.0" +port = 8080 +read-header-timeout = "5s" +shutdown-timeout = "10s" + +[database] +host = "127.0.0.1" +port = 5432 +name = "gl_db" +user = "postgres" +password = "postgres" +ssl-mode = "disable" diff --git a/domain/ledger/journal.go b/domain/ledger/journal.go index c885cef..505b19f 100644 --- a/domain/ledger/journal.go +++ b/domain/ledger/journal.go @@ -96,6 +96,8 @@ type AppendResult struct { type JournalFilter struct { Account *AccountReference AssetID *int64 + OwnerID *string + EffectKind *string RecordedFrom *time.Time RecordedTo *time.Time Limit int diff --git a/gl.cfg.toml b/gl.cfg.toml index 4da7515..3c8ae30 100644 --- a/gl.cfg.toml +++ b/gl.cfg.toml @@ -10,5 +10,5 @@ host = "127.0.0.1" port = 5432 name = "gl_db" user = "postgres" -password = "" +password = "postgres" ssl-mode = "disable" diff --git a/go.mod b/go.mod index de227f6..a6d5cab 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module gl go 1.26 require ( + github.com/a-h/templ v0.3.1020 github.com/jackc/pgx/v5 v5.4.3 github.com/knadh/koanf/parsers/toml v0.1.0 github.com/knadh/koanf/providers/file v1.2.1 @@ -12,19 +13,31 @@ require ( ) require ( + github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect + github.com/andybalholm/brotli v1.1.0 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cli/browser v1.3.0 // indirect + github.com/fatih/color v1.16.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/jackc/puddle/v2 v2.2.1 // indirect github.com/knadh/koanf/maps v0.1.2 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/natefinch/atomic v1.0.1 // indirect github.com/pelletier/go-toml v1.9.5 // indirect - golang.org/x/crypto v0.26.0 // indirect - golang.org/x/net v0.28.0 // indirect - golang.org/x/sync v0.8.0 // indirect - golang.org/x/sys v0.32.0 // indirect - golang.org/x/text v0.17.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/mod v0.32.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + golang.org/x/tools v0.41.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 // indirect ) + +tool github.com/a-h/templ diff --git a/go.sum b/go.sum index 3dce6d6..fac2ebf 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,18 @@ +github.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ69NYAb5jbGNfHanvm1+iYlo= +github.com/a-h/parse v0.0.0-20250122154542-74294addb73e/go.mod h1:3mnrkvGpurZ4ZrTDbYU84xhwXW2TjTKShSwjRi2ihfQ= +github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw= +github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM= +github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= +github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo= +github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= @@ -23,10 +35,17 @@ github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA= github.com/knadh/koanf/v2 v2.3.4 h1:fnynNSDlujWE+v83hAp8wKr/cdoxHLO0629SN+U8Urc= github.com/knadh/koanf/v2 v2.3.4/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A= +github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -34,18 +53,24 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= -golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= -golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= -golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= -golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= -golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= -golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= -golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 h1:e7S5W7MGGLaSu8j3YjdezkZ+m1/Nm0uRVRMEMGk26Xs= google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E= diff --git a/infrastructure/config/config.go b/infrastructure/config/config.go index 4468b4a..ad430e9 100644 --- a/infrastructure/config/config.go +++ b/infrastructure/config/config.go @@ -16,12 +16,25 @@ type Config struct { Database DatabaseConfig `koanf:"database"` } +type DashboardConfig struct { + Environment string `koanf:"environment"` + HTTP HTTPConfig `koanf:"http"` + Database DatabaseConfig `koanf:"database"` +} + type GRPCConfig struct { Host string `koanf:"host"` Port int `koanf:"port"` ShutdownTimeout time.Duration `koanf:"shutdown-timeout"` } +type HTTPConfig struct { + Host string `koanf:"host"` + Port int `koanf:"port"` + ReadHeaderTimeout time.Duration `koanf:"read-header-timeout"` + ShutdownTimeout time.Duration `koanf:"shutdown-timeout"` +} + type DatabaseConfig struct { Host string `koanf:"host"` Port int `koanf:"port"` @@ -39,21 +52,11 @@ func Load(path string) (*Config, error) { Port: 8600, ShutdownTimeout: 10 * time.Second, }, - Database: DatabaseConfig{ - Host: "127.0.0.1", - Port: 5432, - Name: "gl_db", - User: "postgres", - SSLMode: "disable", - }, + Database: defaultDatabaseConfig(), } - k := koanf.New(".") - if err := k.Load(file.Provider(path), toml.Parser()); err != nil { - return nil, fmt.Errorf("load config: %w", err) - } - if err := k.Unmarshal("", cfg); err != nil { - return nil, fmt.Errorf("decode config: %w", err) + if err := load(path, cfg); err != nil { + return nil, err } if err := cfg.Validate(); err != nil { return nil, err @@ -61,6 +64,47 @@ func Load(path string) (*Config, error) { return cfg, nil } +func LoadDashboard(path string) (*DashboardConfig, error) { + cfg := &DashboardConfig{ + Environment: "local", + HTTP: HTTPConfig{ + Host: "0.0.0.0", + Port: 8080, + ReadHeaderTimeout: 5 * time.Second, + ShutdownTimeout: 10 * time.Second, + }, + Database: defaultDatabaseConfig(), + } + if err := load(path, cfg); err != nil { + return nil, err + } + if err := cfg.Validate(); err != nil { + return nil, err + } + return cfg, nil +} + +func load(path string, target any) error { + k := koanf.New(".") + if err := k.Load(file.Provider(path), toml.Parser()); err != nil { + return fmt.Errorf("load config: %w", err) + } + if err := k.Unmarshal("", target); err != nil { + return fmt.Errorf("decode config: %w", err) + } + return nil +} + +func defaultDatabaseConfig() DatabaseConfig { + return DatabaseConfig{ + Host: "127.0.0.1", + Port: 5432, + Name: "gl_db", + User: "postgres", + SSLMode: "disable", + } +} + func (c *Config) Validate() error { if c.GRPC.Host == "" { return fmt.Errorf("grpc host is required") @@ -71,10 +115,27 @@ func (c *Config) Validate() error { if c.GRPC.ShutdownTimeout <= 0 { return fmt.Errorf("grpc shutdown timeout must be positive") } - if c.Database.Host == "" || c.Database.Name == "" || c.Database.User == "" { + return validateDatabase(c.Database) +} + +func (c *DashboardConfig) Validate() error { + if c.HTTP.Host == "" { + return fmt.Errorf("http host is required") + } + if c.HTTP.Port < 0 || c.HTTP.Port > 65535 { + return fmt.Errorf("http port must be between 0 and 65535") + } + if c.HTTP.ReadHeaderTimeout <= 0 || c.HTTP.ShutdownTimeout <= 0 { + return fmt.Errorf("http timeouts must be positive") + } + return validateDatabase(c.Database) +} + +func validateDatabase(database DatabaseConfig) error { + if database.Host == "" || database.Name == "" || database.User == "" { return fmt.Errorf("database host, name, and user are required") } - if c.Database.Port < 1 || c.Database.Port > 65535 { + if database.Port < 1 || database.Port > 65535 { return fmt.Errorf("database port must be between 1 and 65535") } return nil diff --git a/infrastructure/config/config_test.go b/infrastructure/config/config_test.go index 4d8b1e0..8deb720 100644 --- a/infrastructure/config/config_test.go +++ b/infrastructure/config/config_test.go @@ -27,6 +27,26 @@ func TestLoadUsesDefaultsAndOverrides(t *testing.T) { } } +func TestLoadDashboardUsesHTTPDefaultsAndOverrides(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "dashboard.toml") + contents := []byte("[http]\nport = 0\nshutdown-timeout = \"3s\"\n") + if err := os.WriteFile(path, contents, 0o600); err != nil { + t.Fatal(err) + } + + cfg, err := LoadDashboard(path) + if err != nil { + t.Fatal(err) + } + if cfg.HTTP.Port != 0 || cfg.HTTP.ShutdownTimeout != 3*time.Second || cfg.HTTP.ReadHeaderTimeout != 5*time.Second { + t.Fatalf("unexpected http config: %+v", cfg.HTTP) + } + if cfg.Database.Name != "gl_db" || cfg.Database.Port != 5432 { + t.Fatalf("unexpected database defaults: %+v", cfg.Database) + } +} + func TestLoadRejectsInvalidConfiguration(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "gl.toml") diff --git a/infrastructure/postgres/migration_test.go b/infrastructure/postgres/migration_test.go index ea3a977..9293b1d 100644 --- a/infrastructure/postgres/migration_test.go +++ b/infrastructure/postgres/migration_test.go @@ -25,3 +25,16 @@ func TestInitialMigrationContainsLedgerSafetyGuards(t *testing.T) { } } } + +func TestExplorerFilterMigrationAddsSupportingIndexes(t *testing.T) { + contents, err := migrationFiles.ReadFile("migrations/000002_explorer_filters.up.sql") + if err != nil { + t.Fatal(err) + } + schema := string(contents) + for _, required := range []string{"journals_effect_recorded_idx", "lower(effect_kind)", "ledger_accounts_user_owner_idx", "USER_AVAILABLE", "USER_FROZEN"} { + if !strings.Contains(schema, required) { + t.Fatalf("explorer filter migration is missing %q", required) + } + } +} diff --git a/infrastructure/postgres/migrations/000002_explorer_filters.down.sql b/infrastructure/postgres/migrations/000002_explorer_filters.down.sql new file mode 100644 index 0000000..1c77993 --- /dev/null +++ b/infrastructure/postgres/migrations/000002_explorer_filters.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS ledger_accounts_user_owner_idx; +DROP INDEX IF EXISTS journals_effect_recorded_idx; diff --git a/infrastructure/postgres/migrations/000002_explorer_filters.up.sql b/infrastructure/postgres/migrations/000002_explorer_filters.up.sql new file mode 100644 index 0000000..7f71d4e --- /dev/null +++ b/infrastructure/postgres/migrations/000002_explorer_filters.up.sql @@ -0,0 +1,7 @@ +CREATE INDEX journals_effect_recorded_idx + ON journals (lower(effect_kind), recorded_at DESC, id DESC) + WHERE sealed_at IS NOT NULL; + +CREATE INDEX ledger_accounts_user_owner_idx + ON ledger_accounts (owner_id, id) + WHERE class IN ('USER_AVAILABLE', 'USER_FROZEN'); diff --git a/infrastructure/postgres/query_repository.go b/infrastructure/postgres/query_repository.go index 50a01e6..d090006 100644 --- a/infrastructure/postgres/query_repository.go +++ b/infrastructure/postgres/query_repository.go @@ -7,6 +7,7 @@ import ( "fmt" "time" + "gl/application/explorer" "gl/domain/ledger" "github.com/jackc/pgx/v5" @@ -54,22 +55,77 @@ func (r *JournalRepository) List(ctx context.Context, filter JournalFilter) ([]l ownerType = filter.Account.OwnerType ownerID = filter.Account.OwnerID } - rows, err := r.database.Query(ctx, listJournalsSQL, + return r.queryJournals(ctx, listJournalsSQL, filter.AssetID, accountClass, ownerType, ownerID, + filter.OwnerID, + filter.EffectKind, filter.RecordedFrom, filter.RecordedTo, limit, filter.Offset, ) +} + +func (r *JournalRepository) GetByTransactionHash(ctx context.Context, hash string) ([]ledger.Journal, error) { + return r.queryJournals(ctx, getJournalsByTransactionHashSQL, hash) +} + +func (r *JournalRepository) Stats(ctx context.Context) (explorer.Stats, error) { + var stats explorer.Stats + if err := r.database.QueryRow(ctx, getExplorerStatsSQL).Scan( + &stats.JournalCount, + &stats.EntryCount, + &stats.AccountCount, + &stats.LastRecordedAt, + ); err != nil { + return explorer.Stats{}, fmt.Errorf("read explorer stats: %w", err) + } + return stats, nil +} + +func (r *JournalRepository) TopHolders(ctx context.Context, assetLimit, holderLimit int) ([]explorer.Holder, error) { + if assetLimit <= 0 || assetLimit > 20 { + assetLimit = 5 + } + if holderLimit <= 0 || holderLimit > 20 { + holderLimit = 5 + } + rows, err := r.database.Query(ctx, topHoldersSQL, assetLimit, holderLimit) + if err != nil { + return nil, fmt.Errorf("list top holders: %w", err) + } + defer rows.Close() + + holders := make([]explorer.Holder, 0, assetLimit*holderLimit) + for rows.Next() { + var holder explorer.Holder + var balance string + if err := rows.Scan(&holder.Rank, &holder.OwnerType, &holder.OwnerID, &holder.AssetID, &balance); err != nil { + return nil, fmt.Errorf("scan top holder: %w", err) + } + holder.Balance, err = ledger.ParseAmount(balance) + if err != nil { + return nil, fmt.Errorf("decode top holder balance: %w", err) + } + holders = append(holders, holder) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate top holders: %w", err) + } + return holders, nil +} + +func (r *JournalRepository) queryJournals(ctx context.Context, query string, args ...any) ([]ledger.Journal, error) { + rows, err := r.database.Query(ctx, query, args...) if err != nil { return nil, fmt.Errorf("list journals: %w", err) } defer rows.Close() - journals := make([]ledger.Journal, 0, limit) + journals := make([]ledger.Journal, 0) for rows.Next() { journal, scanErr := scanJournal(rows) if scanErr != nil { @@ -206,6 +262,42 @@ FROM journals j WHERE j.id = $1 AND j.sealed_at IS NOT NULL` const getJournalByIdempotencySQL = `SELECT ` + journalColumns + ` FROM journals j WHERE j.idempotency_key = $1 AND j.sealed_at IS NOT NULL` +const getJournalsByTransactionHashSQL = `SELECT ` + journalColumns + ` +FROM journals j +WHERE j.blockchain_transaction_hash = $1 AND j.sealed_at IS NOT NULL +ORDER BY j.recorded_at DESC, j.id DESC` + +const topHoldersSQL = `WITH recent_assets AS ( + SELECT e.asset_id, MAX(j.recorded_at) AS last_activity + FROM journal_entries e + JOIN journals j ON j.id = e.journal_id + WHERE j.sealed_at IS NOT NULL + GROUP BY e.asset_id + ORDER BY last_activity DESC, e.asset_id + LIMIT $1 +), holder_balances AS ( + SELECT a.owner_type, a.owner_id, e.asset_id, SUM(e.amount) AS balance + FROM journal_entries e + JOIN journals j ON j.id = e.journal_id AND j.sealed_at IS NOT NULL + JOIN ledger_accounts a ON a.id = e.account_id + JOIN recent_assets ra ON ra.asset_id = e.asset_id + WHERE a.class IN ('USER_AVAILABLE', 'USER_FROZEN') + GROUP BY a.owner_type, a.owner_id, e.asset_id + HAVING SUM(e.amount) > 0 +), ranked_holders AS ( + SELECT owner_type, owner_id, asset_id, balance, + ROW_NUMBER() OVER ( + PARTITION BY asset_id + ORDER BY balance DESC, owner_type, owner_id + ) AS holder_rank + FROM holder_balances +) +SELECT rh.holder_rank, rh.owner_type, rh.owner_id, rh.asset_id, rh.balance::text +FROM ranked_holders rh +JOIN recent_assets ra ON ra.asset_id = rh.asset_id +WHERE rh.holder_rank <= $2 +ORDER BY ra.last_activity DESC, rh.asset_id, rh.holder_rank` + const listJournalsSQL = `SELECT DISTINCT ` + journalColumns + ` FROM journals j JOIN journal_entries e ON e.journal_id = j.id @@ -215,10 +307,14 @@ WHERE j.sealed_at IS NOT NULL AND ($2::text IS NULL OR ( a.class = $2 AND a.owner_type = $3 AND a.owner_id = $4 )) - AND ($5::timestamptz IS NULL OR j.recorded_at >= $5) - AND ($6::timestamptz IS NULL OR j.recorded_at <= $6) + AND ($5::text IS NULL OR ( + a.class IN ('USER_AVAILABLE', 'USER_FROZEN') AND a.owner_id = $5 + )) + AND ($6::text IS NULL OR lower(j.effect_kind) = lower($6)) + AND ($7::timestamptz IS NULL OR j.recorded_at >= $7) + AND ($8::timestamptz IS NULL OR j.recorded_at <= $8) ORDER BY j.recorded_at DESC, j.id DESC -LIMIT $7 OFFSET $8` +LIMIT $9 OFFSET $10` const getEntriesSQL = ` SELECT e.line_number, a.class, a.owner_type, a.owner_id, e.asset_id, @@ -237,3 +333,10 @@ WHERE j.sealed_at IS NOT NULL AND a.class = $1 AND a.owner_type = $2 AND a.owner_id = $3 AND a.asset_id = $4 AND ($5::timestamptz IS NULL OR j.recorded_at <= $5)` + +const getExplorerStatsSQL = ` +SELECT + (SELECT count(*) FROM journals WHERE sealed_at IS NOT NULL), + (SELECT count(*) FROM journal_entries e JOIN journals j ON j.id = e.journal_id WHERE j.sealed_at IS NOT NULL), + (SELECT count(*) FROM ledger_accounts), + COALESCE((SELECT max(recorded_at) FROM journals WHERE sealed_at IS NOT NULL), 'epoch'::timestamptz)` diff --git a/interface/web/assets.go b/interface/web/assets.go new file mode 100644 index 0000000..4dcc58b --- /dev/null +++ b/interface/web/assets.go @@ -0,0 +1,16 @@ +package web + +import ( + "bytes" + _ "embed" + "net/http" + "time" +) + +//go:embed assets/favicon.ico +var favicon []byte + +func (h *Handler) favicon(response http.ResponseWriter, request *http.Request) { + response.Header().Set("Cache-Control", "public, max-age=86400") + http.ServeContent(response, request, "favicon.ico", time.Time{}, bytes.NewReader(favicon)) +} diff --git a/interface/web/assets/favicon.ico b/interface/web/assets/favicon.ico new file mode 100644 index 0000000..545e58f Binary files /dev/null and b/interface/web/assets/favicon.ico differ diff --git a/interface/web/handler.go b/interface/web/handler.go new file mode 100644 index 0000000..00e4d26 --- /dev/null +++ b/interface/web/handler.go @@ -0,0 +1,317 @@ +package web + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "net/url" + "strconv" + "strings" + "syscall" + "time" + _ "time/tzdata" + + "gl/application/explorer" + "gl/domain/ledger" + + "github.com/a-h/templ" +) + +type Explorer interface { + Dashboard(context.Context) (explorer.Dashboard, error) + Assets(context.Context) (explorer.Assets, error) + Transactions(context.Context, explorer.TransactionFilter) (explorer.TransactionListing, error) + Transaction(context.Context, string) ([]ledger.Journal, error) + Account(context.Context, ledger.AccountReference) (explorer.Account, error) + Holder(context.Context, explorer.HolderReference) (explorer.HolderAccount, error) +} + +type Handler struct { + explorer Explorer + routes *http.ServeMux +} + +type TransactionPageData struct { + Query string + Journals []ledger.Journal + Listing *explorer.TransactionListing + Error string +} + +type AccountInput struct { + Class string + OwnerType string + OwnerID string + AssetID string +} + +type AccountPageData struct { + Input AccountInput + Account *explorer.Account + Error string +} + +type HolderPageData struct { + Account *explorer.HolderAccount + Error string +} + +func NewHandler(service Explorer) *Handler { + handler := &Handler{explorer: service, routes: http.NewServeMux()} + handler.routes.HandleFunc("GET /favicon.ico", handler.favicon) + handler.routes.HandleFunc("GET /{$}", handler.dashboard) + handler.routes.HandleFunc("GET /assets", handler.assets) + handler.routes.HandleFunc("GET /transactions", handler.transaction) + handler.routes.HandleFunc("GET /transactions/{reference}", handler.transaction) + handler.routes.HandleFunc("GET /accounts", handler.account) + handler.routes.HandleFunc("GET /holders", handler.holder) + return handler +} + +func (h *Handler) assets(response http.ResponseWriter, request *http.Request) { + locale := localeForRequest(response, request) + data, err := h.explorer.Assets(request.Context()) + if err != nil { + h.renderFailure(response, request, locale, tr(locale, "assets_unavailable"), err) + return + } + h.render(response, request, http.StatusOK, locale, tr(locale, "title_assets"), "assets", AssetsContent(data, locale)) +} + +func (h *Handler) ServeHTTP(response http.ResponseWriter, request *http.Request) { + response.Header().Set("X-Content-Type-Options", "nosniff") + response.Header().Set("Referrer-Policy", "same-origin") + response.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; base-uri 'self'; frame-ancestors 'none'") + h.routes.ServeHTTP(response, request) +} + +func (h *Handler) dashboard(response http.ResponseWriter, request *http.Request) { + locale := localeForRequest(response, request) + data, err := h.explorer.Dashboard(request.Context()) + if err != nil { + h.renderFailure(response, request, locale, tr(locale, "dashboard_unavailable"), err) + return + } + h.render(response, request, http.StatusOK, locale, tr(locale, "title_overview"), "dashboard", DashboardContent(data, locale)) +} + +func (h *Handler) transaction(response http.ResponseWriter, request *http.Request) { + locale := localeForRequest(response, request) + query := strings.TrimSpace(request.PathValue("reference")) + if query == "" { + query = strings.TrimSpace(request.URL.Query().Get("q")) + } + data := TransactionPageData{Query: query} + showLatest := query == "" + if query != "" { + journals, err := h.explorer.Transaction(request.Context(), query) + switch { + case err == nil: + data.Journals = journals + case requestEnded(request, err): + return + case explorer.IsNotFound(err): + data.Error = tr(locale, "transaction_missing") + showLatest = true + default: + data.Error = tr(locale, "transaction_lookup_error") + } + } + if showLatest { + page, parseErr := strconv.Atoi(strings.TrimSpace(request.URL.Query().Get("page"))) + if parseErr != nil || page < 1 { + page = 1 + } + listing, err := h.explorer.Transactions(request.Context(), explorer.TransactionFilter{ + Page: page, + Wallet: request.URL.Query().Get("wallet"), + EffectKind: request.URL.Query().Get("effect"), + }) + if requestEnded(request, err) { + return + } else if err != nil { + h.renderFailure(response, request, locale, tr(locale, "transactions_unavailable"), err) + return + } + data.Listing = &listing + } + h.render(response, request, http.StatusOK, locale, tr(locale, "title_transactions"), "transactions", TransactionContent(data, locale)) +} + +func (h *Handler) account(response http.ResponseWriter, request *http.Request) { + locale := localeForRequest(response, request) + data := AccountPageData{Input: AccountInput{ + Class: strings.TrimSpace(request.URL.Query().Get("class")), + OwnerType: strings.TrimSpace(request.URL.Query().Get("owner_type")), + OwnerID: strings.TrimSpace(request.URL.Query().Get("owner_id")), + AssetID: strings.TrimSpace(request.URL.Query().Get("asset_id")), + }} + if request.URL.Query().Has("class") { + assetID, err := strconv.ParseInt(data.Input.AssetID, 10, 64) + if err != nil || assetID <= 0 { + data.Error = tr(locale, "asset_positive") + } else { + result, lookupErr := h.explorer.Account(request.Context(), ledger.AccountReference{ + Class: ledger.AccountClass(data.Input.Class), + OwnerType: data.Input.OwnerType, + OwnerID: data.Input.OwnerID, + AssetID: assetID, + }) + if requestEnded(request, lookupErr) { + return + } else if lookupErr != nil { + data.Error = tr(locale, "account_lookup_error") + } else { + data.Account = &result + } + } + } + h.render(response, request, http.StatusOK, locale, tr(locale, "title_accounts"), "accounts", AccountContent(data, locale)) +} + +func (h *Handler) holder(response http.ResponseWriter, request *http.Request) { + locale := localeForRequest(response, request) + data := HolderPageData{} + assetID, err := strconv.ParseInt(strings.TrimSpace(request.URL.Query().Get("asset_id")), 10, 64) + reference := explorer.HolderReference{ + OwnerType: strings.TrimSpace(request.URL.Query().Get("owner_type")), + OwnerID: strings.TrimSpace(request.URL.Query().Get("owner_id")), + AssetID: assetID, + } + if err != nil || reference.OwnerType == "" || reference.OwnerID == "" || reference.AssetID <= 0 { + data.Error = tr(locale, "holder_reference_invalid") + } else { + result, lookupErr := h.explorer.Holder(request.Context(), reference) + if requestEnded(request, lookupErr) { + return + } else if lookupErr != nil { + data.Error = tr(locale, "account_lookup_error") + } else { + data.Account = &result + } + } + h.render(response, request, http.StatusOK, locale, tr(locale, "title_holder"), "assets", HolderContent(data, locale)) +} + +func (h *Handler) render(response http.ResponseWriter, request *http.Request, status int, locale Locale, title, active string, content templ.Component) { + component := Page(title, active, locale, localeURL(request, LocaleEnglish), localeURL(request, LocalePersian), content) + if request.Header.Get("HX-Request") == "true" && request.Header.Get("HX-History-Restore-Request") != "true" { + component = content + } + response.Header().Set("Content-Type", "text/html; charset=utf-8") + response.WriteHeader(status) + if err := component.Render(request.Context(), response); err != nil { + if requestEnded(request, err) { + return + } + slog.Error("render explorer", "error", err) + } +} + +func (h *Handler) renderFailure(response http.ResponseWriter, request *http.Request, locale Locale, title string, err error) { + if requestEnded(request, err) { + return + } + slog.Error("explorer request failed", "error", err) + h.render(response, request, http.StatusInternalServerError, locale, title, "", FailureContent(title, tr(locale, "read_model_unavailable"), locale)) +} + +func requestEnded(request *http.Request, err error) bool { + return request.Context().Err() != nil || + errors.Is(err, context.Canceled) || + errors.Is(err, syscall.EPIPE) || + errors.Is(err, syscall.ECONNRESET) +} + +func accountClasses() []ledger.AccountClass { + return []ledger.AccountClass{ + ledger.AccountClassUserAvailable, + ledger.AccountClassUserFrozen, + ledger.AccountClassExternalBlockchain, + ledger.AccountClassTreasury, + ledger.AccountClassMarketClearing, + ledger.AccountClassIPGClearing, + ledger.AccountClassCommissionRevenue, + } +} + +func formatTime(value time.Time) string { + if value.IsZero() || value.Unix() == 0 { + return "—" + } + return value.In(defaultTimezone).Format("02 Jan 2006 · 15:04:05") + " Asia/Tehran" +} + +func short(value string, size int) string { + if len(value) <= size { + return value + } + left := size / 2 + return value[:left] + "…" + value[len(value)-(size-left):] +} + +func count(value int64) string { + text := strconv.FormatInt(value, 10) + for index := len(text) - 3; index > 0; index -= 3 { + text = text[:index] + "," + text[index:] + } + return text +} + +func transactionName(journal ledger.Journal, locale Locale) string { + if journal.Blockchain.TransactionHash != "" { + return short(journal.Blockchain.TransactionHash, 22) + } + return tr(locale, "internal") + " · " + short(journal.ID, 14) +} + +func transactionReference(journal ledger.Journal) string { + if journal.Blockchain.TransactionHash != "" { + return journal.Blockchain.TransactionHash + } + return journal.ID +} + +func transactionURL(reference string) string { + return "/transactions/" + url.PathEscape(reference) +} + +func transactionPageURL(filter explorer.TransactionFilter, page int) string { + values := url.Values{"page": []string{strconv.Itoa(page)}} + if filter.Wallet != "" { + values.Set("wallet", filter.Wallet) + } + if filter.EffectKind != "" { + values.Set("effect", filter.EffectKind) + } + return "/transactions?" + values.Encode() +} + +func accountURL(account ledger.AccountReference) string { + values := url.Values{ + "class": []string{string(account.Class)}, + "owner_type": []string{account.OwnerType}, + "owner_id": []string{account.OwnerID}, + "asset_id": []string{strconv.FormatInt(account.AssetID, 10)}, + } + return "/accounts?" + values.Encode() +} + +func holderURL(holder explorer.Holder) string { + values := url.Values{ + "owner_type": []string{holder.OwnerType}, + "owner_id": []string{holder.OwnerID}, + "asset_id": []string{strconv.FormatInt(holder.AssetID, 10)}, + } + return "/holders?" + values.Encode() +} + +func accountName(account ledger.AccountReference, locale Locale) string { + owner := account.OwnerID + if owner == "" { + owner = tr(locale, "system") + } + return fmt.Sprintf("%s / %s", account.Class, owner) +} diff --git a/interface/web/handler_test.go b/interface/web/handler_test.go new file mode 100644 index 0000000..e6daf0b --- /dev/null +++ b/interface/web/handler_test.go @@ -0,0 +1,284 @@ +package web + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "gl/application/explorer" + "gl/domain/ledger" +) + +type explorerStub struct { + dashboard explorer.Dashboard + dashboardErr error + assets explorer.Assets + assetsErr error + listing explorer.TransactionListing + listingErr error + transaction []ledger.Journal + transactionErr error + account explorer.Account + accountErr error + holder explorer.HolderAccount + holderErr error + lastHash string + lastFilter explorer.TransactionFilter + lastAccount ledger.AccountReference +} + +func (s *explorerStub) Dashboard(context.Context) (explorer.Dashboard, error) { + return s.dashboard, s.dashboardErr +} + +func (s *explorerStub) Assets(context.Context) (explorer.Assets, error) { + return s.assets, s.assetsErr +} + +func (s *explorerStub) Transactions(_ context.Context, filter explorer.TransactionFilter) (explorer.TransactionListing, error) { + s.lastFilter = filter + return s.listing, s.listingErr +} + +func (s *explorerStub) Transaction(_ context.Context, hash string) ([]ledger.Journal, error) { + s.lastHash = hash + return s.transaction, s.transactionErr +} + +func (s *explorerStub) Account(_ context.Context, account ledger.AccountReference) (explorer.Account, error) { + s.lastAccount = account + return s.account, s.accountErr +} + +func (s *explorerStub) Holder(_ context.Context, reference explorer.HolderReference) (explorer.HolderAccount, error) { + s.holder.Reference = reference + return s.holder, s.holderErr +} + +func TestDashboardRendersFullTemplPage(t *testing.T) { + service := &explorerStub{dashboard: explorer.Dashboard{ + Stats: explorer.Stats{JournalCount: 1234, LastRecordedAt: time.Unix(10, 0).UTC()}, + Journals: []ledger.Journal{{ + ID: "journal-1", EffectKind: "deposit", SourceService: "wallet", + Blockchain: ledger.BlockchainReference{TransactionHash: "abc123"}, + }}, + }} + request := httptest.NewRequest(http.MethodGet, "/", nil) + response := httptest.NewRecorder() + + NewHandler(service).ServeHTTP(response, request) + + body := response.Body.String() + for _, expected := range []string{"", "DARANO", "1,234", "abc123", "htmx.org@2.0.10", "class=\"mark\" src=\"/favicon.ico\"", "#F5F5F5", "#DFF1F1", "#BBD5DA", "#FF0000"} { + if !strings.Contains(body, expected) { + t.Fatalf("response did not contain %q", expected) + } + } + if response.Header().Get("Content-Security-Policy") == "" { + t.Fatal("expected content security policy") + } +} + +func TestDashboardLinksInternalTransactionsByJournalID(t *testing.T) { + const journalID = "5c1e31b0-0000-4000-8000-0000084accc8" + service := &explorerStub{dashboard: explorer.Dashboard{Journals: []ledger.Journal{{ID: journalID}}}} + request := httptest.NewRequest(http.MethodGet, "/", nil) + response := httptest.NewRecorder() + + NewHandler(service).ServeHTTP(response, request) + + if !strings.Contains(response.Body.String(), `href="/transactions/`+journalID+`"`) { + t.Fatalf("internal transaction was not linked: %s", response.Body.String()) + } +} + +func TestAssetsPageRendersLinkedTopAssetHolders(t *testing.T) { + balance, err := ledger.ParseAmount("987.25") + if err != nil { + t.Fatal(err) + } + service := &explorerStub{assets: explorer.Assets{TopHolders: []explorer.Holder{{ + Rank: 1, OwnerType: "user", OwnerID: "holder-42", AssetID: 7, Balance: balance, + }}}} + request := httptest.NewRequest(http.MethodGet, "/assets", nil) + response := httptest.NewRecorder() + + NewHandler(service).ServeHTTP(response, request) + + body := response.Body.String() + for _, expected := range []string{"Top asset holders", "holder-42", "Asset 7", "987.25", "/holders?asset_id=7&owner_id=holder-42&owner_type=user"} { + if !strings.Contains(body, expected) { + t.Fatalf("top holder response did not contain %q: %s", expected, body) + } + } +} + +func TestHolderPageLoadsAggregateAccountDetail(t *testing.T) { + balance, err := ledger.ParseAmount("42.5") + if err != nil { + t.Fatal(err) + } + service := &explorerStub{holder: explorer.HolderAccount{Balance: balance}} + request := httptest.NewRequest(http.MethodGet, "/holders?owner_type=user&owner_id=holder-42&asset_id=7", nil) + response := httptest.NewRecorder() + + NewHandler(service).ServeHTTP(response, request) + + body := response.Body.String() + for _, expected := range []string{"holder-42", "Asset 7", "42.5", "AVAILABLE + FROZEN"} { + if !strings.Contains(body, expected) { + t.Fatalf("holder response did not contain %q: %s", expected, body) + } + } +} + +func TestDashboardDefaultsToEnglishAndSupportsPersian(t *testing.T) { + service := &explorerStub{} + handler := NewHandler(service) + + englishRequest := httptest.NewRequest(http.MethodGet, "/", nil) + englishResponse := httptest.NewRecorder() + handler.ServeHTTP(englishResponse, englishRequest) + if !strings.Contains(englishResponse.Body.String(), `lang="en" dir="ltr"`) || !strings.Contains(englishResponse.Body.String(), "Every movement") { + t.Fatalf("dashboard did not default to English: %s", englishResponse.Body.String()) + } + + persianRequest := httptest.NewRequest(http.MethodGet, "/?lang=fa", nil) + persianResponse := httptest.NewRecorder() + handler.ServeHTTP(persianResponse, persianRequest) + if !strings.Contains(persianResponse.Body.String(), `lang="fa" dir="rtl"`) || !strings.Contains(persianResponse.Body.String(), "هر جابهجایی") { + t.Fatalf("dashboard did not render Persian: %s", persianResponse.Body.String()) + } + if !strings.Contains(persianResponse.Header().Get("Set-Cookie"), "gl_locale=fa") { + t.Fatal("Persian locale was not persisted") + } +} + +func TestDashboardUsesLocaleCookie(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, "/transactions", nil) + request.AddCookie(&http.Cookie{Name: localeCookie, Value: "fa"}) + response := httptest.NewRecorder() + + NewHandler(&explorerStub{}).ServeHTTP(response, request) + + if !strings.Contains(response.Body.String(), "کاوشگر تراکنش") { + t.Fatalf("locale cookie was ignored: %s", response.Body.String()) + } +} + +func TestFaviconIsServedFromEmbeddedBrandAsset(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, "/favicon.ico", nil) + response := httptest.NewRecorder() + + NewHandler(&explorerStub{}).ServeHTTP(response, request) + + if response.Code != http.StatusOK || response.Body.Len() < 1000 { + t.Fatalf("unexpected favicon response: status=%d bytes=%d", response.Code, response.Body.Len()) + } + if !strings.Contains(response.Header().Get("Content-Type"), "image/") { + t.Fatalf("unexpected favicon content type: %s", response.Header().Get("Content-Type")) + } +} + +func TestTransactionHTMXRequestRendersOnlyExplorerContent(t *testing.T) { + service := &explorerStub{transaction: []ledger.Journal{{ + ID: "journal-1", EffectKind: "withdrawal", + Blockchain: ledger.BlockchainReference{TransactionHash: "tx-hash"}, + }}} + request := httptest.NewRequest(http.MethodGet, "/transactions?q=tx-hash", nil) + request.Header.Set("HX-Request", "true") + response := httptest.NewRecorder() + + NewHandler(service).ServeHTTP(response, request) + + body := response.Body.String() + if strings.Contains(body, "") || !strings.Contains(body, `id="explorer-content"`) { + t.Fatalf("unexpected HTMX fragment: %s", body) + } + if service.lastHash != "tx-hash" || !strings.Contains(body, "COMMITTED") { + t.Fatalf("transaction was not rendered: %s", body) + } +} + +func TestTransactionsPageShowsLatestTransactionsWithPagination(t *testing.T) { + service := &explorerStub{listing: explorer.TransactionListing{ + Journals: []ledger.Journal{{ID: "latest-journal", EffectKind: "transfer"}}, + Filter: explorer.TransactionFilter{Page: 2, Wallet: "wallet-42", EffectKind: "transfer"}, + HasPrevious: true, HasNext: true, + }} + request := httptest.NewRequest(http.MethodGet, "/transactions?page=2&wallet=wallet-42&effect=transfer", nil) + response := httptest.NewRecorder() + + NewHandler(service).ServeHTTP(response, request) + + body := response.Body.String() + for _, expected := range []string{"Latest transactions", "latest-journal", "value=\"wallet-42\"", "value=\"transfer\"", "/transactions?effect=transfer&page=1&wallet=wallet-42", "/transactions?effect=transfer&page=3&wallet=wallet-42"} { + if !strings.Contains(body, expected) { + t.Fatalf("transaction listing did not contain %q: %s", expected, body) + } + } + if service.lastFilter.Page != 2 || service.lastFilter.Wallet != "wallet-42" || service.lastFilter.EffectKind != "transfer" { + t.Fatalf("unexpected transaction filter: %+v", service.lastFilter) + } +} + +func TestMissingTransactionShowsLatestTransactions(t *testing.T) { + service := &explorerStub{ + transactionErr: ledger.ErrNotFound, + listing: explorer.TransactionListing{Journals: []ledger.Journal{{ID: "latest-journal"}}, Filter: explorer.TransactionFilter{Page: 1}}, + } + request := httptest.NewRequest(http.MethodGet, "/transactions?q=missing", nil) + response := httptest.NewRecorder() + + NewHandler(service).ServeHTTP(response, request) + + body := response.Body.String() + if !strings.Contains(body, "No committed transaction matches") || !strings.Contains(body, "latest-journal") { + t.Fatalf("missing transaction did not include latest activity: %s", body) + } +} + +func TestAccountExplorerParsesStableAccountIdentity(t *testing.T) { + balance, err := ledger.ParseAmount("42.5") + if err != nil { + t.Fatal(err) + } + service := &explorerStub{account: explorer.Account{Balance: balance, Reference: ledger.AccountReference{ + Class: ledger.AccountClassUserAvailable, OwnerType: "user", OwnerID: "17", AssetID: 9, + }}} + request := httptest.NewRequest(http.MethodGet, "/accounts?class=USER_AVAILABLE&owner_type=user&owner_id=17&asset_id=9", nil) + response := httptest.NewRecorder() + + NewHandler(service).ServeHTTP(response, request) + + if service.lastAccount.OwnerID != "17" || service.lastAccount.AssetID != 9 { + t.Fatalf("unexpected account lookup: %+v", service.lastAccount) + } + if !strings.Contains(response.Body.String(), "42.5") { + t.Fatal("account balance was not rendered") + } +} + +func TestCanceledDashboardRequestDoesNotRenderAnErrorPage(t *testing.T) { + service := &explorerStub{dashboardErr: context.Canceled} + ctx, cancel := context.WithCancel(context.Background()) + cancel() + request := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx) + response := httptest.NewRecorder() + + NewHandler(service).ServeHTTP(response, request) + + if response.Body.Len() != 0 { + t.Fatalf("expected canceled request to produce no response, got %q", response.Body.String()) + } +} + +func TestFormatTimeUsesTehranTimezone(t *testing.T) { + value := time.Date(2026, time.August, 14, 0, 0, 0, 0, time.UTC) + if got := formatTime(value); got != "14 Aug 2026 · 03:30:00 Asia/Tehran" { + t.Fatalf("unexpected localized time: %q", got) + } +} diff --git a/interface/web/i18n.go b/interface/web/i18n.go new file mode 100644 index 0000000..fda10c0 --- /dev/null +++ b/interface/web/i18n.go @@ -0,0 +1,286 @@ +package web + +import ( + "net/http" + "net/url" + "time" +) + +type Locale string + +const ( + LocaleEnglish Locale = "en" + LocalePersian Locale = "fa" + localeCookie = "gl_locale" +) + +var defaultTimezone = mustLoadLocation("Asia/Tehran") + +func mustLoadLocation(name string) *time.Location { + location, err := time.LoadLocation(name) + if err != nil { + panic("load dashboard timezone: " + err.Error()) + } + return location +} + +func localeForRequest(response http.ResponseWriter, request *http.Request) Locale { + if locale, ok := validLocale(request.URL.Query().Get("lang")); ok { + http.SetCookie(response, &http.Cookie{ + Name: localeCookie, + Value: string(locale), + Path: "/", + MaxAge: 365 * 24 * 60 * 60, + SameSite: http.SameSiteLaxMode, + }) + return locale + } + if cookie, err := request.Cookie(localeCookie); err == nil { + if locale, ok := validLocale(cookie.Value); ok { + return locale + } + } + return LocaleEnglish +} + +func validLocale(value string) (Locale, bool) { + switch Locale(value) { + case LocaleEnglish: + return LocaleEnglish, true + case LocalePersian: + return LocalePersian, true + default: + return "", false + } +} + +func (l Locale) Direction() string { + if l == LocalePersian { + return "rtl" + } + return "ltr" +} + +func localeURL(request *http.Request, locale Locale) string { + query := cloneQuery(request.URL.Query()) + query.Set("lang", string(locale)) + return request.URL.Path + "?" + query.Encode() +} + +func cloneQuery(source url.Values) url.Values { + result := make(url.Values, len(source)) + for key, values := range source { + result[key] = append([]string(nil), values...) + } + return result +} + +func tr(locale Locale, key string) string { + if locale == LocalePersian { + if value, ok := persian[key]; ok { + return value + } + } + if value, ok := english[key]; ok { + return value + } + return key +} + +var english = map[string]string{ + "site_name": "DARANO", + "site_subtitle": "LEDGER NETWORK", + "overview": "Overview", + "transactions": "Transactions", + "assets": "Assets", + "accounts": "Accounts", + "read_only": "READ ONLY", + "general_ledger_explorer": "General ledger explorer", + "hero_line_one": "Every movement.", + "hero_line_two": "One durable record.", + "hero_description": "Inspect committed journals, trace blockchain transaction hashes, and reconstruct any ledger account without touching the write path.", + "transaction_placeholder": "Enter a transaction hash or journal ID", + "transaction_hash": "Transaction hash", + "explore_transaction": "Explore transaction", + "committed_journals": "Committed journals", + "ledger_entries": "Ledger entries", + "tracked_accounts": "Tracked accounts", + "last_recorded": "Last recorded", + "latest_activity": "Latest ledger activity", + "newest_first": "NEWEST FIRST", + "top_asset_holders": "Top asset holders", + "holder_balance_scope": "AVAILABLE + FROZEN · RECENT ASSETS", + "no_asset_holders": "No positive user holdings yet.", + "assets_explorer": "Asset explorer", + "track_asset_ownership": "Track asset ownership.", + "assets_description": "Review the leading positive user balances for the most recently active ledger assets.", + "rank": "Rank", + "holder": "Holder", + "balance": "Balance", + "holder_account": "Holder account", + "inspect_holder": "Inspect a holder.", + "holder_description": "This account combines the holder's available and frozen balances and activity for one asset.", + "combined_balance": "Combined balance", + "available_and_frozen": "AVAILABLE + FROZEN", + "holder_reference_invalid": "A holder identity and positive asset ID are required.", + "transaction": "Transaction", + "effect": "Effect", + "source": "Source", + "entries": "Entries", + "recorded": "Recorded", + "no_journals": "No committed journals yet.", + "network": "NETWORK", + "transaction_explorer": "Transaction explorer", + "trace_settlement": "Trace a settlement.", + "transaction_description": "Search an exact blockchain hash or internal journal ID retained on committed ledger journals.", + "transaction_not_found": "Transaction not found", + "transaction_missing": "No committed transaction matches that hash or journal ID.", + "transaction_lookup_error": "The transaction could not be loaded. Try again shortly.", + "enter_hash": "Enter a hash or journal ID to inspect its balanced financial effects.", + "latest_transactions": "Latest transactions", + "page": "PAGE", + "pagination": "Transaction pages", + "previous": "Previous", + "next": "Next", + "user_wallet": "User / wallet", + "user_wallet_placeholder": "Exact owner or wallet ID", + "effect_type": "Effect type", + "effect_type_placeholder": "Exact effect type", + "apply_filters": "Apply filters", + "clear_filters": "Clear", + "committed": "COMMITTED", + "journal_id": "Journal ID", + "ledger_sequence": "Ledger sequence", + "balanced_entries": "Balanced entries", + "asset": "Asset", + "internal": "internal", + "system": "system", + "account_explorer": "Account explorer", + "rebuild_account": "Rebuild an account.", + "account_description": "Select the stable ledger identity and asset to calculate its current balance from immutable entries.", + "account_class": "Account class", + "owner_type": "Owner type", + "owner_id": "Owner ID", + "stable_owner_id": "Stable owner ID", + "asset_id": "Asset ID", + "explore": "Explore", + "account_unavailable": "Account unavailable", + "asset_positive": "Asset ID must be a positive integer.", + "account_lookup_error": "The account could not be loaded. Check its identity and try again.", + "ledger_identity": "Ledger identity", + "current_balance": "Current balance", + "account_activity": "Account activity", + "journals": "JOURNALS", + "choose_account": "Choose an account class, owner, and asset to begin.", + "explorer_error": "Explorer error", + "dashboard_unavailable": "Dashboard unavailable", + "assets_unavailable": "Assets unavailable", + "transactions_unavailable": "Transactions unavailable", + "read_model_unavailable": "The ledger read model could not be reached. Try again shortly.", + "footer_description": "Darano General Ledger · immutable financial history", + "timezone_notice": "All times shown in Asia/Tehran", + "title_overview": "Network overview", + "title_transactions": "Transaction explorer", + "title_assets": "Asset explorer", + "title_accounts": "Account explorer", + "title_holder": "Holder account", +} + +var persian = map[string]string{ + "site_name": "دارانو", + "site_subtitle": "شبکه دفتر کل", + "overview": "نمای کلی", + "transactions": "تراکنشها", + "assets": "داراییها", + "accounts": "حسابها", + "read_only": "فقط خواندنی", + "general_ledger_explorer": "کاوشگر دفتر کل", + "hero_line_one": "هر جابهجایی.", + "hero_line_two": "یک سابقه ماندگار.", + "hero_description": "دفاتر ثبتشده را ببینید، هش تراکنشهای بلاکچین را ردیابی کنید و هر حساب دفتر کل را بدون دسترسی به مسیر نوشتن بازسازی کنید.", + "transaction_placeholder": "هش تراکنش یا شناسه دفتر را وارد کنید", + "transaction_hash": "هش تراکنش", + "explore_transaction": "جستوجوی تراکنش", + "committed_journals": "دفاتر ثبتشده", + "ledger_entries": "ردیفهای دفتر کل", + "tracked_accounts": "حسابهای ردیابیشده", + "last_recorded": "آخرین ثبت", + "latest_activity": "آخرین فعالیت دفتر کل", + "newest_first": "جدیدترین ابتدا", + "top_asset_holders": "دارندگان برتر دارایی", + "holder_balance_scope": "در دسترس + مسدود · داراییهای اخیر", + "no_asset_holders": "هنوز موجودی مثبت کاربری ثبت نشده است.", + "assets_explorer": "کاوشگر دارایی", + "track_asset_ownership": "مالکیت دارایی را ردیابی کنید.", + "assets_description": "بالاترین موجودیهای مثبت کاربران را برای داراییهای فعال اخیر دفتر کل بررسی کنید.", + "rank": "رتبه", + "holder": "دارنده", + "balance": "موجودی", + "holder_account": "حساب دارنده", + "inspect_holder": "دارنده را بررسی کنید.", + "holder_description": "این حساب موجودی و فعالیت در دسترس و مسدود دارنده را برای یک دارایی ترکیب میکند.", + "combined_balance": "موجودی ترکیبی", + "available_and_frozen": "در دسترس + مسدود", + "holder_reference_invalid": "شناسه دارنده و شناسه مثبت دارایی الزامی است.", + "transaction": "تراکنش", + "effect": "اثر", + "source": "منبع", + "entries": "ردیفها", + "recorded": "زمان ثبت", + "no_journals": "هنوز دفتری ثبت نشده است.", + "network": "شبکه", + "transaction_explorer": "کاوشگر تراکنش", + "trace_settlement": "تسویه را ردیابی کنید.", + "transaction_description": "هش دقیق بلاکچین یا شناسه دفتر داخلی را در دفاتر قطعی جستوجو کنید.", + "transaction_not_found": "تراکنش پیدا نشد", + "transaction_missing": "هیچ تراکنش قطعی با این هش یا شناسه دفتر پیدا نشد.", + "transaction_lookup_error": "بارگذاری تراکنش ممکن نشد. کمی بعد دوباره تلاش کنید.", + "enter_hash": "برای مشاهده اثرهای مالی تراز، هش یا شناسه دفتر را وارد کنید.", + "latest_transactions": "آخرین تراکنشها", + "page": "صفحه", + "pagination": "صفحههای تراکنش", + "previous": "قبلی", + "next": "بعدی", + "user_wallet": "کاربر / کیف پول", + "user_wallet_placeholder": "شناسه دقیق مالک یا کیف پول", + "effect_type": "نوع اثر", + "effect_type_placeholder": "نوع دقیق اثر", + "apply_filters": "اعمال فیلترها", + "clear_filters": "پاککردن", + "committed": "ثبتشده", + "journal_id": "شناسه دفتر", + "ledger_sequence": "شماره دفتر", + "balanced_entries": "ردیفهای تراز", + "asset": "دارایی", + "internal": "داخلی", + "system": "سیستم", + "account_explorer": "کاوشگر حساب", + "rebuild_account": "یک حساب را بازسازی کنید.", + "account_description": "شناسه پایدار حساب و دارایی را انتخاب کنید تا موجودی فعلی از ردیفهای تغییرناپذیر محاسبه شود.", + "account_class": "نوع حساب", + "owner_type": "نوع مالک", + "owner_id": "شناسه مالک", + "stable_owner_id": "شناسه پایدار مالک", + "asset_id": "شناسه دارایی", + "explore": "جستوجو", + "account_unavailable": "حساب در دسترس نیست", + "asset_positive": "شناسه دارایی باید یک عدد صحیح مثبت باشد.", + "account_lookup_error": "بارگذاری حساب ممکن نشد. شناسه آن را بررسی و دوباره تلاش کنید.", + "ledger_identity": "شناسه دفتر کل", + "current_balance": "موجودی فعلی", + "account_activity": "فعالیت حساب", + "journals": "دفتر", + "choose_account": "برای شروع، نوع حساب، مالک و دارایی را انتخاب کنید.", + "explorer_error": "خطای کاوشگر", + "dashboard_unavailable": "داشبورد در دسترس نیست", + "assets_unavailable": "داراییها در دسترس نیستند", + "transactions_unavailable": "تراکنشها در دسترس نیستند", + "read_model_unavailable": "مدل خواندنی دفتر کل در دسترس نیست. کمی بعد دوباره تلاش کنید.", + "footer_description": "دفتر کل دارانو · تاریخچه مالی تغییرناپذیر", + "timezone_notice": "همه زمانها بر پایه منطقه زمانی تهران نمایش داده میشوند", + "title_overview": "نمای کلی شبکه", + "title_transactions": "کاوشگر تراکنش", + "title_assets": "کاوشگر دارایی", + "title_accounts": "کاوشگر حساب", + "title_holder": "حساب دارنده", +} diff --git a/interface/web/i18n_test.go b/interface/web/i18n_test.go new file mode 100644 index 0000000..d04f058 --- /dev/null +++ b/interface/web/i18n_test.go @@ -0,0 +1,23 @@ +package web + +import ( + "net/http/httptest" + "strings" + "testing" +) + +func TestLocaleURLPreservesExplorerQuery(t *testing.T) { + request := httptest.NewRequest("GET", "/accounts?class=USER_AVAILABLE&asset_id=9", nil) + value := localeURL(request, LocalePersian) + for _, expected := range []string{"/accounts?", "class=USER_AVAILABLE", "asset_id=9", "lang=fa"} { + if !strings.Contains(value, expected) { + t.Fatalf("locale URL %q did not preserve %q", value, expected) + } + } +} + +func TestTranslationFallsBackToEnglish(t *testing.T) { + if got := tr(Locale("invalid"), "overview"); got != "Overview" { + t.Fatalf("unexpected fallback translation: %q", got) + } +} diff --git a/interface/web/server.go b/interface/web/server.go new file mode 100644 index 0000000..c7d778e --- /dev/null +++ b/interface/web/server.go @@ -0,0 +1,47 @@ +package web + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" +) + +type ServerConfig struct { + Host string + Port int + ReadHeaderTimeout time.Duration + ShutdownTimeout time.Duration +} + +func Run(ctx context.Context, cfg ServerConfig, handler http.Handler) error { + server := &http.Server{ + Addr: fmt.Sprintf("%s:%d", cfg.Host, cfg.Port), + Handler: handler, + ReadHeaderTimeout: cfg.ReadHeaderTimeout, + } + serveErr := make(chan error, 1) + go func() { serveErr <- server.ListenAndServe() }() + + select { + case err := <-serveErr: + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return fmt.Errorf("serve explorer: %w", err) + case <-ctx.Done(): + } + + shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout) + defer cancel() + if err := server.Shutdown(shutdownCtx); err != nil { + _ = server.Close() + return fmt.Errorf("shut down explorer: %w", err) + } + err := <-serveErr + if err != nil && !errors.Is(err, http.ErrServerClosed) { + return fmt.Errorf("serve explorer: %w", err) + } + return nil +} diff --git a/interface/web/templates.templ b/interface/web/templates.templ new file mode 100644 index 0000000..75f4ebe --- /dev/null +++ b/interface/web/templates.templ @@ -0,0 +1,421 @@ +package web + +import ( + "strconv" + + "gl/application/explorer" + "gl/domain/ledger" +) + +templ Page(title string, active string, locale Locale, englishURL string, persianURL string, content templ.Component) { + + +
+ + + + +{ tr(locale, "hero_description") }
+ @TransactionSearch("", locale) +{ tr(locale, "assets_description") }
+| { tr(locale, "asset") } | { tr(locale, "rank") } | { tr(locale, "holder") } | { tr(locale, "balance") } |
|---|---|---|---|
| { tr(locale, "asset") } { strconv.FormatInt(holder.AssetID, 10) } | +#{ strconv.FormatInt(holder.Rank, 10) } | +{ holder.OwnerID }{ holder.OwnerType } | +{ holder.Balance.String() } | +
{ tr(locale, "holder_description") }
+{ data.Account.Reference.OwnerType } · { tr(locale, "asset") } { strconv.FormatInt(data.Account.Reference.AssetID, 10) }
| { tr(locale, "transaction") } | { tr(locale, "effect") } | { tr(locale, "source") } | { tr(locale, "entries") } | { tr(locale, "recorded") } |
|---|---|---|---|---|
| + { transactionName(journal, locale) } + | +{ journal.EffectKind } | +{ journal.SourceService } | +{ strconv.Itoa(len(journal.Entries)) } | +{ formatTime(journal.RecordedAt) } | +
{ tr(locale, "transaction_description") }
+ @TransactionSearch(data.Query, locale) +{ tr(locale, "account_description") }
+{ data.Account.Reference.OwnerType } · { tr(locale, "asset") } { strconv.FormatInt(data.Account.Reference.AssetID, 10) }
{ message }
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var33 string + templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "hero_description")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 189, Col: 51} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = TransactionSearch("", locale).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var50 string + templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "assets_description")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 211, Col: 53} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "
| ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var55 string + templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "asset")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 226, Col: 40} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, " | ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var56 string + templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "rank")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 226, Col: 71} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, " | ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var57 string + templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "holder")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 226, Col: 104} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, " | ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var58 string + templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "balance")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 226, Col: 138} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, " |
|---|---|---|---|
| ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var59 string + templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "asset")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 230, Col: 51} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var60 string + templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.FormatInt(holder.AssetID, 10)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 230, Col: 93} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, " | #") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var61 string + templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.FormatInt(holder.Rank, 10)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 231, Col: 61} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var61)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, " | ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var63 string + templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinStringErrs(holder.OwnerID) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 232, Col: 109} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var64 string + templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.JoinStringErrs(holder.OwnerType) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 232, Col: 140} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var64)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, " | ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var65 string + templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinStringErrs(holder.Balance.String()) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 233, Col: 60} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, " |
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var72 string + templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "holder_description")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 248, Col: 53} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var72)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var77 string + templ_7745c5c3_Var77, templ_7745c5c3_Err = templ.JoinStringErrs(data.Account.Reference.OwnerType) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 254, Col: 171} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var77)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, " · ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var78 string + templ_7745c5c3_Var78, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "asset")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 254, Col: 198} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var78)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var79 string + templ_7745c5c3_Var79, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.FormatInt(data.Account.Reference.AssetID, 10)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 254, Col: 256} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var79)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "
| ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var86 string + templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "transaction")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 271, Col: 46} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var86)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, " | ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var87 string + templ_7745c5c3_Var87, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "effect")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 271, Col: 79} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var87)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, " | ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var88 string + templ_7745c5c3_Var88, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "source")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 271, Col: 112} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var88)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, " | ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var89 string + templ_7745c5c3_Var89, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "entries")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 271, Col: 146} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var89)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, " | ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var90 string + templ_7745c5c3_Var90, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "recorded")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 271, Col: 181} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var90)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, " |
|---|---|---|---|---|
| ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var92 string + templ_7745c5c3_Var92, templ_7745c5c3_Err = templ.JoinStringErrs(transactionName(journal, locale)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 276, Col: 129} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var92)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, " | ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var93 string + templ_7745c5c3_Var93, templ_7745c5c3_Err = templ.JoinStringErrs(journal.EffectKind) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 278, Col: 50} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var93)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, " | ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var94 string + templ_7745c5c3_Var94, templ_7745c5c3_Err = templ.JoinStringErrs(journal.SourceService) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 279, Col: 34} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var94)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, " | ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var95 string + templ_7745c5c3_Var95, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(len(journal.Entries))) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 280, Col: 47} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var95)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, " | ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var96 string + templ_7745c5c3_Var96, templ_7745c5c3_Err = templ.JoinStringErrs(formatTime(journal.RecordedAt)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 281, Col: 56} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var96)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, " |
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var102 string + templ_7745c5c3_Var102, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "transaction_description")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 296, Col: 58} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var102)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = TransactionSearch(data.Query, locale).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var157 string + templ_7745c5c3_Var157, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "account_description")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 385, Col: 54} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var157)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 210, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var175 string + templ_7745c5c3_Var175, templ_7745c5c3_Err = templ.JoinStringErrs(data.Account.Reference.OwnerType) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 406, Col: 180} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var175)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 233, " · ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var176 string + templ_7745c5c3_Var176, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "asset")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 406, Col: 207} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var176)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 234, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var177 string + templ_7745c5c3_Var177, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.FormatInt(data.Account.Reference.AssetID, 10)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 406, Col: 265} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var177)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 235, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var187 string + templ_7745c5c3_Var187, templ_7745c5c3_Err = templ.JoinStringErrs(message) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 419, Col: 132} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var187)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 247, "