feat: add localized ledger explorer dashboard
This commit is contained in:
@@ -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
|
||||||
+3
-1
@@ -22,6 +22,9 @@
|
|||||||
go.work
|
go.work
|
||||||
go.work.sum
|
go.work.sum
|
||||||
|
|
||||||
|
# Air live-reload build artifacts
|
||||||
|
/tmp/
|
||||||
|
|
||||||
# env file
|
# env file
|
||||||
.env
|
.env
|
||||||
|
|
||||||
@@ -240,4 +243,3 @@ cython_debug/
|
|||||||
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
|
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
|
||||||
hs_err_pid*
|
hs_err_pid*
|
||||||
replay_pid*
|
replay_pid*
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
.PHONY: build generate test
|
.PHONY: build generate gl-load-test gl-redistribution-load-test load-test test
|
||||||
|
|
||||||
generate:
|
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
|
buf generate ../proto --template ./buf.gen.yaml --path ../proto/base/v1 --path ../proto/ledger/v1
|
||||||
|
|
||||||
build: generate
|
build: generate
|
||||||
@@ -8,3 +9,12 @@ build: generate
|
|||||||
|
|
||||||
test:
|
test:
|
||||||
go 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)
|
||||||
|
|||||||
@@ -14,4 +14,44 @@ make test
|
|||||||
make build
|
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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-1
@@ -39,7 +39,8 @@ func main() {
|
|||||||
os.Exit(1)
|
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)
|
slog.Info("starting GL gRPC service", "host", cfg.GRPC.Host, "port", cfg.GRPC.Port)
|
||||||
serverConfig := grpcadapter.ServerConfig{
|
serverConfig := grpcadapter.ServerConfig{
|
||||||
Host: cfg.GRPC.Host,
|
Host: cfg.GRPC.Host,
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -96,6 +96,8 @@ type AppendResult struct {
|
|||||||
type JournalFilter struct {
|
type JournalFilter struct {
|
||||||
Account *AccountReference
|
Account *AccountReference
|
||||||
AssetID *int64
|
AssetID *int64
|
||||||
|
OwnerID *string
|
||||||
|
EffectKind *string
|
||||||
RecordedFrom *time.Time
|
RecordedFrom *time.Time
|
||||||
RecordedTo *time.Time
|
RecordedTo *time.Time
|
||||||
Limit int
|
Limit int
|
||||||
|
|||||||
+1
-1
@@ -10,5 +10,5 @@ host = "127.0.0.1"
|
|||||||
port = 5432
|
port = 5432
|
||||||
name = "gl_db"
|
name = "gl_db"
|
||||||
user = "postgres"
|
user = "postgres"
|
||||||
password = ""
|
password = "postgres"
|
||||||
ssl-mode = "disable"
|
ssl-mode = "disable"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ module gl
|
|||||||
go 1.26
|
go 1.26
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/a-h/templ v0.3.1020
|
||||||
github.com/jackc/pgx/v5 v5.4.3
|
github.com/jackc/pgx/v5 v5.4.3
|
||||||
github.com/knadh/koanf/parsers/toml v0.1.0
|
github.com/knadh/koanf/parsers/toml v0.1.0
|
||||||
github.com/knadh/koanf/providers/file v1.2.1
|
github.com/knadh/koanf/providers/file v1.2.1
|
||||||
@@ -12,19 +13,31 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
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/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
||||||
github.com/knadh/koanf/maps v0.1.2 // 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/copystructure v1.2.0 // indirect
|
||||||
github.com/mitchellh/reflectwalk v1.0.2 // 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
|
github.com/pelletier/go-toml v1.9.5 // indirect
|
||||||
golang.org/x/crypto v0.26.0 // indirect
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
golang.org/x/net v0.28.0 // indirect
|
golang.org/x/mod v0.32.0 // indirect
|
||||||
golang.org/x/sync v0.8.0 // indirect
|
golang.org/x/net v0.51.0 // indirect
|
||||||
golang.org/x/sys v0.32.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/text v0.17.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
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
|
tool github.com/a-h/templ
|
||||||
|
|||||||
@@ -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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
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/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 h1:fnynNSDlujWE+v83hAp8wKr/cdoxHLO0629SN+U8Urc=
|
||||||
github.com/knadh/koanf/v2 v2.3.4/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28=
|
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 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
|
||||||
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
|
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 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
|
||||||
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
|
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 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
|
||||||
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
|
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=
|
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/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.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.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
|
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||||
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
|
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||||
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
|
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||||
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||||
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
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 h1:e7S5W7MGGLaSu8j3YjdezkZ+m1/Nm0uRVRMEMGk26Xs=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
|
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=
|
google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E=
|
||||||
|
|||||||
@@ -16,12 +16,25 @@ type Config struct {
|
|||||||
Database DatabaseConfig `koanf:"database"`
|
Database DatabaseConfig `koanf:"database"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DashboardConfig struct {
|
||||||
|
Environment string `koanf:"environment"`
|
||||||
|
HTTP HTTPConfig `koanf:"http"`
|
||||||
|
Database DatabaseConfig `koanf:"database"`
|
||||||
|
}
|
||||||
|
|
||||||
type GRPCConfig struct {
|
type GRPCConfig struct {
|
||||||
Host string `koanf:"host"`
|
Host string `koanf:"host"`
|
||||||
Port int `koanf:"port"`
|
Port int `koanf:"port"`
|
||||||
ShutdownTimeout time.Duration `koanf:"shutdown-timeout"`
|
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 {
|
type DatabaseConfig struct {
|
||||||
Host string `koanf:"host"`
|
Host string `koanf:"host"`
|
||||||
Port int `koanf:"port"`
|
Port int `koanf:"port"`
|
||||||
@@ -39,21 +52,11 @@ func Load(path string) (*Config, error) {
|
|||||||
Port: 8600,
|
Port: 8600,
|
||||||
ShutdownTimeout: 10 * time.Second,
|
ShutdownTimeout: 10 * time.Second,
|
||||||
},
|
},
|
||||||
Database: DatabaseConfig{
|
Database: defaultDatabaseConfig(),
|
||||||
Host: "127.0.0.1",
|
|
||||||
Port: 5432,
|
|
||||||
Name: "gl_db",
|
|
||||||
User: "postgres",
|
|
||||||
SSLMode: "disable",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
k := koanf.New(".")
|
if err := load(path, cfg); err != nil {
|
||||||
if err := k.Load(file.Provider(path), toml.Parser()); err != nil {
|
return nil, err
|
||||||
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 := cfg.Validate(); err != nil {
|
if err := cfg.Validate(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -61,6 +64,47 @@ func Load(path string) (*Config, error) {
|
|||||||
return cfg, nil
|
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 {
|
func (c *Config) Validate() error {
|
||||||
if c.GRPC.Host == "" {
|
if c.GRPC.Host == "" {
|
||||||
return fmt.Errorf("grpc host is required")
|
return fmt.Errorf("grpc host is required")
|
||||||
@@ -71,10 +115,27 @@ func (c *Config) Validate() error {
|
|||||||
if c.GRPC.ShutdownTimeout <= 0 {
|
if c.GRPC.ShutdownTimeout <= 0 {
|
||||||
return fmt.Errorf("grpc shutdown timeout must be positive")
|
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")
|
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 fmt.Errorf("database port must be between 1 and 65535")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -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) {
|
func TestLoadRejectsInvalidConfiguration(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "gl.toml")
|
path := filepath.Join(dir, "gl.toml")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
DROP INDEX IF EXISTS ledger_accounts_user_owner_idx;
|
||||||
|
DROP INDEX IF EXISTS journals_effect_recorded_idx;
|
||||||
@@ -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');
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gl/application/explorer"
|
||||||
"gl/domain/ledger"
|
"gl/domain/ledger"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
@@ -54,22 +55,77 @@ func (r *JournalRepository) List(ctx context.Context, filter JournalFilter) ([]l
|
|||||||
ownerType = filter.Account.OwnerType
|
ownerType = filter.Account.OwnerType
|
||||||
ownerID = filter.Account.OwnerID
|
ownerID = filter.Account.OwnerID
|
||||||
}
|
}
|
||||||
rows, err := r.database.Query(ctx, listJournalsSQL,
|
return r.queryJournals(ctx, listJournalsSQL,
|
||||||
filter.AssetID,
|
filter.AssetID,
|
||||||
accountClass,
|
accountClass,
|
||||||
ownerType,
|
ownerType,
|
||||||
ownerID,
|
ownerID,
|
||||||
|
filter.OwnerID,
|
||||||
|
filter.EffectKind,
|
||||||
filter.RecordedFrom,
|
filter.RecordedFrom,
|
||||||
filter.RecordedTo,
|
filter.RecordedTo,
|
||||||
limit,
|
limit,
|
||||||
filter.Offset,
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list journals: %w", err)
|
return nil, fmt.Errorf("list journals: %w", err)
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
journals := make([]ledger.Journal, 0, limit)
|
journals := make([]ledger.Journal, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
journal, scanErr := scanJournal(rows)
|
journal, scanErr := scanJournal(rows)
|
||||||
if scanErr != nil {
|
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 + `
|
const getJournalByIdempotencySQL = `SELECT ` + journalColumns + `
|
||||||
FROM journals j WHERE j.idempotency_key = $1 AND j.sealed_at IS NOT NULL`
|
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 + `
|
const listJournalsSQL = `SELECT DISTINCT ` + journalColumns + `
|
||||||
FROM journals j
|
FROM journals j
|
||||||
JOIN journal_entries e ON e.journal_id = j.id
|
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 (
|
AND ($2::text IS NULL OR (
|
||||||
a.class = $2 AND a.owner_type = $3 AND a.owner_id = $4
|
a.class = $2 AND a.owner_type = $3 AND a.owner_id = $4
|
||||||
))
|
))
|
||||||
AND ($5::timestamptz IS NULL OR j.recorded_at >= $5)
|
AND ($5::text IS NULL OR (
|
||||||
AND ($6::timestamptz IS NULL OR j.recorded_at <= $6)
|
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
|
ORDER BY j.recorded_at DESC, j.id DESC
|
||||||
LIMIT $7 OFFSET $8`
|
LIMIT $9 OFFSET $10`
|
||||||
|
|
||||||
const getEntriesSQL = `
|
const getEntriesSQL = `
|
||||||
SELECT e.line_number, a.class, a.owner_type, a.owner_id, e.asset_id,
|
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.class = $1 AND a.owner_type = $2 AND a.owner_id = $3
|
||||||
AND a.asset_id = $4
|
AND a.asset_id = $4
|
||||||
AND ($5::timestamptz IS NULL OR j.recorded_at <= $5)`
|
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)`
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -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)
|
||||||
|
}
|
||||||
@@ -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{"<!doctype html>", "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, "<!doctype html>") || !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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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": "حساب دارنده",
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
<!doctype html>
|
||||||
|
<html lang={ string(locale) } dir={ locale.Direction() }>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
|
<meta name="description" content={ tr(locale, "general_ledger_explorer") }/>
|
||||||
|
<meta name="htmx-config" content='{"historyRestoreAsHxRequest":false,"selfRequestsOnly":true}'/>
|
||||||
|
<title>{ title } · { tr(locale, "site_name") }</title>
|
||||||
|
<link rel="icon" href="/favicon.ico" sizes="any"/>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.10/dist/htmx.min.js" integrity="sha384-H5SrcfygHmAuTDZphMHqBJLc3FhssKjG7w/CeCpFReSfwBWDTKpkzPP8c+cLsK+V" crossorigin="anonymous"></script>
|
||||||
|
<style>
|
||||||
|
:root { --ink:#1f2a2c; --muted:rgba(31,42,44,.68); --paper:#F5F5F5; --surface:#DFF1F1; --card:#ffffff; --line:rgba(31,42,44,.15); --teal:#BBD5DA; --accent:#FF0000; --shadow:0 18px 50px rgba(31,42,44,.10); }
|
||||||
|
* { box-sizing:border-box; }
|
||||||
|
html { background:var(--paper); color:var(--ink); font-family:Inter,Tahoma,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }
|
||||||
|
body { margin:0; min-height:100vh; background:radial-gradient(circle at 78% 0%,rgba(187,213,218,.48),transparent 30rem),radial-gradient(circle at 5% 30%,rgba(255,0,0,.04),transparent 24rem),var(--paper); }
|
||||||
|
a { color:inherit; }
|
||||||
|
.shell { width:min(1180px,calc(100% - 40px)); margin:0 auto; }
|
||||||
|
.topbar { min-height:76px; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--line); }
|
||||||
|
.brand { display:flex; align-items:center; gap:12px; text-decoration:none; font-weight:850; letter-spacing:-.03em; }
|
||||||
|
.mark { width:35px; height:35px; display:block; flex:none; object-fit:contain; }
|
||||||
|
.brand small { display:block; color:var(--muted); font-size:9px; letter-spacing:.2em; margin-top:2px; }
|
||||||
|
.nav { display:flex; gap:6px; align-items:center; }
|
||||||
|
.nav a { padding:10px 13px; border-radius:999px; text-decoration:none; color:var(--muted); font-size:13px; font-weight:700; }
|
||||||
|
.nav a:hover,.nav a.active { color:var(--ink); background:var(--surface); }
|
||||||
|
.live { display:flex; align-items:center; gap:8px; margin-inline-start:10px; padding:8px 11px; border:1px solid var(--line); border-radius:999px; font:700 11px/1 ui-monospace,SFMono-Regular,monospace; background:rgba(255,255,255,.62); }
|
||||||
|
.dot { width:7px; height:7px; background:var(--teal); border-radius:50%; box-shadow:0 0 0 4px rgba(187,213,218,.35); }
|
||||||
|
.language-switch { display:flex; padding:3px; margin-inline-start:8px; border:1px solid var(--line); border-radius:999px; background:rgba(255,255,255,.62); direction:ltr; }
|
||||||
|
.language-switch a { padding:6px 8px; font:800 10px/1 ui-monospace,SFMono-Regular,monospace; }
|
||||||
|
.language-switch a.active { background:var(--ink); color:white; }
|
||||||
|
main { padding:62px 0 90px; }
|
||||||
|
.eyebrow { display:flex; align-items:center; gap:9px; color:var(--teal); text-transform:uppercase; letter-spacing:.14em; font:800 11px/1 ui-monospace,SFMono-Regular,monospace; }
|
||||||
|
.eyebrow:before { content:""; width:26px; height:2px; background:var(--accent); }
|
||||||
|
h1 { max-width:830px; margin:20px 0 14px; font-size:clamp(42px,7vw,82px); line-height:.94; letter-spacing:-.065em; font-weight:850; }
|
||||||
|
.lede { max-width:660px; margin:0; color:var(--muted); font-size:17px; line-height:1.65; }
|
||||||
|
.search { display:flex; gap:10px; margin:34px 0 46px; padding:9px; border:1px solid var(--line); border-radius:16px; background:rgba(255,255,255,.88); box-shadow:var(--shadow); }
|
||||||
|
.search input { flex:1; min-width:0; border:0; outline:0; background:transparent; padding:9px 12px; color:var(--ink); font:500 14px/1.4 ui-monospace,SFMono-Regular,monospace; }
|
||||||
|
.search button,.button { border:0; border-radius:10px; background:var(--accent); color:white; padding:13px 18px; font-weight:800; cursor:pointer; }
|
||||||
|
.search button:hover,.button:hover { background:var(--ink); }
|
||||||
|
.stats { display:grid; grid-template-columns:repeat(4,1fr); gap:12px; margin-bottom:46px; }
|
||||||
|
.stat { min-height:136px; padding:22px; border:1px solid var(--line); border-radius:16px; background:rgba(255,255,255,.78); }
|
||||||
|
.stat.accent { background:var(--accent); border-color:var(--accent); color:white; }
|
||||||
|
.stat.accent .stat-label { color:rgba(255,255,255,.78); }
|
||||||
|
.stat-label { color:var(--muted); text-transform:uppercase; letter-spacing:.13em; font:750 10px/1 ui-monospace,SFMono-Regular,monospace; }
|
||||||
|
.stat strong { display:block; margin-top:21px; font-size:32px; line-height:1; letter-spacing:-.05em; }
|
||||||
|
.stat time { display:block; margin-top:18px; font:650 12px/1.5 ui-monospace,SFMono-Regular,monospace; }
|
||||||
|
.section-head { display:flex; align-items:end; justify-content:space-between; margin:0 0 14px; }
|
||||||
|
.section-head h2 { margin:0; font-size:22px; letter-spacing:-.035em; }
|
||||||
|
.section-head span { color:var(--muted); font:600 11px/1 ui-monospace,SFMono-Regular,monospace; }
|
||||||
|
.dashboard-section { margin-top:46px; }
|
||||||
|
.panel { overflow:hidden; border:1px solid var(--line); border-radius:18px; background:var(--card); box-shadow:0 8px 25px rgba(28,35,29,.04); }
|
||||||
|
table { width:100%; border-collapse:collapse; }
|
||||||
|
th { padding:14px 18px; background:var(--surface); color:var(--ink); text-align:start; text-transform:uppercase; letter-spacing:.1em; font:750 9px/1 ui-monospace,SFMono-Regular,monospace; }
|
||||||
|
td { padding:17px 18px; border-top:1px solid var(--line); font-size:13px; vertical-align:middle; }
|
||||||
|
tr:first-child td { border-top:0; }
|
||||||
|
tr:hover td { background:rgba(223,241,241,.72); }
|
||||||
|
.mono { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:12px; direction:ltr; unicode-bidi:isolate; }
|
||||||
|
.hash-link { font-weight:750; text-decoration:none; border-bottom:1px solid var(--teal); }
|
||||||
|
.pill { display:inline-flex; padding:6px 9px; border-radius:999px; background:var(--surface); font:750 10px/1 ui-monospace,SFMono-Regular,monospace; text-transform:uppercase; }
|
||||||
|
.holder-id { max-width:330px; overflow-wrap:anywhere; font-weight:750; }
|
||||||
|
.holder-id small { display:block; margin-top:5px; color:var(--muted); font:650 10px/1 ui-monospace,SFMono-Regular,monospace; }
|
||||||
|
.empty { padding:45px 24px; text-align:center; color:var(--muted); }
|
||||||
|
.notice + .transaction-list { margin-top:46px; }
|
||||||
|
.pagination { display:flex; align-items:center; justify-content:center; gap:8px; margin-top:18px; direction:ltr; }
|
||||||
|
.pagination a,.pagination span { min-width:42px; padding:10px 13px; border:1px solid var(--line); border-radius:10px; text-align:center; text-decoration:none; font:750 11px/1 ui-monospace,SFMono-Regular,monospace; }
|
||||||
|
.pagination a { background:var(--card); }
|
||||||
|
.pagination a:hover { background:var(--surface); }
|
||||||
|
.pagination .current { background:var(--ink); color:white; border-color:var(--ink); }
|
||||||
|
.pagination .disabled { color:var(--muted); opacity:.45; }
|
||||||
|
.transaction-filters { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr) auto auto; gap:10px; margin-bottom:14px; padding:14px; border:1px solid var(--line); border-radius:14px; background:var(--card); }
|
||||||
|
.transaction-filters .field input { height:40px; }
|
||||||
|
.transaction-filters .button { align-self:end; height:40px; padding-block:0; }
|
||||||
|
.transaction-filters .clear-filter { display:flex; align-items:center; align-self:end; height:40px; padding:0 12px; color:var(--muted); font-weight:750; text-decoration:none; }
|
||||||
|
.page-title h1 { font-size:clamp(42px,6vw,68px); }
|
||||||
|
.breadcrumb { margin-bottom:25px; color:var(--muted); font:650 11px/1 ui-monospace,SFMono-Regular,monospace; }
|
||||||
|
.breadcrumb a { text-decoration:none; }
|
||||||
|
.result-stack { display:grid; gap:18px; margin-top:34px; }
|
||||||
|
.journal { border:1px solid var(--line); border-radius:18px; background:var(--card); overflow:hidden; box-shadow:var(--shadow); }
|
||||||
|
.journal-head { padding:22px; display:flex; gap:20px; align-items:flex-start; justify-content:space-between; border-bottom:1px solid var(--line); }
|
||||||
|
.journal-head h2 { margin:8px 0 0; max-width:770px; overflow-wrap:anywhere; font:750 14px/1.5 ui-monospace,SFMono-Regular,monospace; }
|
||||||
|
.status { flex:none; padding:7px 10px; border-radius:999px; background:var(--teal); color:var(--ink); font:800 10px/1 ui-monospace,SFMono-Regular,monospace; }
|
||||||
|
.detail-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:1px; background:var(--line); border-bottom:1px solid var(--line); }
|
||||||
|
.detail { min-width:0; padding:18px 22px; background:var(--card); }
|
||||||
|
.detail label { display:block; margin-bottom:8px; color:var(--muted); text-transform:uppercase; letter-spacing:.1em; font:750 9px/1 ui-monospace,SFMono-Regular,monospace; }
|
||||||
|
.detail div { overflow-wrap:anywhere; font-size:13px; }
|
||||||
|
.entries { padding:18px 22px 22px; }
|
||||||
|
.entries h3 { margin:0 0 12px; font-size:13px; }
|
||||||
|
.entry { display:grid; grid-template-columns:46px minmax(0,1fr) 170px; gap:15px; align-items:center; padding:13px 0; border-top:1px solid var(--line); }
|
||||||
|
.entry:first-of-type { border-top:0; }
|
||||||
|
.entry-number { color:var(--muted); font:650 11px/1 ui-monospace,SFMono-Regular,monospace; }
|
||||||
|
.entry-account a { font-weight:700; text-decoration:none; }
|
||||||
|
.entry-account small { display:block; color:var(--muted); margin-top:4px; }
|
||||||
|
.amount { text-align:end; direction:ltr; font:800 13px/1 ui-monospace,SFMono-Regular,monospace; }
|
||||||
|
.amount.positive { color:#497f82; }
|
||||||
|
.amount.negative { color:var(--accent); }
|
||||||
|
.notice { margin-top:28px; padding:20px; border:1px solid rgba(255,0,0,.32); border-radius:14px; background:rgba(255,0,0,.06); color:var(--ink); }
|
||||||
|
.notice strong { display:block; margin-bottom:5px; }
|
||||||
|
.account-form { display:grid; grid-template-columns:1.25fr 1fr 1.2fr .65fr auto; gap:10px; margin:34px 0; padding:14px; border:1px solid var(--line); border-radius:16px; background:var(--card); box-shadow:var(--shadow); }
|
||||||
|
.field label { display:block; margin:0 0 7px 3px; color:var(--muted); text-transform:uppercase; letter-spacing:.09em; font:750 9px/1 ui-monospace,SFMono-Regular,monospace; }
|
||||||
|
.field input,.field select { width:100%; height:43px; border:1px solid var(--line); border-radius:9px; outline:0; padding:0 11px; background:white; color:var(--ink); }
|
||||||
|
.field input:focus,.field select:focus { border-color:var(--teal); box-shadow:0 0 0 3px rgba(187,213,218,.42); }
|
||||||
|
.account-form button { align-self:end; height:43px; }
|
||||||
|
.account-card { display:grid; grid-template-columns:1.35fr .65fr; gap:1px; background:var(--line); border:1px solid var(--line); border-radius:18px; overflow:hidden; margin-bottom:30px; }
|
||||||
|
.account-card > div { padding:25px; background:var(--card); }
|
||||||
|
.account-card h2 { margin:8px 0 0; font:800 16px/1.5 ui-monospace,SFMono-Regular,monospace; overflow-wrap:anywhere; }
|
||||||
|
.balance { font-size:34px; line-height:1; font-weight:850; letter-spacing:-.04em; overflow-wrap:anywhere; }
|
||||||
|
.htmx-request .search-label { display:none; }
|
||||||
|
.htmx-request button:after { content:" …"; }
|
||||||
|
footer { padding:24px 0 38px; border-top:1px solid var(--line); color:var(--muted); display:flex; justify-content:space-between; font-size:11px; }
|
||||||
|
html[dir="rtl"] body { letter-spacing:0; }
|
||||||
|
html[dir="rtl"] h1 { letter-spacing:-.035em; }
|
||||||
|
html[dir="rtl"] .search input { font-family:Tahoma,ui-sans-serif,system-ui,sans-serif; }
|
||||||
|
@media (max-width:850px) { .stats{grid-template-columns:repeat(2,1fr)} .account-form{grid-template-columns:1fr 1fr} .account-form button{grid-column:1/-1} .transaction-filters{grid-template-columns:1fr 1fr} .detail-grid{grid-template-columns:1fr 1fr} .nav>a{display:none} }
|
||||||
|
@media (max-width:620px) { .shell{width:min(100% - 24px,1180px)} .topbar{min-height:66px} .live{display:none} main{padding-top:42px} .stats{grid-template-columns:1fr 1fr} .stat{min-height:115px;padding:17px}.stat strong{font-size:25px} .search{display:grid}.search button{width:100%} .transaction-filters{grid-template-columns:1fr}.panel{overflow-x:auto} table{min-width:720px} .journal-head{display:block}.status{display:inline-flex;margin-top:14px}.detail-grid{grid-template-columns:1fr}.entry{grid-template-columns:32px minmax(0,1fr)}.amount{grid-column:2;text-align:start}.account-form{grid-template-columns:1fr}.account-form button{grid-column:auto}.account-card{grid-template-columns:1fr} footer{display:block;line-height:1.8} }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body hx-boost="true" hx-target="#explorer-content" hx-select="#explorer-content" hx-swap="outerHTML show:top" hx-push-url="true">
|
||||||
|
<header class="shell topbar">
|
||||||
|
<a class="brand" href="/" hx-target="body" hx-select="body">
|
||||||
|
<img class="mark" src="/favicon.ico" alt="" width="35" height="35" aria-hidden="true"/>
|
||||||
|
<span>{ tr(locale, "site_name") } <small>{ tr(locale, "site_subtitle") }</small></span>
|
||||||
|
</a>
|
||||||
|
<nav class="nav" aria-label="Main navigation" hx-target="body" hx-select="body">
|
||||||
|
if active == "dashboard" {
|
||||||
|
<a class="active" href="/">{ tr(locale, "overview") }</a>
|
||||||
|
} else {
|
||||||
|
<a href="/">{ tr(locale, "overview") }</a>
|
||||||
|
}
|
||||||
|
if active == "transactions" {
|
||||||
|
<a class="active" href="/transactions">{ tr(locale, "transactions") }</a>
|
||||||
|
} else {
|
||||||
|
<a href="/transactions">{ tr(locale, "transactions") }</a>
|
||||||
|
}
|
||||||
|
if active == "assets" {
|
||||||
|
<a class="active" href="/assets">{ tr(locale, "assets") }</a>
|
||||||
|
} else {
|
||||||
|
<a href="/assets">{ tr(locale, "assets") }</a>
|
||||||
|
}
|
||||||
|
if active == "accounts" {
|
||||||
|
<a class="active" href="/accounts">{ tr(locale, "accounts") }</a>
|
||||||
|
} else {
|
||||||
|
<a href="/accounts">{ tr(locale, "accounts") }</a>
|
||||||
|
}
|
||||||
|
<span class="live"><span class="dot"></span> { tr(locale, "read_only") }</span>
|
||||||
|
<span class="language-switch" aria-label="Language">
|
||||||
|
if locale == LocaleEnglish {
|
||||||
|
<a class="active" href={ templ.URL(englishURL) } hx-boost="false" lang="en">EN</a>
|
||||||
|
} else {
|
||||||
|
<a href={ templ.URL(englishURL) } hx-boost="false" lang="en">EN</a>
|
||||||
|
}
|
||||||
|
if locale == LocalePersian {
|
||||||
|
<a class="active" href={ templ.URL(persianURL) } hx-boost="false" lang="fa">فا</a>
|
||||||
|
} else {
|
||||||
|
<a href={ templ.URL(persianURL) } hx-boost="false" lang="fa">فا</a>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
@content
|
||||||
|
<footer class="shell">
|
||||||
|
<span>{ tr(locale, "footer_description") }</span>
|
||||||
|
<span>{ tr(locale, "timezone_notice") }</span>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ TransactionSearch(value string, locale Locale) {
|
||||||
|
<form class="search" action="/transactions" method="get" hx-get="/transactions" hx-target="#explorer-content" hx-push-url="true">
|
||||||
|
<input name="q" value={ value } placeholder={ tr(locale, "transaction_placeholder") } aria-label={ tr(locale, "transaction_hash") } autocomplete="off" required/>
|
||||||
|
<button type="submit"><span class="search-label">{ tr(locale, "explore_transaction") }</span></button>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ DashboardContent(data explorer.Dashboard, locale Locale) {
|
||||||
|
<main id="explorer-content" class="shell">
|
||||||
|
<section>
|
||||||
|
<div class="eyebrow">{ tr(locale, "general_ledger_explorer") }</div>
|
||||||
|
<h1>{ tr(locale, "hero_line_one") }<br/>{ tr(locale, "hero_line_two") }</h1>
|
||||||
|
<p class="lede">{ tr(locale, "hero_description") }</p>
|
||||||
|
@TransactionSearch("", locale)
|
||||||
|
</section>
|
||||||
|
<section class="stats" aria-label={ tr(locale, "general_ledger_explorer") }>
|
||||||
|
<div class="stat accent"><span class="stat-label">{ tr(locale, "committed_journals") }</span><strong>{ count(data.Stats.JournalCount) }</strong></div>
|
||||||
|
<div class="stat"><span class="stat-label">{ tr(locale, "ledger_entries") }</span><strong>{ count(data.Stats.EntryCount) }</strong></div>
|
||||||
|
<div class="stat"><span class="stat-label">{ tr(locale, "tracked_accounts") }</span><strong>{ count(data.Stats.AccountCount) }</strong></div>
|
||||||
|
<div class="stat"><span class="stat-label">{ tr(locale, "last_recorded") }</span><time>{ formatTime(data.Stats.LastRecordedAt) }</time></div>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<div class="section-head"><h2>{ tr(locale, "latest_activity") }</h2><span>{ tr(locale, "newest_first") }</span></div>
|
||||||
|
@JournalTable(data.Journals, locale)
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ AssetsContent(data explorer.Assets, locale Locale) {
|
||||||
|
<main id="explorer-content" class="shell">
|
||||||
|
<div class="breadcrumb"><a href="/">{ tr(locale, "network") }</a> / { tr(locale, "assets") }</div>
|
||||||
|
<section class="page-title">
|
||||||
|
<div class="eyebrow">{ tr(locale, "assets_explorer") }</div>
|
||||||
|
<h1>{ tr(locale, "track_asset_ownership") }</h1>
|
||||||
|
<p class="lede">{ tr(locale, "assets_description") }</p>
|
||||||
|
</section>
|
||||||
|
<section class="dashboard-section">
|
||||||
|
<div class="section-head"><h2>{ tr(locale, "top_asset_holders") }</h2><span>{ tr(locale, "holder_balance_scope") }</span></div>
|
||||||
|
@HolderTable(data.TopHolders, locale)
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ HolderTable(holders []explorer.Holder, locale Locale) {
|
||||||
|
<div class="panel">
|
||||||
|
if len(holders) == 0 {
|
||||||
|
<div class="empty">{ tr(locale, "no_asset_holders") }</div>
|
||||||
|
} else {
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>{ tr(locale, "asset") }</th><th>{ tr(locale, "rank") }</th><th>{ tr(locale, "holder") }</th><th>{ tr(locale, "balance") }</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
for _, holder := range holders {
|
||||||
|
<tr>
|
||||||
|
<td><span class="pill">{ tr(locale, "asset") } { strconv.FormatInt(holder.AssetID, 10) }</span></td>
|
||||||
|
<td class="mono">#{ strconv.FormatInt(holder.Rank, 10) }</td>
|
||||||
|
<td class="holder-id mono"><a class="hash-link" href={ templ.URL(holderURL(holder)) }>{ holder.OwnerID }</a><small>{ holder.OwnerType }</small></td>
|
||||||
|
<td class="amount positive">{ holder.Balance.String() }</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ HolderContent(data HolderPageData, locale Locale) {
|
||||||
|
<main id="explorer-content" class="shell">
|
||||||
|
<div class="breadcrumb"><a href="/">{ tr(locale, "network") }</a> / <a href="/assets">{ tr(locale, "assets") }</a> / { tr(locale, "holder") }</div>
|
||||||
|
<section class="page-title">
|
||||||
|
<div class="eyebrow">{ tr(locale, "holder_account") }</div>
|
||||||
|
<h1>{ tr(locale, "inspect_holder") }</h1>
|
||||||
|
<p class="lede">{ tr(locale, "holder_description") }</p>
|
||||||
|
</section>
|
||||||
|
if data.Error != "" {
|
||||||
|
<div class="notice"><strong>{ tr(locale, "account_unavailable") }</strong>{ data.Error }</div>
|
||||||
|
} else if data.Account != nil {
|
||||||
|
<div class="account-card result-stack">
|
||||||
|
<div><span class="stat-label">{ tr(locale, "holder") }</span><h2 class="mono">{ data.Account.Reference.OwnerID }</h2><p class="mono">{ data.Account.Reference.OwnerType } · { tr(locale, "asset") } { strconv.FormatInt(data.Account.Reference.AssetID, 10) }</p></div>
|
||||||
|
<div><span class="stat-label">{ tr(locale, "combined_balance") }</span><div class="balance">{ data.Account.Balance.String() }</div></div>
|
||||||
|
</div>
|
||||||
|
<section class="dashboard-section">
|
||||||
|
<div class="section-head"><h2>{ tr(locale, "account_activity") }</h2><span>{ tr(locale, "available_and_frozen") }</span></div>
|
||||||
|
@JournalTable(data.Account.Journals, locale)
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
</main>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ JournalTable(journals []ledger.Journal, locale Locale) {
|
||||||
|
<div class="panel">
|
||||||
|
if len(journals) == 0 {
|
||||||
|
<div class="empty">{ tr(locale, "no_journals") }</div>
|
||||||
|
} else {
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>{ tr(locale, "transaction") }</th><th>{ tr(locale, "effect") }</th><th>{ tr(locale, "source") }</th><th>{ tr(locale, "entries") }</th><th>{ tr(locale, "recorded") }</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
for _, journal := range journals {
|
||||||
|
<tr>
|
||||||
|
<td class="mono">
|
||||||
|
<a class="hash-link" href={ templ.URL(transactionURL(transactionReference(journal))) }>{ transactionName(journal, locale) }</a>
|
||||||
|
</td>
|
||||||
|
<td><span class="pill">{ journal.EffectKind }</span></td>
|
||||||
|
<td>{ journal.SourceService }</td>
|
||||||
|
<td>{ strconv.Itoa(len(journal.Entries)) }</td>
|
||||||
|
<td class="mono">{ formatTime(journal.RecordedAt) }</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ TransactionContent(data TransactionPageData, locale Locale) {
|
||||||
|
<main id="explorer-content" class="shell">
|
||||||
|
<div class="breadcrumb"><a href="/">{ tr(locale, "network") }</a> / { tr(locale, "transactions") }</div>
|
||||||
|
<section class="page-title">
|
||||||
|
<div class="eyebrow">{ tr(locale, "transaction_explorer") }</div>
|
||||||
|
<h1>{ tr(locale, "trace_settlement") }</h1>
|
||||||
|
<p class="lede">{ tr(locale, "transaction_description") }</p>
|
||||||
|
@TransactionSearch(data.Query, locale)
|
||||||
|
</section>
|
||||||
|
if data.Error != "" {
|
||||||
|
<div class="notice"><strong>{ tr(locale, "transaction_not_found") }</strong>{ data.Error }</div>
|
||||||
|
}
|
||||||
|
if data.Query != "" && data.Error == "" {
|
||||||
|
<div class="result-stack">
|
||||||
|
for _, journal := range data.Journals {
|
||||||
|
@JournalCard(journal, locale)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
if data.Listing != nil {
|
||||||
|
<section class="transaction-list">
|
||||||
|
<div class="section-head"><h2>{ tr(locale, "latest_transactions") }</h2><span>{ tr(locale, "page") } { strconv.Itoa(data.Listing.Filter.Page) }</span></div>
|
||||||
|
<form class="transaction-filters" action="/transactions" method="get" hx-get="/transactions" hx-target="#explorer-content" hx-push-url="true">
|
||||||
|
<div class="field"><label for="wallet">{ tr(locale, "user_wallet") }</label><input id="wallet" name="wallet" value={ data.Listing.Filter.Wallet } placeholder={ tr(locale, "user_wallet_placeholder") } autocomplete="off"/></div>
|
||||||
|
<div class="field"><label for="effect">{ tr(locale, "effect_type") }</label><input id="effect" name="effect" value={ data.Listing.Filter.EffectKind } placeholder={ tr(locale, "effect_type_placeholder") } autocomplete="off"/></div>
|
||||||
|
<button class="button" type="submit">{ tr(locale, "apply_filters") }</button>
|
||||||
|
<a class="clear-filter" href="/transactions">{ tr(locale, "clear_filters") }</a>
|
||||||
|
</form>
|
||||||
|
@JournalTable(data.Listing.Journals, locale)
|
||||||
|
@Pagination(*data.Listing, locale)
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
</main>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ Pagination(listing explorer.TransactionListing, locale Locale) {
|
||||||
|
<nav class="pagination" aria-label={ tr(locale, "pagination") }>
|
||||||
|
if listing.HasPrevious {
|
||||||
|
<a href={ templ.URL(transactionPageURL(listing.Filter, listing.Filter.Page - 1)) } rel="prev">{ tr(locale, "previous") }</a>
|
||||||
|
} else {
|
||||||
|
<span class="disabled">{ tr(locale, "previous") }</span>
|
||||||
|
}
|
||||||
|
<span class="current" aria-current="page">{ strconv.Itoa(listing.Filter.Page) }</span>
|
||||||
|
if listing.HasNext {
|
||||||
|
<a href={ templ.URL(transactionPageURL(listing.Filter, listing.Filter.Page + 1)) } rel="next">{ tr(locale, "next") }</a>
|
||||||
|
} else {
|
||||||
|
<span class="disabled">{ tr(locale, "next") }</span>
|
||||||
|
}
|
||||||
|
</nav>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ JournalCard(journal ledger.Journal, locale Locale) {
|
||||||
|
<article class="journal">
|
||||||
|
<header class="journal-head">
|
||||||
|
<div>
|
||||||
|
if journal.Blockchain.TransactionHash != "" {
|
||||||
|
<span class="stat-label">{ tr(locale, "transaction_hash") }</span>
|
||||||
|
} else {
|
||||||
|
<span class="stat-label">{ tr(locale, "journal_id") }</span>
|
||||||
|
}
|
||||||
|
<h2 class="mono">{ transactionReference(journal) }</h2>
|
||||||
|
</div>
|
||||||
|
<span class="status">{ tr(locale, "committed") }</span>
|
||||||
|
</header>
|
||||||
|
<div class="detail-grid">
|
||||||
|
<div class="detail"><label>{ tr(locale, "effect") }</label><div><span class="pill">{ journal.EffectKind }</span></div></div>
|
||||||
|
<div class="detail"><label>{ tr(locale, "journal_id") }</label><div class="mono">{ journal.ID }</div></div>
|
||||||
|
<div class="detail"><label>{ tr(locale, "recorded") }</label><div>{ formatTime(journal.RecordedAt) }</div></div>
|
||||||
|
<div class="detail"><label>{ tr(locale, "source") }</label><div>{ journal.SourceService } · { journal.SourceTransactionID }</div></div>
|
||||||
|
<div class="detail"><label>{ tr(locale, "network") }</label><div>{ journal.Blockchain.Network }</div></div>
|
||||||
|
<div class="detail"><label>{ tr(locale, "ledger_sequence") }</label><div class="mono">{ journal.Blockchain.LedgerSequence }</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="entries">
|
||||||
|
<h3>{ tr(locale, "balanced_entries") }</h3>
|
||||||
|
for _, entry := range journal.Entries {
|
||||||
|
<div class="entry">
|
||||||
|
<span class="entry-number">#{ strconv.FormatUint(uint64(entry.LineNumber), 10) }</span>
|
||||||
|
<div class="entry-account"><a href={ templ.URL(accountURL(entry.Account)) }>{ accountName(entry.Account, locale) }</a><small>{ tr(locale, "asset") } { strconv.FormatInt(entry.Account.AssetID, 10) } · { entry.Account.OwnerType }</small></div>
|
||||||
|
if len(entry.Amount.String()) > 0 && entry.Amount.String()[0] == '-' {
|
||||||
|
<div class="amount negative">{ entry.Amount.String() }</div>
|
||||||
|
} else {
|
||||||
|
<div class="amount positive">+{ entry.Amount.String() }</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ AccountContent(data AccountPageData, locale Locale) {
|
||||||
|
<main id="explorer-content" class="shell">
|
||||||
|
<div class="breadcrumb"><a href="/">{ tr(locale, "network") }</a> / { tr(locale, "accounts") }</div>
|
||||||
|
<section class="page-title">
|
||||||
|
<div class="eyebrow">{ tr(locale, "account_explorer") }</div>
|
||||||
|
<h1>{ tr(locale, "rebuild_account") }</h1>
|
||||||
|
<p class="lede">{ tr(locale, "account_description") }</p>
|
||||||
|
</section>
|
||||||
|
<form class="account-form" action="/accounts" method="get" hx-get="/accounts" hx-target="#explorer-content" hx-push-url="true">
|
||||||
|
<div class="field"><label for="class">{ tr(locale, "account_class") }</label><select id="class" name="class" required>
|
||||||
|
for _, class := range accountClasses() {
|
||||||
|
if string(class) == data.Input.Class {
|
||||||
|
<option value={ string(class) } selected>{ string(class) }</option>
|
||||||
|
} else {
|
||||||
|
<option value={ string(class) }>{ string(class) }</option>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</select></div>
|
||||||
|
<div class="field"><label for="owner_type">{ tr(locale, "owner_type") }</label><input id="owner_type" name="owner_type" value={ data.Input.OwnerType } placeholder="user"/></div>
|
||||||
|
<div class="field"><label for="owner_id">{ tr(locale, "owner_id") }</label><input id="owner_id" name="owner_id" value={ data.Input.OwnerID } placeholder={ tr(locale, "stable_owner_id") }/></div>
|
||||||
|
<div class="field"><label for="asset_id">{ tr(locale, "asset_id") }</label><input id="asset_id" name="asset_id" value={ data.Input.AssetID } inputmode="numeric" placeholder="1" required/></div>
|
||||||
|
<button class="button" type="submit">{ tr(locale, "explore") }</button>
|
||||||
|
</form>
|
||||||
|
if data.Error != "" {
|
||||||
|
<div class="notice"><strong>{ tr(locale, "account_unavailable") }</strong>{ data.Error }</div>
|
||||||
|
} else if data.Account != nil {
|
||||||
|
<div class="account-card">
|
||||||
|
<div><span class="stat-label">{ tr(locale, "ledger_identity") }</span><h2>{ accountName(data.Account.Reference, locale) }</h2><p class="mono">{ data.Account.Reference.OwnerType } · { tr(locale, "asset") } { strconv.FormatInt(data.Account.Reference.AssetID, 10) }</p></div>
|
||||||
|
<div><span class="stat-label">{ tr(locale, "current_balance") }</span><div class="balance">{ data.Account.Balance.String() }</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="section-head"><h2>{ tr(locale, "account_activity") }</h2><span>{ strconv.Itoa(len(data.Account.Journals)) } { tr(locale, "journals") }</span></div>
|
||||||
|
@JournalTable(data.Account.Journals, locale)
|
||||||
|
} else {
|
||||||
|
<div class="panel empty">{ tr(locale, "choose_account") }</div>
|
||||||
|
}
|
||||||
|
</main>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ FailureContent(title string, message string, locale Locale) {
|
||||||
|
<main id="explorer-content" class="shell">
|
||||||
|
<section class="page-title"><div class="eyebrow">{ tr(locale, "explorer_error") }</div><h1>{ title }</h1><p class="lede">{ message }</p></section>
|
||||||
|
</main>
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,23 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -u
|
||||||
|
|
||||||
|
./tmp/gl -conf ./gl.cfg.toml &
|
||||||
|
gl_pid=$!
|
||||||
|
|
||||||
|
./tmp/dashboard -conf ./dashboard.cfg.toml &
|
||||||
|
dashboard_pid=$!
|
||||||
|
|
||||||
|
shutdown() {
|
||||||
|
trap - EXIT
|
||||||
|
kill -TERM "$gl_pid" "$dashboard_pid" 2>/dev/null || true
|
||||||
|
wait "$gl_pid" 2>/dev/null || true
|
||||||
|
wait "$dashboard_pid" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
trap 'exit 0' INT TERM
|
||||||
|
trap shutdown EXIT
|
||||||
|
|
||||||
|
wait -n "$gl_pid" "$dashboard_pid"
|
||||||
|
status=$?
|
||||||
|
exit "$status"
|
||||||
Reference in New Issue
Block a user