feat: add localized ledger explorer dashboard
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user