webapi: Add missed tickets to admin page.

A new tab on the admin page displays a list of all tickets which were
registered with the VSP but missed their votes. Clicking on the ticket
hash redirects to the Ticket Search tab with the details of the missed
ticket displayed.
This commit is contained in:
Jamie Holdstock 2023-09-26 17:18:32 +01:00 committed by GitHub
parent d960898987
commit a254e943f7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 97 additions and 11 deletions

View File

@ -6,6 +6,7 @@ package database
import ( import (
"fmt" "fmt"
"sort"
"time" "time"
bolt "go.etcd.io/bbolt" bolt "go.etcd.io/bbolt"
@ -116,6 +117,12 @@ func (t TicketList) EarliestPurchaseHeight() int64 {
return oldestHeight return oldestHeight
} }
func (t TicketList) SortByPurchaseHeight() {
sort.Slice(t, func(i, j int) bool {
return t[i].PurchaseHeight > t[j].PurchaseHeight
})
}
func (t *Ticket) FeeExpired() bool { func (t *Ticket) FeeExpired() bool {
now := time.Now() now := time.Now()
return now.After(time.Unix(t.FeeExpiration, 0)) return now.After(time.Unix(t.FeeExpiration, 0))
@ -385,6 +392,13 @@ func (vdb *VspDatabase) GetMissingPurchaseHeight() (TicketList, error) {
}) })
} }
// GetMissedTickets returns all tickets which have outcome == missed.
func (vdb *VspDatabase) GetMissedTickets() (TicketList, error) {
return vdb.filterTickets(func(t *bolt.Bucket) bool {
return TicketOutcome(t.Get(outcomeK)) == Missed
})
}
// filterTickets accepts a filter function and returns all tickets from the // filterTickets accepts a filter function and returns all tickets from the
// database which match the filter. // database which match the filter.
func (vdb *VspDatabase) filterTickets(filter func(*bolt.Bucket) bool) (TicketList, error) { func (vdb *VspDatabase) filterTickets(filter func(*bolt.Bucket) bool) (TicketList, error) {

View File

@ -146,11 +146,21 @@ func (w *WebAPI) statusJSON(c *gin.Context) {
func (w *WebAPI) adminPage(c *gin.Context) { func (w *WebAPI) adminPage(c *gin.Context) {
cacheData := c.MustGet(cacheKey).(cacheData) cacheData := c.MustGet(cacheKey).(cacheData)
missed, err := w.db.GetMissedTickets()
if err != nil {
w.log.Errorf("db.GetMissedTickets error: %v", err)
c.String(http.StatusInternalServerError, "Error getting missed tickets from db")
return
}
missed.SortByPurchaseHeight()
c.HTML(http.StatusOK, "admin.html", gin.H{ c.HTML(http.StatusOK, "admin.html", gin.H{
"WebApiCache": cacheData, "WebApiCache": cacheData,
"WebApiCfg": w.cfg, "WebApiCfg": w.cfg,
"WalletStatus": w.walletStatus(c), "WalletStatus": w.walletStatus(c),
"DcrdStatus": w.dcrdStatus(c), "DcrdStatus": w.dcrdStatus(c),
"MissedTickets": missed,
}) })
} }
@ -212,6 +222,15 @@ func (w *WebAPI) ticketSearch(c *gin.Context) {
feeTxDecoded = string(decoded) feeTxDecoded = string(decoded)
} }
missed, err := w.db.GetMissedTickets()
if err != nil {
w.log.Errorf("db.GetMissedTickets error: %v", err)
c.String(http.StatusInternalServerError, "Error getting missed tickets from db")
return
}
missed.SortByPurchaseHeight()
c.HTML(http.StatusOK, "admin.html", gin.H{ c.HTML(http.StatusOK, "admin.html", gin.H{
"SearchResult": searchResult{ "SearchResult": searchResult{
Hash: hash, Hash: hash,
@ -222,10 +241,11 @@ func (w *WebAPI) ticketSearch(c *gin.Context) {
VoteChanges: voteChanges, VoteChanges: voteChanges,
MaxVoteChanges: w.cfg.MaxVoteChangeRecords, MaxVoteChanges: w.cfg.MaxVoteChangeRecords,
}, },
"WebApiCache": cacheData, "WebApiCache": cacheData,
"WebApiCfg": w.cfg, "WebApiCfg": w.cfg,
"WalletStatus": w.walletStatus(c), "WalletStatus": w.walletStatus(c),
"DcrdStatus": w.dcrdStatus(c), "DcrdStatus": w.dcrdStatus(c),
"MissedTickets": missed,
}) })
} }

View File

@ -62,3 +62,16 @@ func atomsToDCR(atoms int64) string {
func float32ToPercent(input float32) string { func float32ToPercent(input float32) string {
return fmt.Sprintf("%.2f%%", input*100) return fmt.Sprintf("%.2f%%", input*100)
} }
// pluralize suffixes the provided noun with "s" if n is not 1, then
// concatenates n and noun with a space between them. For example:
//
// (0, "biscuit") will return "0 biscuits"
// (1, "biscuit") will return "1 biscuit"
// (3, "biscuit") will return "3 biscuits"
func pluralize(n int, noun string) string {
if n != 1 {
noun += "s"
}
return fmt.Sprintf("%d %s", n, noun)
}

View File

@ -92,7 +92,7 @@ footer .code {
} }
} }
.btn { .btn-primary, .btn-secondary {
outline: 0; outline: 0;
-webkit-box-shadow: 1px 3px 14px 0px rgba(0,0,0,0.19); -webkit-box-shadow: 1px 3px 14px 0px rgba(0,0,0,0.19);
box-shadow: 1px 3px 14px 0px rgba(0,0,0,0.19); box-shadow: 1px 3px 14px 0px rgba(0,0,0,0.19);
@ -206,6 +206,12 @@ footer .code {
padding-left: 40px; padding-left: 40px;
} }
table.missed-tickets th,
table.missed-tickets td {
border: 1px solid #edeff1;
vertical-align: middle;
text-align: center;
}
.tabset > input { .tabset > input {
display:block; /* "enable" hidden elements in IE/edge */ display:block; /* "enable" hidden elements in IE/edge */

View File

@ -45,11 +45,18 @@
id="tabset_1_4" id="tabset_1_4"
hidden hidden
> >
<input
type="radio"
name="tabset_1"
id="tabset_1_5"
hidden
>
<ul> <ul>
<li><label for="tabset_1_1">VSP Status</label></li> <li><label for="tabset_1_1">VSP Status</label></li>
<li><label for="tabset_1_2">Ticket Search</label></li> <li><label for="tabset_1_2">Ticket Search</label></li>
<li><label for="tabset_1_3">Database</label></li> <li><label for="tabset_1_3">Missed Tickets</label></li>
<li><label for="tabset_1_4">Logout</label></li> <li><label for="tabset_1_4">Database</label></li>
<li><label for="tabset_1_5">Logout</label></li>
</ul> </ul>
<div> <div>
@ -190,6 +197,31 @@
{{ end }} {{ end }}
</section> </section>
<section>
<h1>{{ pluralize (len .MissedTickets) "Missed Ticket" }}</h1>
{{ with .MissedTickets }}
<table class="missed-tickets mx-auto">
<thead>
<th>Purchase Height</th>
<th>Ticket Hash</th>
</thead>
<tbody>
{{ range . }}
<tr>
<td>{{ .PurchaseHeight }}</td>
<td>
<form action="/admin/ticket" method="post">
<input type="hidden" name="hash" value="{{ .Hash }}">
<button class="btn btn-link p-0 code" type="submit">{{ .Hash }}</button>
</form>
</td>
</tr>
{{ end }}
</tbody>
</table>
{{ end}}
</section>
<section> <section>
<p>Database size: {{ .WebApiCache.DatabaseSize }}</p> <p>Database size: {{ .WebApiCache.DatabaseSize }}</p>
<a class="btn btn-primary" href="/admin/backup" download>Download Backup</a> <a class="btn btn-primary" href="/admin/backup" download>Download Backup</a>

View File

@ -217,6 +217,7 @@ func (w *WebAPI) router(cookieSecret []byte, dcrd rpc.DcrdConnect, wallets rpc.W
"atomsToDCR": atomsToDCR, "atomsToDCR": atomsToDCR,
"float32ToPercent": float32ToPercent, "float32ToPercent": float32ToPercent,
"comma": humanize.Comma, "comma": humanize.Comma,
"pluralize": pluralize,
}) })
router.LoadHTMLGlob("internal/webapi/templates/*.html") router.LoadHTMLGlob("internal/webapi/templates/*.html")