chore: update project changes
This commit is contained in:
@@ -13,10 +13,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
recentJournalLimit = 12
|
recentJournalLimit = 12
|
||||||
transactionPageSize = 20
|
recentSettlementLimit = 12
|
||||||
topAssetLimit = 5
|
transactionPageSize = 20
|
||||||
topHoldersPerAsset = 5
|
topAssetLimit = 5
|
||||||
|
topHoldersPerAsset = 5
|
||||||
)
|
)
|
||||||
|
|
||||||
type Stats struct {
|
type Stats struct {
|
||||||
@@ -26,6 +27,25 @@ type Stats struct {
|
|||||||
LastRecordedAt time.Time
|
LastRecordedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SettlementStats struct {
|
||||||
|
Pending int64
|
||||||
|
Retryable int64
|
||||||
|
Confirmed int64
|
||||||
|
ManualReview int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type Settlement struct {
|
||||||
|
ID string
|
||||||
|
SourceService string
|
||||||
|
SourceTransactionID string
|
||||||
|
Status ledger.SettlementStatus
|
||||||
|
Network string
|
||||||
|
TransactionHash string
|
||||||
|
Attempts int
|
||||||
|
LastError string
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
type Holder struct {
|
type Holder struct {
|
||||||
Rank int64
|
Rank int64
|
||||||
OwnerType string
|
OwnerType string
|
||||||
@@ -47,6 +67,7 @@ type Repository interface {
|
|||||||
GetByID(context.Context, string) (ledger.Journal, error)
|
GetByID(context.Context, string) (ledger.Journal, error)
|
||||||
Balance(context.Context, ledger.AccountReference, time.Time) (ledger.Amount, error)
|
Balance(context.Context, ledger.AccountReference, time.Time) (ledger.Amount, error)
|
||||||
TopHolders(context.Context, int, int) ([]Holder, error)
|
TopHolders(context.Context, int, int) ([]Holder, error)
|
||||||
|
SettlementDashboard(context.Context, int) (SettlementStats, []Settlement, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
@@ -54,8 +75,10 @@ type Service struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Dashboard struct {
|
type Dashboard struct {
|
||||||
Stats Stats
|
Stats Stats
|
||||||
Journals []ledger.Journal
|
Journals []ledger.Journal
|
||||||
|
SettlementStats SettlementStats
|
||||||
|
Settlements []Settlement
|
||||||
}
|
}
|
||||||
|
|
||||||
type Assets struct {
|
type Assets struct {
|
||||||
@@ -100,7 +123,11 @@ func (s *Service) Dashboard(ctx context.Context) (Dashboard, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Dashboard{}, fmt.Errorf("read recent journals: %w", err)
|
return Dashboard{}, fmt.Errorf("read recent journals: %w", err)
|
||||||
}
|
}
|
||||||
return Dashboard{Stats: stats, Journals: journals}, nil
|
settlementStats, settlements, err := s.repository.SettlementDashboard(ctx, recentSettlementLimit)
|
||||||
|
if err != nil {
|
||||||
|
return Dashboard{}, fmt.Errorf("read settlement dashboard: %w", err)
|
||||||
|
}
|
||||||
|
return Dashboard{Stats: stats, Journals: journals, SettlementStats: settlementStats, Settlements: settlements}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Assets(ctx context.Context) (Assets, error) {
|
func (s *Service) Assets(ctx context.Context) (Assets, error) {
|
||||||
|
|||||||
@@ -10,14 +10,16 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type repositoryStub struct {
|
type repositoryStub struct {
|
||||||
stats Stats
|
stats Stats
|
||||||
journals []ledger.Journal
|
journals []ledger.Journal
|
||||||
transactions []ledger.Journal
|
transactions []ledger.Journal
|
||||||
journal ledger.Journal
|
journal ledger.Journal
|
||||||
journalErr error
|
journalErr error
|
||||||
holders []Holder
|
holders []Holder
|
||||||
balance ledger.Amount
|
balance ledger.Amount
|
||||||
lastFilter *ledger.JournalFilter
|
settlementStats SettlementStats
|
||||||
|
settlements []Settlement
|
||||||
|
lastFilter *ledger.JournalFilter
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r repositoryStub) Stats(context.Context) (Stats, error) { return r.stats, nil }
|
func (r repositoryStub) Stats(context.Context) (Stats, error) { return r.stats, nil }
|
||||||
@@ -39,6 +41,23 @@ func (r repositoryStub) Balance(context.Context, ledger.AccountReference, time.T
|
|||||||
func (r repositoryStub) TopHolders(context.Context, int, int) ([]Holder, error) {
|
func (r repositoryStub) TopHolders(context.Context, int, int) ([]Holder, error) {
|
||||||
return r.holders, nil
|
return r.holders, nil
|
||||||
}
|
}
|
||||||
|
func (r repositoryStub) SettlementDashboard(context.Context, int) (SettlementStats, []Settlement, error) {
|
||||||
|
return r.settlementStats, r.settlements, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDashboardIncludesBlockchainSettlements(t *testing.T) {
|
||||||
|
service := NewService(repositoryStub{
|
||||||
|
settlementStats: SettlementStats{Confirmed: 7, Pending: 2},
|
||||||
|
settlements: []Settlement{{ID: "settlement-1", Status: ledger.SettlementConfirmed, TransactionHash: "chain-hash"}},
|
||||||
|
})
|
||||||
|
result, err := service.Dashboard(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.SettlementStats.Confirmed != 7 || len(result.Settlements) != 1 || result.Settlements[0].TransactionHash != "chain-hash" {
|
||||||
|
t.Fatalf("unexpected settlement dashboard: %+v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAssetsIncludesTopHolders(t *testing.T) {
|
func TestAssetsIncludesTopHolders(t *testing.T) {
|
||||||
balance, err := ledger.ParseAmount("125.5")
|
balance, err := ledger.ParseAmount("125.5")
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ func Configure(service string, level slog.Leveler) *slog.Logger {
|
|||||||
attr.Key = "timestamp"
|
attr.Key = "timestamp"
|
||||||
case slog.LevelKey:
|
case slog.LevelKey:
|
||||||
attr.Key = "severity"
|
attr.Key = "severity"
|
||||||
|
attr.Value = slog.StringValue(levelName(attr.Value))
|
||||||
case slog.MessageKey:
|
case slog.MessageKey:
|
||||||
attr.Key = "message"
|
attr.Key = "message"
|
||||||
case slog.SourceKey:
|
case slog.SourceKey:
|
||||||
@@ -32,6 +33,16 @@ func Configure(service string, level slog.Leveler) *slog.Logger {
|
|||||||
return logger
|
return logger
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func levelName(value slog.Value) string {
|
||||||
|
if value.Kind() == slog.KindInt64 {
|
||||||
|
return slog.Level(value.Int64()).String()
|
||||||
|
}
|
||||||
|
if level, ok := value.Any().(slog.Level); ok {
|
||||||
|
return level.String()
|
||||||
|
}
|
||||||
|
return value.String()
|
||||||
|
}
|
||||||
|
|
||||||
type traceHandler struct{ slog.Handler }
|
type traceHandler struct{ slog.Handler }
|
||||||
|
|
||||||
func (h *traceHandler) Handle(ctx context.Context, record slog.Record) error {
|
func (h *traceHandler) Handle(ctx context.Context, record slog.Record) error {
|
||||||
|
|||||||
@@ -86,6 +86,42 @@ func (r *JournalRepository) Stats(ctx context.Context) (explorer.Stats, error) {
|
|||||||
return stats, nil
|
return stats, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *JournalRepository) SettlementDashboard(ctx context.Context, limit int) (explorer.SettlementStats, []explorer.Settlement, error) {
|
||||||
|
if limit <= 0 || limit > 100 {
|
||||||
|
limit = 12
|
||||||
|
}
|
||||||
|
var stats explorer.SettlementStats
|
||||||
|
if err := r.database.QueryRow(ctx, `SELECT
|
||||||
|
count(*) FILTER (WHERE status='PENDING'),
|
||||||
|
count(*) FILTER (WHERE status='RETRYABLE'),
|
||||||
|
count(*) FILTER (WHERE status='CONFIRMED'),
|
||||||
|
count(*) FILTER (WHERE status='MANUAL_REVIEW')
|
||||||
|
FROM kuknos_settlements`).Scan(&stats.Pending, &stats.Retryable, &stats.Confirmed, &stats.ManualReview); err != nil {
|
||||||
|
return explorer.SettlementStats{}, nil, fmt.Errorf("read settlement stats: %w", err)
|
||||||
|
}
|
||||||
|
rows, err := r.database.Query(ctx, `SELECT id, source_service, source_transaction_id, status,
|
||||||
|
network, transaction_hash, attempts, last_error, updated_at
|
||||||
|
FROM kuknos_settlements ORDER BY updated_at DESC LIMIT $1`, limit)
|
||||||
|
if err != nil {
|
||||||
|
return explorer.SettlementStats{}, nil, fmt.Errorf("list recent settlements: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
settlements := make([]explorer.Settlement, 0, limit)
|
||||||
|
for rows.Next() {
|
||||||
|
var settlement explorer.Settlement
|
||||||
|
if err := rows.Scan(&settlement.ID, &settlement.SourceService, &settlement.SourceTransactionID,
|
||||||
|
&settlement.Status, &settlement.Network, &settlement.TransactionHash, &settlement.Attempts,
|
||||||
|
&settlement.LastError, &settlement.UpdatedAt); err != nil {
|
||||||
|
return explorer.SettlementStats{}, nil, fmt.Errorf("scan recent settlement: %w", err)
|
||||||
|
}
|
||||||
|
settlements = append(settlements, settlement)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return explorer.SettlementStats{}, nil, fmt.Errorf("iterate recent settlements: %w", err)
|
||||||
|
}
|
||||||
|
return stats, settlements, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *JournalRepository) TopHolders(ctx context.Context, assetLimit, holderLimit int) ([]explorer.Holder, error) {
|
func (r *JournalRepository) TopHolders(ctx context.Context, assetLimit, holderLimit int) ([]explorer.Holder, error) {
|
||||||
if assetLimit <= 0 || assetLimit > 20 {
|
if assetLimit <= 0 || assetLimit > 20 {
|
||||||
assetLimit = 5
|
assetLimit = 5
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ func TestDashboardRendersFullTemplPage(t *testing.T) {
|
|||||||
ID: "journal-1", EffectKind: "deposit", SourceService: "wallet",
|
ID: "journal-1", EffectKind: "deposit", SourceService: "wallet",
|
||||||
Blockchain: ledger.BlockchainReference{TransactionHash: "abc123"},
|
Blockchain: ledger.BlockchainReference{TransactionHash: "abc123"},
|
||||||
}},
|
}},
|
||||||
|
SettlementStats: explorer.SettlementStats{Confirmed: 9, Pending: 2},
|
||||||
|
Settlements: []explorer.Settlement{{ID: "settlement-1", Status: ledger.SettlementConfirmed, Network: "kuknos", TransactionHash: "chain-hash", SourceService: "wallet", Attempts: 1}},
|
||||||
}}
|
}}
|
||||||
request := httptest.NewRequest(http.MethodGet, "/", nil)
|
request := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
response := httptest.NewRecorder()
|
response := httptest.NewRecorder()
|
||||||
@@ -72,7 +74,7 @@ func TestDashboardRendersFullTemplPage(t *testing.T) {
|
|||||||
NewHandler(service).ServeHTTP(response, request)
|
NewHandler(service).ServeHTTP(response, request)
|
||||||
|
|
||||||
body := response.Body.String()
|
body := response.Body.String()
|
||||||
for _, expected := range []string{"<!doctype html>", "DARANO", "1,234", "abc123", "htmx.org@2.0.10", "src=\"/theme.js\"", "data-theme-toggle", "html[data-theme=\"dark\"]", "class=\"mark\" src=\"/favicon.ico\"", "#F5F5F5", "#DFF1F1", "#BBD5DA", "#FF0000"} {
|
for _, expected := range []string{"<!doctype html>", "DARANO", "1,234", "abc123", "Blockchain settlements", "Confirmed on chain", "chain-hash", "htmx.org@2.0.10", "src=\"/theme.js\"", "data-theme-toggle", "html[data-theme=\"dark\"]", "class=\"mark\" src=\"/favicon.ico\"", "#F5F5F5", "#DFF1F1", "#BBD5DA", "#FF0000"} {
|
||||||
if !strings.Contains(body, expected) {
|
if !strings.Contains(body, expected) {
|
||||||
t.Fatalf("response did not contain %q", expected)
|
t.Fatalf("response did not contain %q", expected)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,6 +109,18 @@ var english = map[string]string{
|
|||||||
"tracked_accounts": "Tracked accounts",
|
"tracked_accounts": "Tracked accounts",
|
||||||
"last_recorded": "Last recorded",
|
"last_recorded": "Last recorded",
|
||||||
"latest_activity": "Latest ledger activity",
|
"latest_activity": "Latest ledger activity",
|
||||||
|
"blockchain_settlements": "Blockchain settlements",
|
||||||
|
"settlement_summary": "LATEST SETTLEMENT STATE",
|
||||||
|
"confirmed_on_chain": "Confirmed on chain",
|
||||||
|
"pending_submission": "Pending submission",
|
||||||
|
"retryable": "Retryable",
|
||||||
|
"manual_review": "Manual review",
|
||||||
|
"blockchain_transaction": "Blockchain transaction",
|
||||||
|
"status": "Status",
|
||||||
|
"attempts": "Attempts",
|
||||||
|
"updated": "Updated",
|
||||||
|
"not_submitted": "Not submitted",
|
||||||
|
"no_settlements": "No blockchain settlements have been queued yet.",
|
||||||
"newest_first": "NEWEST FIRST",
|
"newest_first": "NEWEST FIRST",
|
||||||
"top_asset_holders": "Top asset holders",
|
"top_asset_holders": "Top asset holders",
|
||||||
"holder_balance_scope": "AVAILABLE + FROZEN · RECENT ASSETS",
|
"holder_balance_scope": "AVAILABLE + FROZEN · RECENT ASSETS",
|
||||||
@@ -210,6 +222,18 @@ var persian = map[string]string{
|
|||||||
"tracked_accounts": "حسابهای ردیابیشده",
|
"tracked_accounts": "حسابهای ردیابیشده",
|
||||||
"last_recorded": "آخرین ثبت",
|
"last_recorded": "آخرین ثبت",
|
||||||
"latest_activity": "آخرین فعالیت دفتر کل",
|
"latest_activity": "آخرین فعالیت دفتر کل",
|
||||||
|
"blockchain_settlements": "تسویههای بلاکچین",
|
||||||
|
"settlement_summary": "آخرین وضعیت تسویه",
|
||||||
|
"confirmed_on_chain": "تأییدشده روی زنجیره",
|
||||||
|
"pending_submission": "در انتظار ارسال",
|
||||||
|
"retryable": "قابل تلاش مجدد",
|
||||||
|
"manual_review": "نیازمند بررسی دستی",
|
||||||
|
"blockchain_transaction": "تراکنش بلاکچین",
|
||||||
|
"status": "وضعیت",
|
||||||
|
"attempts": "تعداد تلاش",
|
||||||
|
"updated": "آخرین تغییر",
|
||||||
|
"not_submitted": "ارسالنشده",
|
||||||
|
"no_settlements": "هنوز تسویهای برای بلاکچین در صف قرار نگرفته است.",
|
||||||
"newest_first": "جدیدترین ابتدا",
|
"newest_first": "جدیدترین ابتدا",
|
||||||
"top_asset_holders": "دارندگان برتر دارایی",
|
"top_asset_holders": "دارندگان برتر دارایی",
|
||||||
"holder_balance_scope": "در دسترس + مسدود · داراییهای اخیر",
|
"holder_balance_scope": "در دسترس + مسدود · داراییهای اخیر",
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ templ Page(title string, active string, locale Locale, englishURL string, persia
|
|||||||
.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; }
|
.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 { 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; }
|
.holder-id small { display:block; margin-top:5px; color:var(--muted); font:650 10px/1 ui-monospace,SFMono-Regular,monospace; }
|
||||||
|
td small { display:block; margin-top:5px; color:var(--muted); font:600 10px/1.35 ui-monospace,SFMono-Regular,monospace; overflow-wrap:anywhere; }
|
||||||
.empty { padding:45px 24px; text-align:center; color:var(--muted); }
|
.empty { padding:45px 24px; text-align:center; color:var(--muted); }
|
||||||
.notice + .transaction-list { margin-top:46px; }
|
.notice + .transaction-list { margin-top:46px; }
|
||||||
.pagination { display:flex; align-items:center; justify-content:center; gap:8px; margin-top:18px; direction:ltr; }
|
.pagination { display:flex; align-items:center; justify-content:center; gap:8px; margin-top:18px; direction:ltr; }
|
||||||
@@ -200,6 +201,16 @@ templ DashboardContent(data explorer.Dashboard, locale Locale) {
|
|||||||
<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, "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>
|
<div class="stat"><span class="stat-label">{ tr(locale, "last_recorded") }</span><time>{ formatTime(data.Stats.LastRecordedAt) }</time></div>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="dashboard-section">
|
||||||
|
<div class="section-head"><h2>{ tr(locale, "blockchain_settlements") }</h2><span>{ tr(locale, "settlement_summary") }</span></div>
|
||||||
|
<section class="stats" aria-label={ tr(locale, "blockchain_settlements") }>
|
||||||
|
<div class="stat"><span class="stat-label">{ tr(locale, "confirmed_on_chain") }</span><strong>{ count(data.SettlementStats.Confirmed) }</strong></div>
|
||||||
|
<div class="stat"><span class="stat-label">{ tr(locale, "pending_submission") }</span><strong>{ count(data.SettlementStats.Pending) }</strong></div>
|
||||||
|
<div class="stat"><span class="stat-label">{ tr(locale, "retryable") }</span><strong>{ count(data.SettlementStats.Retryable) }</strong></div>
|
||||||
|
<div class="stat"><span class="stat-label">{ tr(locale, "manual_review") }</span><strong>{ count(data.SettlementStats.ManualReview) }</strong></div>
|
||||||
|
</section>
|
||||||
|
@SettlementTable(data.Settlements, locale)
|
||||||
|
</section>
|
||||||
<section>
|
<section>
|
||||||
<div class="section-head"><h2>{ tr(locale, "latest_activity") }</h2><span>{ tr(locale, "newest_first") }</span></div>
|
<div class="section-head"><h2>{ tr(locale, "latest_activity") }</h2><span>{ tr(locale, "newest_first") }</span></div>
|
||||||
@JournalTable(data.Journals, locale)
|
@JournalTable(data.Journals, locale)
|
||||||
@@ -207,6 +218,39 @@ templ DashboardContent(data explorer.Dashboard, locale Locale) {
|
|||||||
</main>
|
</main>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
templ SettlementTable(settlements []explorer.Settlement, locale Locale) {
|
||||||
|
<div class="panel">
|
||||||
|
if len(settlements) == 0 {
|
||||||
|
<div class="empty">{ tr(locale, "no_settlements") }</div>
|
||||||
|
} else {
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>{ tr(locale, "blockchain_transaction") }</th><th>{ tr(locale, "status") }</th><th>{ tr(locale, "network") }</th><th>{ tr(locale, "source") }</th><th>{ tr(locale, "attempts") }</th><th>{ tr(locale, "updated") }</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
for _, settlement := range settlements {
|
||||||
|
<tr>
|
||||||
|
<td class="mono">
|
||||||
|
if settlement.TransactionHash != "" {
|
||||||
|
<a class="hash-link" href={ templ.URL(transactionURL(settlement.TransactionHash)) } title={ settlement.TransactionHash }>{ short(settlement.TransactionHash, 22) }</a>
|
||||||
|
} else {
|
||||||
|
<span title={ settlement.ID }>{ tr(locale, "not_submitted") } · { short(settlement.ID, 14) }</span>
|
||||||
|
}
|
||||||
|
if settlement.LastError != "" {
|
||||||
|
<small>{ short(settlement.LastError, 70) }</small>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td><span class="pill">{ string(settlement.Status) }</span></td>
|
||||||
|
<td>{ settlement.Network }</td>
|
||||||
|
<td>{ settlement.SourceService }<small class="mono">{ short(settlement.SourceTransactionID, 18) }</small></td>
|
||||||
|
<td class="mono">{ strconv.Itoa(settlement.Attempts) }</td>
|
||||||
|
<td class="mono">{ formatTime(settlement.UpdatedAt) }</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
templ AssetsContent(data explorer.Assets, locale Locale) {
|
templ AssetsContent(data explorer.Assets, locale Locale) {
|
||||||
<main id="explorer-content" class="shell">
|
<main id="explorer-content" class="shell">
|
||||||
<div class="breadcrumb"><a href="/">{ tr(locale, "network") }</a> / { tr(locale, "assets") }</div>
|
<div class="breadcrumb"><a href="/">{ tr(locale, "network") }</a> / { tr(locale, "assets") }</div>
|
||||||
|
|||||||
+1280
-794
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user