From 5826501eaf6c374f9d0041cbeb66b9b47819cfcb Mon Sep 17 00:00:00 2001
From: Joshua Sing
Date: Fri, 14 Jun 2024 02:48:04 +1000
Subject: [PATCH] popmd/wasm: use structs instead of custom 'Object' type
---
web/popminer/api.go | 144 ++++++++++++++++++++++++++++++
web/popminer/dispatch.go | 158 ++++++++++++++++-----------------
web/popminer/util.go | 184 +++++++++++++++++++++++++++++++++------
web/www/index.html | 4 +-
4 files changed, 376 insertions(+), 114 deletions(-)
create mode 100644 web/popminer/api.go
diff --git a/web/popminer/api.go b/web/popminer/api.go
new file mode 100644
index 00000000..3911eba2
--- /dev/null
+++ b/web/popminer/api.go
@@ -0,0 +1,144 @@
+package main
+
+// Method represents a method that can be dispatched.
+type Method string
+
+const (
+ // The following can be dispatched at any time.
+ MethodVersion Method = "version" // Retrieve WASM version information
+ MethodGenerateKey Method = "generateKey" // Generate secp256k1 key pair
+ MethodStartPoPMiner Method = "startPoPMiner" // Start PoP Miner
+ MethodStopPoPMiner Method = "stopPoPMiner" // Stop PoP Miner
+
+ // The following can only be dispatched after the PoP Miner is running.
+ MethodPing Method = "ping" // Ping BFG
+ MethodL2Keystones Method = "l2Keystones" // Retrieve L2 keystones
+ MethodBitcoinBalance Method = "bitcoinBalance" // Retrieve bitcoin balance
+ MethodBitcoinInfo Method = "bitcoinInfo" // Retrieve bitcoin information
+ MethodBitcoinUTXOs Method = "bitcoinUTXOs" // Retrieve bitcoin UTXOs
+)
+
+// Error represents an error that has occurred within the WASM PoP Miner.
+type Error struct {
+ // Message is the error message.
+ Message string `json:"message"`
+
+ // Stack is the Go debug stack (from debug.Stack()) for the error.
+ Stack string `json:"stack"`
+
+ // Timestamp is the time the error occurred, in unix seconds.
+ Timestamp int64 `json:"timestamp"`
+}
+
+// VersionResult contains version information for the WASM PoP Miner.
+// Returned by MethodVersion.
+type VersionResult struct {
+ // Version is the version of the WASM PoP Miner.
+ Version string `json:"version"`
+
+ // GitCommit is the SHA-1 hash of the Git commit the WASM binary was built
+ // from. The value should be the same as the output from git rev-parse HEAD.
+ GitCommit string `json:"gitCommit"`
+}
+
+// GenerateKeyResult contains the generated key information.
+// Returned by MethodGenerateKey.
+type GenerateKeyResult struct {
+ // EthereumAddress is the Ethereum address for the generated key.
+ EthereumAddress string `json:"ethereumAddress"`
+
+ // Network is the network for which the key was generated.
+ Network string `json:"network"`
+
+ // PrivateKey is the generated secpk256k1 private key, encoded as a
+ // hexadecimal string.
+ PrivateKey string `json:"privateKey"`
+
+ // PublicKey is the generated secp256k1 public key, in the 33-byte
+ // compressed format, encoded as a hexadecimal string.
+ PublicKey string `json:"publicKey"`
+
+ // PublicKeyHash is the Bitcoin pay-to-pubkey-hash address for the generated
+ // key.
+ PublicKeyHash string `json:"publicKeyHash"`
+}
+
+// PingResult contains information when pinging the BFG server.
+// Returned by MethodPing.
+type PingResult struct {
+ // OriginTimestamp is the time the PoP Miner sent the ping request to BFG,
+ // in unix nanoseconds.
+ OriginTimestamp int64 `json:"originTimestamp"`
+
+ // Timestamp is the time the BFG server sent the ping response, in unix
+ // nanoseconds.
+ Timestamp int64 `json:"timestamp"`
+}
+
+// L2KeystoneResult contains the requested l2 keystones.
+// Returned by MethodL2Keystones.
+type L2KeystoneResult struct {
+ // L2Keystones contains the requested keystones.
+ L2Keystones []L2Keystone `json:"l2Keystones"`
+}
+
+// L2Keystone represents an L2 keystone.
+type L2Keystone struct {
+ // Version is the version of the L2 keystone.
+ Version uint8 `json:"version"`
+
+ // L1BlockNumber is the L1 block number for the keystone.
+ L1BlockNumber uint32 `json:"l1BlockNumber"`
+
+ // L2BlockNumber is the L2 block number for the keystone.
+ L2BlockNumber uint32 `json:"l2BlockNumber"`
+
+ // EPHash is the hash of the L2 block that contains the PoP payout.
+ EPHash string `json:"epHash"`
+
+ // ParentEPHash is the parent of the L2 block that contains the PoP payout.
+ ParentEPHash string `json:"parentEPHash"`
+
+ // PrevKeystoneEPHash is the hash of the L2 block that contains the previous
+ // keystone PoP payout.
+ PrevKeystoneEPHash string `json:"prevKeystoneEPHash"`
+
+ // StateRoot is the Ethereum execution payload state root.
+ StateRoot string `json:"stateRoot"`
+}
+
+// BitcoinBalanceResult contains the balances for the script hash.
+// Returned by MethodBitcoinBalance.
+type BitcoinBalanceResult struct {
+ // Confirmed is the confirmed balance in satoshis.
+ Confirmed uint64 `json:"confirmed"`
+
+ // Unconfirmed is the unconfirmed balance in satoshis.
+ Unconfirmed int64 `json:"unconfirmed"`
+}
+
+// BitcoinInfoResult contains Bitcoin-related information.
+// Returned by MethodBitcoinInfo.
+type BitcoinInfoResult struct {
+ // Height is the current best known Bitcoin block height.
+ Height uint64 `json:"height"`
+}
+
+// BitcoinUTXOsResult contains the UTXOs for the script hash.
+// Returned by MethodBitcoinUTXOs.
+type BitcoinUTXOsResult struct {
+ // UTXOs contains the UTXOs for the script hash.
+ UTXOs []BitcoinUTXO `json:"utxos"`
+}
+
+// BitcoinUTXO represents a Bitcoin UTXO.
+type BitcoinUTXO struct {
+ // Hash is the output's transaction hash, encoded as a hexadecimal string.
+ Hash string `json:"hash"`
+
+ // Index is the index of the output in the transaction's list of outputs.
+ Index uint32 `json:"index"`
+
+ // Value is the value of the output in satoshis.
+ Value int64 `json:"value"`
+}
diff --git a/web/popminer/dispatch.go b/web/popminer/dispatch.go
index c7a943c2..898f553e 100644
--- a/web/popminer/dispatch.go
+++ b/web/popminer/dispatch.go
@@ -24,23 +24,8 @@ import (
"github.com/hemilabs/heminetwork/service/popm"
)
-const (
- // The following can be dispatched at any time.
- MethodVersion = "version" // Retrieve WASM version information
- MethodGenerateKey = "generateKey" // Generate secp256k1 key pair
- MethodStartPoPMiner = "startPoPMiner" // Start PoP Miner
- MethodStopPoPMiner = "stopPoPMiner" // Stop PoP Miner
-
- // The following can only be dispatched after the PoP Miner is running.
- MethodPing = "ping" // Ping BFG
- MethodL2Keystones = "l2Keystones" // Retrieve L2 keystones
- MethodBitcoinBalance = "bitcoinBalance" // Retrieve bitcoin balance
- MethodBitcoinInfo = "bitcoinInfo" // Retrieve bitcoin information
- MethodBitcoinUTXOs = "bitcoinUTXOs" // Retrieve bitcoin UTXOs
-)
-
-// Dispatcher maps methods to the relative dispatch.
-var Dispatcher = map[string]*Dispatch{
+// handlers maps methods to the correct handler.
+var handlers = map[Method]*Dispatch{
MethodVersion: {
Handler: wasmVersion,
},
@@ -95,7 +80,7 @@ type DispatchArgs struct {
}
type Dispatch struct {
- Handler func(this js.Value, args []js.Value) (js.Value, error)
+ Handler func(this js.Value, args []js.Value) (any, error)
Required []DispatchArgs
}
@@ -139,7 +124,7 @@ func dispatch(this js.Value, args []js.Value) any {
reject.Invoke(jsError(err))
return
}
- resolve.Invoke(rv)
+ resolve.Invoke(jsValueOf(rv))
}()
// The handler of a Promise doesn't return any value
@@ -165,7 +150,7 @@ func parseArgs(args []js.Value) (*Dispatch, error) {
if m.Type() != js.TypeString {
return nil, fmt.Errorf("expected method to be a string, got: %v", m.Type())
}
- d, ok := Dispatcher[m.String()]
+ d, ok := handlers[Method(m.String())]
if !ok {
log.Warningf("method not found: %v", m.String())
return nil, fmt.Errorf("method not found: %v", m.String())
@@ -185,14 +170,14 @@ func parseArgs(args []js.Value) (*Dispatch, error) {
return d, nil
}
-func wasmVersion(_ js.Value, _ []js.Value) (js.Value, error) {
- return Object{
- "version": version,
- "gitCommit": gitCommit,
- }.Value(), nil
+func wasmVersion(_ js.Value, _ []js.Value) (any, error) {
+ return VersionResult{
+ Version: version,
+ GitCommit: gitCommit,
+ }, nil
}
-func generateKey(_ js.Value, args []js.Value) (js.Value, error) {
+func generateKey(_ js.Value, args []js.Value) (any, error) {
log.Tracef("generatekey")
defer log.Tracef("generatekey exit")
@@ -212,7 +197,7 @@ func generateKey(_ js.Value, args []js.Value) (js.Value, error) {
return js.Null(), fmt.Errorf("invalid network: %v", net)
}
- // TODO: consider alternative as dcrsecpk256k1 package is large.
+ // TODO(joshuasing): consider alternative as dcrsecpk256k1 package is large.
privKey, err := dcrsecpk256k1.GeneratePrivateKey()
if err != nil {
log.Errorf("failed to generate private key: %v", err)
@@ -230,23 +215,23 @@ func generateKey(_ js.Value, args []js.Value) (js.Value, error) {
compressedPubKey := privKey.PubKey().SerializeCompressed()
ethereumAddress := ethereum.PublicKeyToAddress(compressedPubKey).String()
- return Object{
- "ethereumAddress": ethereumAddress,
- "network": netNormalized,
- "privateKey": hex.EncodeToString(privKey.Serialize()),
- "publicKey": hex.EncodeToString(compressedPubKey),
- "publicKeyHash": btcAddress.AddressPubKeyHash().String(),
- }.Value(), nil
+ return GenerateKeyResult{
+ EthereumAddress: ethereumAddress,
+ Network: netNormalized,
+ PrivateKey: hex.EncodeToString(privKey.Serialize()),
+ PublicKey: hex.EncodeToString(compressedPubKey),
+ PublicKeyHash: btcAddress.AddressPubKeyHash().String(),
+ }, nil
}
-func startPoPMiner(_ js.Value, args []js.Value) (js.Value, error) {
+func startPoPMiner(_ js.Value, args []js.Value) (any, error) {
log.Tracef("startPoPMiner")
defer log.Tracef("startPoPMiner exit")
pmMtx.Lock()
defer pmMtx.Unlock()
if pm != nil {
- return js.Null(), errors.New("pop miner already started")
+ return nil, errors.New("pop miner already started")
}
pm = new(Miner)
@@ -260,12 +245,16 @@ func startPoPMiner(_ js.Value, args []js.Value) (js.Value, error) {
if cfg.LogLevel == "" {
cfg.LogLevel = "popm=ERROR"
}
- loggo.ConfigureLoggers(cfg.LogLevel)
+ if err := loggo.ConfigureLoggers(cfg.LogLevel); err != nil {
+ pm = nil
+ return nil, fmt.Errorf("configure logger: %w", err)
+ }
network := args[0].Get("network").String()
netOpts, ok := networks[network]
if !ok {
- return js.Null(), fmt.Errorf("unknown network: %s", network)
+ pm = nil
+ return nil, fmt.Errorf("unknown network: %s", network)
}
cfg.BFGWSURL = netOpts.bfgURL
cfg.BTCChainName = netOpts.btcChainName
@@ -273,7 +262,8 @@ func startPoPMiner(_ js.Value, args []js.Value) (js.Value, error) {
var err error
pm.miner, err = popm.NewMiner(cfg)
if err != nil {
- return js.Null(), fmt.Errorf("create PoP miner: %w", err)
+ pm = nil
+ return nil, fmt.Errorf("create PoP miner: %w", err)
}
// launch in background
@@ -281,24 +271,24 @@ func startPoPMiner(_ js.Value, args []js.Value) (js.Value, error) {
go func() {
defer pm.wg.Done()
if err := pm.miner.Run(pm.ctx); !errors.Is(err, context.Canceled) {
- // TODO: dispatch event on failure
+ // TODO(joshuasing): dispatch event on failure
pmMtx.Lock()
defer pmMtx.Unlock()
pm.err = err // Theoretically this can logic race unless we unset om
}
}()
- return js.Undefined(), nil
+ return js.Null(), nil
}
-func stopPopMiner(_ js.Value, _ []js.Value) (js.Value, error) {
+func stopPopMiner(_ js.Value, _ []js.Value) (any, error) {
log.Tracef("stopPopMiner")
defer log.Tracef("stopPopMiner exit")
pmMtx.Lock()
if pm == nil {
pmMtx.Unlock()
- return js.Null(), errors.New("pop miner not running")
+ return nil, errors.New("pop miner not running")
}
oldPM := pm
@@ -308,13 +298,13 @@ func stopPopMiner(_ js.Value, _ []js.Value) (js.Value, error) {
oldPM.wg.Wait()
if oldPM.err != nil {
- return js.Null(), oldPM.err
+ return nil, oldPM.err
}
- return js.Undefined(), nil
+ return js.Null(), nil
}
-func ping(_ js.Value, _ []js.Value) (js.Value, error) {
+func ping(_ js.Value, _ []js.Value) (any, error) {
log.Tracef("ping")
defer log.Tracef("ping exit")
@@ -327,13 +317,15 @@ func ping(_ js.Value, _ []js.Value) (js.Value, error) {
return js.Null(), err
}
- return Object{
- "originTimestamp": pr.OriginTimestamp,
- "timestamp": pr.Timestamp,
- }.Value(), nil
+ // TODO(joshuasing): protocol.PingResponse should really use a more accurate
+ // time format instead of unix seconds.
+ return PingResult{
+ OriginTimestamp: time.Unix(pr.OriginTimestamp, 0).UnixNano(),
+ Timestamp: time.Unix(pr.Timestamp, 0).UnixNano(),
+ }, nil
}
-func l2Keystones(_ js.Value, args []js.Value) (js.Value, error) {
+func l2Keystones(_ js.Value, args []js.Value) (any, error) {
log.Tracef("l2Keystones")
defer log.Tracef("l2Keystones exit")
@@ -352,25 +344,25 @@ func l2Keystones(_ js.Value, args []js.Value) (js.Value, error) {
return js.Null(), err
}
- keystones := make([]Object, len(pr.L2Keystones))
+ keystones := make([]L2Keystone, len(pr.L2Keystones))
for i, ks := range pr.L2Keystones {
- keystones[i] = Object{
- "version": ks.Version,
- "l1BlockNumber": ks.L1BlockNumber,
- "l2BlockNumber": ks.L2BlockNumber,
- "parentEPHash": ks.ParentEPHash,
- "prevKeystoneEPHash": ks.PrevKeystoneEPHash,
- "stateRoot": ks.StateRoot,
- "epHash": ks.EPHash,
+ keystones[i] = L2Keystone{
+ Version: ks.Version,
+ L1BlockNumber: ks.L1BlockNumber,
+ L2BlockNumber: ks.L2BlockNumber,
+ ParentEPHash: ks.ParentEPHash.String(),
+ PrevKeystoneEPHash: ks.PrevKeystoneEPHash.String(),
+ StateRoot: ks.StateRoot.String(),
+ EPHash: ks.EPHash.String(),
}
}
- return Object{
- "l2Keystones": pr.L2Keystones,
- }.Value(), nil
+ return L2KeystoneResult{
+ L2Keystones: keystones,
+ }, nil
}
-func bitcoinBalance(_ js.Value, args []js.Value) (js.Value, error) {
+func bitcoinBalance(_ js.Value, args []js.Value) (any, error) {
log.Tracef("bitcoinBalance")
defer log.Tracef("bitcoinBalance exit")
@@ -385,13 +377,13 @@ func bitcoinBalance(_ js.Value, args []js.Value) (js.Value, error) {
return js.Null(), err
}
- return Object{
- "confirmed": pr.Confirmed,
- "unconfirmed": pr.Unconfirmed,
- }.Value(), nil
+ return BitcoinBalanceResult{
+ Confirmed: pr.Confirmed,
+ Unconfirmed: pr.Unconfirmed,
+ }, nil
}
-func bitcoinInfo(_ js.Value, _ []js.Value) (js.Value, error) {
+func bitcoinInfo(_ js.Value, _ []js.Value) (any, error) {
log.Tracef("bitcoinInfo")
defer log.Tracef("bitcoinInfo exit")
@@ -404,12 +396,12 @@ func bitcoinInfo(_ js.Value, _ []js.Value) (js.Value, error) {
return js.Null(), err
}
- return Object{
- "height": pr.Height,
- }.Value(), nil
+ return BitcoinInfoResult{
+ Height: pr.Height,
+ }, nil
}
-func bitcoinUTXOs(this js.Value, args []js.Value) (js.Value, error) {
+func bitcoinUTXOs(this js.Value, args []js.Value) (any, error) {
log.Tracef("bitcoinUTXOs")
defer log.Tracef("bitcoinUTXOs exit")
@@ -417,23 +409,23 @@ func bitcoinUTXOs(this js.Value, args []js.Value) (js.Value, error) {
activePM, err := activeMiner()
if err != nil {
- return js.Null(), err
+ return nil, err
}
pr, err := activePM.miner.BitcoinUTXOs(activePM.ctx, scriptHash)
if err != nil {
- return js.Null(), err
+ return nil, err
}
- utxos := make([]Object, len(pr.UTXOs))
+ utxos := make([]BitcoinUTXO, len(pr.UTXOs))
for i, u := range pr.UTXOs {
- utxos[i] = Object{
- "hash": u.Hash,
- "index": u.Index,
- "value": u.Value,
+ utxos[i] = BitcoinUTXO{
+ Hash: u.Hash.String(),
+ Index: u.Index,
+ Value: u.Value,
}
}
- return Object{
- "utxos": utxos,
- }.Value(), nil
+ return BitcoinUTXOsResult{
+ UTXOs: utxos,
+ }, nil
}
diff --git a/web/popminer/util.go b/web/popminer/util.go
index 6b10ead5..3e7b9be2 100644
--- a/web/popminer/util.go
+++ b/web/popminer/util.go
@@ -7,11 +7,11 @@
package main
import (
+ "reflect"
"runtime/debug"
+ "strings"
"syscall/js"
"time"
-
- "github.com/hemilabs/heminetwork/api"
)
var (
@@ -20,31 +20,157 @@ var (
arrayConstructor = js.Global().Get("Array")
)
-// Object represents a JavaScript Object.
-type Object map[string]any
-
-// Value returns a [js.Value] containing a JavaScript Object for the object.
-func (o Object) Value() js.Value {
- obj := objectConstructor.New()
- for k, v := range o {
- switch t := v.(type) {
- case Object:
- obj.Set(k, t.Value())
- case []Object:
- a := arrayConstructor.New(len(t))
- for i, so := range t {
- a.SetIndex(i, so.Value())
+// jsValueOf returns x as a JavaScript value.
+//
+// | Go | JavaScript |
+// | ---------------------- | ---------------------- |
+// | nil | null |
+// | js.Value | [value] |
+// | js.Func | function |
+// | bool | boolean |
+// | integers and floats | number |
+// | string | string |
+// | []any and [x]any | Array |
+// | map[string]any | Object |
+// | struct | Object |
+// | all others | undefined |
+func jsValueOf(x any) js.Value {
+ switch t := x.(type) {
+ case nil:
+ return js.Null()
+ case js.Value:
+ return t
+ case js.Func:
+ return t.Value
+ case bool,
+ int, int8, int16, int32, int64,
+ uint, uint8, uint16, uint32, uint64, uintptr,
+ float32, float64, string:
+ return js.ValueOf(t)
+ case []any:
+ a := arrayConstructor.New(len(t))
+ for i, s := range t {
+ a.SetIndex(i, s)
+ }
+ return a
+ case map[string]any:
+ o := objectConstructor.New()
+ for k, v := range t {
+ o.Set(k, v)
+ }
+ return o
+ default:
+ // Attempt reflection, will fall back to using jsValueSafe.
+ return jsReflectValueOf(reflect.ValueOf(x))
+ }
+}
+
+func jsReflectValueOf(rv reflect.Value) js.Value {
+ switch rv.Kind() {
+ case reflect.Ptr, reflect.Interface:
+ if rv.IsNil() {
+ return js.Null()
+ }
+ return jsReflectValueOf(rv.Elem())
+ case reflect.Slice, reflect.Array:
+ if rv.IsNil() {
+ return js.Null()
+ }
+ a := arrayConstructor.New(rv.Len())
+ for i := range rv.Len() {
+ a.SetIndex(i, jsReflectValueOf(rv.Index(i)))
+ }
+ return a
+ case reflect.Map:
+ if rv.IsNil() {
+ return js.Null()
+ }
+ o := objectConstructor.New()
+ i := rv.MapRange()
+ for i.Next() {
+ k, ok := i.Key().Interface().(string)
+ if !ok {
+ // Non-string keys are unsupported.
+ log.Warningf("cannot encode map with non-string key %v",
+ i.Key().Type())
+ return js.Undefined()
}
- obj.Set(k, a)
- case api.ByteSlice:
- // Special handling for api.ByteSlice.
- // calls String() to return value as a hexadecimal encoded string
- obj.Set(k, t.String())
- default:
- obj.Set(k, jsValueSafe(v))
+ o.Set(k, jsReflectValueOf(i.Value()))
+ }
+ return o
+ case reflect.Struct:
+ return jsReflectStruct(rv)
+ default:
+ if !rv.CanInterface() {
+ log.Warningf("cannot encode reflect value of type %v", rv.Type())
+ return js.Undefined()
+ }
+ return jsValueSafe(rv.Interface())
+ }
+}
+
+// jsReflectStruct converts a Go struct to a JavaScript Object, using the 'json'
+// struct field tags similar to the encoding/json package.
+//
+// Note: This may not handle anonymous or embedded structs and other uncommon
+// types inside structs, additionally the 'string' json tag option is not
+// supported.
+func jsReflectStruct(rv reflect.Value) js.Value {
+ o := objectConstructor.New()
+ t := rv.Type()
+ for i := range t.NumField() {
+ f := rv.Field(i)
+ if !f.CanInterface() {
+ continue
}
+ sf := t.Field(i)
+
+ tag := sf.Tag.Get("json")
+ if tag == "-" {
+ continue
+ }
+ name, opts := parseJSONTag(tag)
+ if name == "" {
+ name = sf.Name
+ }
+
+ if opts.Contains("omitempty") && isEmptyValue(f) {
+ continue
+ }
+ o.Set(name, jsReflectValueOf(f))
+ }
+ return o
+}
+
+type jsonOptions []string
+
+func (o jsonOptions) Contains(optionName string) bool {
+ for _, option := range o {
+ if option == optionName {
+ return true
+ }
+ }
+ return false
+}
+
+func parseJSONTag(tag string) (string, jsonOptions) {
+ tag, opt, _ := strings.Cut(tag, ",")
+ return tag, strings.Split(opt, ",")
+}
+
+func isEmptyValue(rv reflect.Value) bool {
+ switch rv.Kind() {
+ case reflect.Array, reflect.Map, reflect.Slice, reflect.String:
+ return rv.Len() == 0
+ case reflect.Bool,
+ reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
+ reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr,
+ reflect.Float32, reflect.Float64,
+ reflect.Interface, reflect.Pointer:
+ return rv.IsZero()
+ default:
+ return false
}
- return obj
}
// jsValueSafe wraps js.ValueOf and recovers when js.ValueOf panics due to it
@@ -66,9 +192,9 @@ func jsError(err error) js.Value {
defer log.Tracef("jsError exit")
stack := string(debug.Stack())
- return Object{
- "message": err.Error(),
- "stack": stack,
- "timestamp": time.Now().Unix(),
- }.Value()
+ return jsValueOf(Error{
+ Message: err.Error(),
+ Stack: stack,
+ Timestamp: time.Now().Unix(),
+ })
}
diff --git a/web/www/index.html b/web/www/index.html
index dc6a89d9..bf4c9889 100644
--- a/web/www/index.html
+++ b/web/www/index.html
@@ -37,7 +37,7 @@
-
+
@@ -64,7 +64,7 @@
-
+