Commit f00137e6 authored by Sondre Bjellås's avatar Sondre Bjellås

Add models for API, improve README.

- Add models for integration with indexer API.
- Add details on how to easily run the indexer using docker-compose.
- Add example on configuration to run Stratis.Guru.
- Improve use of RestClient.
parent f826d4c2
version: '2'
services:
nako:
container_name: stratis-nako
networks:
nakonet:
ipv4_address: 172.16.11.100
image: coinvault/nako
command: stratis
ports:
- 9040:9000
depends_on:
- mongo
- client
client:
container_name: stratis-client
networks:
nakonet:
ipv4_address: 172.16.11.101
image: stratisplatform/fullnode:StratisMain
command: ["dotnet", "run", "--", "-server=1", "-rpcallowip=172.16.11.100", "-rpcbind=172.16.11.101", "-rpcport=5000", "-rpcuser=rpcuser", "-rpcpassword=rpcpassword", "-rpcthreads=300", "-txindex=1"]
ports:
- 5040:5000
- 16178:16178
mongo:
container_name: stratis-mongo
networks:
nakonet:
ipv4_address: 172.16.11.102
image: mongo:3.2
networks:
nakonet:
driver: bridge
ipam:
driver: default
config:
- subnet: 172.16.11.0/24
\ No newline at end of file
......@@ -19,5 +19,51 @@ You will find a $STRAT Price Ticker, an address generator, a block explorer, a c
For now it's a very simple process, you make a deposit with the amount that you want, and you put your nickname/withdraw address, when the lotery countdown end, I manually choose a winner by using random.org (this system will change in the future)
All $STRAT coins are stored in cold wallet, by using an xpub.
## Block Indexer API
Stratis.Guru relies on the Nako block indexer API. To run your own local Nako instance, you must have Docker installed.
First navigate to the Docker/Nako/ folder, where the docker.compose.yml file should be located, then you can for debugging and development run:
```sh
docker-compose up
```
This will initiate the nako indexer (including the API), the stratis fullnode daemon and mongodb for storage.
To verify that it worked, you can open the stats page to see sync status:
[http://localhost:9040/api/stats](http://localhost:9040/api/stats)
It might take a minute or two for the fullnode daemon to connect to other nodes. The API won't respond correctly, until blockchain download has started.
## Configuration
To run the Stratis.Guru, you need a appsettings.json. This is currently not included in the source code, so you must manually add it to the root of the project.
```json
{
"ConnectionStrings": {
"DefaultConnection": "mongodb://localhost:27017"
},
"NakoApi": {
"Endpoint": "http://localhost:9040/api/"
},
"FixerApi": {
"ApiKey": "",
"Endpoint": ""
},
"Ticker": {
"ApiUrl": "https://api.coinmarketcap.com/v2/ticker/1343/"
},
"Sentry": {
"Dsn": "https://ed8ea72e1f6341ae901d96691d9e58a0@sentry.io/1359208",
"IncludeRequestPayload": true,
"IncludeActivityData": true,
"Logging": {
"MinimumBreadcrumbLevel": "Information"
}
}
}
```
## About the Author
Proudly Crafted with 💖 by Clint.Network — Help me to maintain by sending $STRAT at [SXDaQGs56aC9ZjFzTdbhudNXTbyxU5aNXJ](https://chainz.cryptoid.info/strat/address.dws?SXDaQGs56aC9ZjFzTdbhudNXTbyxU5aNXJ.htm).
......@@ -7,6 +7,8 @@ using Microsoft.Extensions.Options;
using Newtonsoft.Json;
using RestSharp;
using Stratis.Guru.Models;
using Stratis.Guru.Models.ApiModels;
using Stratis.Guru.Services;
using Stratis.Guru.Settings;
namespace Stratis.Guru.Controllers
......@@ -17,34 +19,32 @@ namespace Stratis.Guru.Controllers
private readonly NakoApiSettings _nakoApiSettings;
private readonly IMemoryCache _memoryCache;
private readonly dynamic _stats;
private readonly BlockIndexService _indexService;
public BlockExplorerController(IMemoryCache memoryCache, IOptions<NakoApiSettings> nakoApiSettings)
public BlockExplorerController(IMemoryCache memoryCache, IOptions<NakoApiSettings> nakoApiSettings, BlockIndexService indexService)
{
_nakoApiSettings = nakoApiSettings.Value;
_memoryCache = memoryCache;
_stats = JsonConvert.DeserializeObject(_memoryCache.Get("BlockchainStats").ToString());
_indexService = indexService;
}
public IActionResult Index()
{
var latestBlockClient = new RestClient($"{_nakoApiSettings.Endpoint}query/block/Latest/transactions");
var latestBlockRequest = new RestRequest(Method.GET);
var latestBlockResponse = latestBlockClient.Execute(latestBlockRequest);
ViewBag.LatestBlock = JsonConvert.DeserializeObject(latestBlockResponse.Content);
ViewBag.BlockchainHeight = ViewBag.LatestBlock.blockIndex;
var latestBlock = _indexService.GetLatestBlock();
ViewBag.LatestBlock = latestBlock;
ViewBag.BlockchainHeight = latestBlock.BlockIndex;
var x = new List<dynamic>();
var latestBlocks = new List<dynamic>();
latestBlocks.Add(latestBlock);
for (int i = (int)ViewBag.LatestBlock.blockIndex; i >= (int)ViewBag.LatestBlock.blockIndex-5; i--)
for (int i = (int)ViewBag.LatestBlock.BlockIndex-1; i >= (int)ViewBag.LatestBlock.BlockIndex-5; i--)
{
var endpointClient = new RestClient($"{_nakoApiSettings.Endpoint}query/block/index/{i}/transactions");
var enpointRequest = new RestRequest(Method.GET);
var endpointResponse = endpointClient.Execute(enpointRequest);
x.Add(JsonConvert.DeserializeObject(endpointResponse.Content));
latestBlocks.Add(_indexService.GetBlockByHeight(i));
}
ViewBag.Blocks = x;
ViewBag.Blocks = latestBlocks;
return View();
......@@ -71,41 +71,34 @@ namespace Stratis.Guru.Controllers
[Route("block/{block}")]
public IActionResult Block(string block)
{
ViewBag.BlockchainHeight = _stats.syncBlockIndex;
var endpointClient = new RestClient($"{_nakoApiSettings.Endpoint}query/block/index/{block}/transactions");
var enpointRequest = new RestRequest(Method.GET);
var endpointResponse = endpointClient.Execute(enpointRequest);
return View(JsonConvert.DeserializeObject(endpointResponse.Content));
ViewBag.BlockchainHeight = _stats.SyncBlockIndex;
var result = (block.ToLower() == "latest") ? _indexService.GetLatestBlock() : _indexService.GetBlockByHeight(int.Parse(block));
return View(result);
}
[Route("block/hash/{hash}")]
public IActionResult BlockHash(string hash)
{
ViewBag.BlockchainHeight = _stats.syncBlockIndex;
var endpointClient = new RestClient($"{_nakoApiSettings.Endpoint}query/block/{hash}/transactions");
var enpointRequest = new RestRequest(Method.GET);
var endpointResponse = endpointClient.Execute(enpointRequest);
return View("Block", JsonConvert.DeserializeObject(endpointResponse.Content));
ViewBag.BlockchainHeight = _stats.SyncBlockIndex;
return View("Block", _indexService.GetBlockByHash(hash));
}
[Route("address/{address}")]
public IActionResult Address(string address)
{
ViewBag.BlockchainHeight = _stats.syncBlockIndex;
var endpointClient = new RestClient($"{_nakoApiSettings.Endpoint}query/address/{address}/transactions");
var enpointRequest = new RestRequest(Method.GET);
var endpointResponse = endpointClient.Execute(enpointRequest);
return View(JsonConvert.DeserializeObject(endpointResponse.Content));
ViewBag.BlockchainHeight = _stats.SyncBlockIndex;
return View(_indexService.GetTransactionsByAddress(address));
}
[Route("transaction/{transactionId}")]
public IActionResult Transaction(string transactionId)
{
ViewBag.BlockchainHeight = _stats.syncBlockIndex;
var endpointClient = new RestClient($"{_nakoApiSettings.Endpoint}query/transaction/{transactionId}");
var enpointRequest = new RestRequest(Method.GET);
var endpointResponse = endpointClient.Execute(enpointRequest);
return View(JsonConvert.DeserializeObject(endpointResponse.Content));
ViewBag.BlockchainHeight = _stats.SyncBlockIndex;
return View(_indexService.GetTransaction(transactionId));
}
}
}
\ No newline at end of file
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace Stratis.Guru.Models.ApiModels
{
public class AddressModel
{
public AddressModel()
{
Transactions = new List<TransactionModel>();
UnconfirmedTransactions = new List<TransactionModel>();
}
public string CoinTag { get; set; }
public string Address { get; set; }
public decimal Balance { get; set; }
public decimal TotalReceived { get; set; }
public decimal TotalSent { get; set; }
public decimal UnconfirmedBalance { get; set; }
public List<TransactionModel> Transactions { get; set; }
public List<TransactionModel> UnconfirmedTransactions { get; set; }
}
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace Stratis.Guru.Models.ApiModels
{
public class BlockModel
{
public BlockModel()
{
Transactions = new List<string>();
}
public string CoinTag { get; set; }
public string BlockHash { get; set; }
public long BlockIndex { get; set; }
public int BlockSize { get; set; }
public long BlockTime { get; set; }
public string NextBlockHash { get; set; }
public string PreviousBlockHash { get; set; }
public bool Synced { get; set; }
public int TransactionCount { get; set; }
public List<string> Transactions { get; set; }
}
}
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
namespace Stratis.Guru.Models.ApiModels
{
public class TransactionDetailsModel
{
public TransactionDetailsModel()
{
Inputs = new List<TransactionInputModel>();
Outputs = new List<TransactionOutputModel>();
}
public string CoinTag { get; set; }
public string BlockHash { get; set; }
public long BlockIndex { get; set; }
[DataType(DataType.Date)]
public DateTime Timestamp { get; set; }
public string TransactionId { get; set; }
public int Confirmations { get; set; }
public List<TransactionInputModel> Inputs { get; set; }
public List<TransactionOutputModel> Outputs { get; set; }
}
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace Stratis.Guru.Models.ApiModels
{
public class TransactionInputModel
{
public int InputIndex { get; set; }
public string InputAddress { get; set; }
public string CoinBase { get; set; }
public string InputTransactionId { get; set; }
}
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace Stratis.Guru.Models.ApiModels
{
public class TransactionModel
{
public int Index { get; set; }
public string Type { get; set; }
public string TransactionHash { get; set; }
public string SpendingTransactionHash { get; set; }
public string PubScriptHex { get; set; }
public string CoinBase { get; set; }
public decimal Value { get; set; }
public long BlockIndex { get; set; }
public long Confirmations { get; set; }
public int Time { get; set; }
}
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace Stratis.Guru.Models.ApiModels
{
public class TransactionOutputModel
{
public int Index { get; set; }
public string Address { get; set; }
public decimal Balance { get; set; }
public string OutputType { get; set; }
}
}
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using RestSharp;
using RestSharp.Authenticators;
using Stratis.Guru.Models.ApiModels;
using Stratis.Guru.Settings;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace Stratis.Guru.Services
{
public class BlockIndexService
{
private readonly NakoApiSettings _nakoApiSettings;
private readonly RestClient _client;
public BlockIndexService()
{
}
public BlockIndexService(IOptions<NakoApiSettings> nakoApiSettings)
{
_nakoApiSettings = nakoApiSettings.Value;
// RestClient is suppose to be able to be re-used. If not, we should move this into the Execute method.
_client = new RestClient();
_client.BaseUrl = new System.Uri(_nakoApiSettings.Endpoint);
}
public T Execute<T>(RestRequest request) where T : new()
{
var response = _client.Execute<T>(request);
if (response.ErrorException != null)
{
const string message = "Error retrieving response. Check inner details for more info.";
var blockIndexServiceException = new ApplicationException(message, response.ErrorException);
throw blockIndexServiceException;
}
return response.Data;
}
private RestRequest GetRequest(string resource)
{
var request = new RestRequest();
request.Method = Method.GET;
request.Resource = resource;
return request;
}
public BlockModel GetBlockByHeight(long blockHeight)
{
return Execute<BlockModel>(GetRequest($"query/block/index/{blockHeight}/transactions"));
}
public BlockModel GetBlockByHash(string blockHash)
{
return Execute<BlockModel>(GetRequest($"query/block/{blockHash}/transactions"));
}
public BlockModel GetLatestBlock()
{
return Execute<BlockModel>(GetRequest("query/block/Latest/transactions"));
}
public AddressModel GetTransactionsByAddress(string adddress)
{
return Execute<AddressModel>(GetRequest($"query/address/{adddress}/transactions"));
}
public TransactionDetailsModel GetTransaction(string transactionId)
{
return Execute<TransactionDetailsModel>(GetRequest($"query/transaction/{transactionId}"));
}
}
}
......@@ -62,6 +62,7 @@ namespace Stratis.Guru
services.AddSingleton<ISettings, Models.Settings>();
services.AddSingleton<IDraws, Draws>();
services.AddSingleton<IParticipation, Participations>();
services.AddSingleton<BlockIndexService>();
services.AddHostedService<UpdateInfosService>();
services.AddHostedService<FixerService>();
......
@model dynamic
@{
ViewBag.Title = $"Address {Model.address}";
ViewBag.Title = $"Address {Model.Address}";
Layout = "_Layout";
ViewBag.Query = Model.address;
ViewBag.Query = Model.Address;
}
@section Style
{
......@@ -31,30 +31,30 @@
<tr>
<td><strong>Address</strong></td>
<td>
<a class="to-copy" asp-controller="BlockExplorer" asp-action="Address" asp-route-address="@Model.address">@Model.address</a>
<a href="#" data-value="@Model.address" class="copy-me"><i class="fa fa-copy"></i></a>
<a class="to-copy" asp-controller="BlockExplorer" asp-action="Address" asp-route-address="@Model.Address">@Model.Address</a>
<a href="#" data-value="@Model.Address" class="copy-me"><i class="fa fa-copy"></i></a>
</td>
</tr>
<tr>
<td><strong>No. Transactions</strong></td>
<td>@Model.transactions.Count</td>
<td>@Model.Transactions.Count</td>
</tr>
<tr>
<td><strong>Balance</strong></td>
<td>@Model.balance.ToString("N8") STRAT</td>
<td>@Model.Balance.ToString("N8") STRAT</td>
</tr>
<tr>
<td><strong>Unconfirmed Balance</strong></td>
<td>@Model.unconfirmedBalance.ToString("N8") STRAT</td>
<td>@Model.UnconfirmedBalance.ToString("N8") STRAT</td>
</tr>
<tr>
<td><strong>Total Received</strong></td>
<td>@Model.totalReceived.ToString("N8") STRAT</td>
<td>@Model.TotalReceived.ToString("N8") STRAT</td>
</tr>
<tr>
<td><strong>Total Sent</strong></td>
<td>@Model.totalSent.ToString("N8") STRAT</td>
<td>@Model.TotalSent.ToString("N8") STRAT</td>
</tr>
</tbody>
</table>
......@@ -62,7 +62,7 @@
</div>
<div class="col-lg-3 col-md-3 col-sm-12">
<div class="qr">
<img src="@Url.Action("Qr", "Home", new {value=Model.address})" class="img-fluid d-block mx-auto" alt="">
<img src="@Url.Action("Qr", "Home", new {value=Model.Address})" class="img-fluid d-block mx-auto" alt="">
</div>
</div>
</div>
......@@ -90,14 +90,14 @@
@{
double total = 0;
}
@foreach (var transaction in Model.transactions)
@foreach (var transaction in Model.Transactions)
{
total += (double)transaction.value;
total += (double)transaction.Value;
<tr>
<td><a asp-controller="BlockExplorer" asp-action="Transaction" asp-route-transactionId="@transaction.transactionHash">@transaction.transactionHash.ToString().Substring(0, 25)...</a></td>
<td><a asp-controller="BlockExplorer" asp-action="Block" asp-route-block="@transaction.blockIndex">@transaction.blockIndex</a></td>
<td><a asp-controller="BlockExplorer" asp-action="Transaction" asp-route-transactionId="@transaction.TransactionHash">@transaction.TransactionHash.ToString().Substring(0, 25)...</a></td>
<td><a asp-controller="BlockExplorer" asp-action="Block" asp-route-block="@transaction.BlockIndex">@transaction.BlockIndex</a></td>
<td>-</td>
<td><span class="@(transaction.value > 0 ? "green" : "red")">@transaction.value.ToString("N8") STRAT</span></td>
<td><span class="@(transaction.Value > 0 ? "green" : "red")">@transaction.Value.ToString("N8") STRAT</span></td>
<td>@total.ToString("N8") STRAT</td>
</tr>
}
......
@model dynamic
@{
ViewBag.Title = $"Block {Model.blockIndex}";
ViewBag.Title = $"Block {Model.BlockIndex}";
Layout = "_Layout";
ViewBag.Query = Model.blockHash;
ViewBag.Query = Model.BlockHash;
}
@section Style
{
......@@ -19,7 +19,7 @@
<div class="row">
<div class="col-lg-12">
<div class="center-heading">
<h2 class="section-title">Details for Block #@Model.blockIndex</h2>
<h2 class="section-title">Details for Block #@Model.BlockIndex</h2>
</div>
</div>
</div>
......@@ -31,28 +31,28 @@
<tr>
<td><strong>Address</strong></td>
<td>
<a asp-controller="BlockExplorer" asp-action="Address" asp-route-address="@Model.blockHash">@Model.blockHash</a>
<a asp-controller="BlockExplorer" asp-action="Address" asp-route-address="@Model.BlockHash">@Model.BlockHash</a>
<a href="#" class="copy-me"><i class="fa fa-copy"></i></a>
</td>
</tr>
<tr>
<td><strong>Date/Time</strong></td>
<td>@DateTimeOffset.FromUnixTimeSeconds((long)Model.blockTime).ToString("F")</td>
<td>@DateTimeOffset.FromUnixTimeSeconds((long)Model.BlockTime).ToString("F")</td>
</tr>
<tr>
<td><strong>Block Size</strong></td>
<td>@Model.blockSize bytes</td>
<td>@Model.BlockSize bytes</td>
</tr>
<tr>
<td><strong>Previous Block Hash</strong></td>
<td>
<a asp-controller="BlockExplorer" asp-action="BlockHash" asp-route-hash="@Model.previousBlockHash">@(Model.previousBlockHash??"-")</a>
<a asp-controller="BlockExplorer" asp-action="BlockHash" asp-route-hash="@Model.PreviousBlockHash">@(Model.PreviousBlockHash??"-")</a>
</td>
</tr>
<tr>
<td><strong>Next Block Hash</strong></td>
<td>
<a asp-controller="BlockExplorer" asp-action="BlockHash" asp-route-hash="@Model.nextBlockHash">@(Model.nextBlockHash??"-")</a>
<a asp-controller="BlockExplorer" asp-action="BlockHash" asp-route-hash="@Model.NextBlockHash">@(Model.NextBlockHash??"-")</a>
</td>
</tr>
</tbody>
......@@ -79,7 +79,7 @@
</thead>
<tbody>
@{ int i = 0;}
@foreach (var transaction in Model.transactions)
@foreach (var transaction in Model.Transactions)
{
<tr>
<td>@i</td>
......
......@@ -39,11 +39,11 @@
@foreach(var block in ViewBag.Blocks)
{
<tr>
<td><a asp-controller="BlockExplorer" asp-action="Block" asp-route-block="@block.blockIndex">@block.blockIndex</a></td>
<td>@((DateTime.Now - DateTimeOffset.FromUnixTimeSeconds((long)block.blockTime)).TotalMinutes.ToString("N0")) Minutes</td>
<td class="text-center">@block.transactionCount</td>
<td>@block.blockSize bytes</td>
<td><a asp-controller="BlockExplorer" asp-action="BlockHash" asp-route-hash="@block.blockHash">@block.blockHash</a></td>
<td><a asp-controller="BlockExplorer" asp-action="Block" asp-route-block="@block.BlockIndex">@block.BlockIndex</a></td>
<td>@((DateTime.Now - DateTimeOffset.FromUnixTimeSeconds((long)block.BlockTime)).TotalMinutes.ToString("N0")) Minutes</td>
<td class="text-center">@block.TransactionCount</td>
<td>@block.BlockSize bytes</td>
<td><a asp-controller="BlockExplorer" asp-action="BlockHash" asp-route-hash="@block.BlockHash">@block.BlockHash</a></td>
</tr>
}
</tbody>
......
@model dynamic
@{
ViewBag.Title = $"Transaction {Model.transactionId}";
ViewBag.Title = $"Transaction {Model.TransactionId}";
Layout = "_Layout";
ViewBag.Query = Model.transactionId;
ViewBag.Query = Model.TransactionId;
}
@section Style
{
......@@ -36,34 +36,34 @@
<tr>
<td><strong>Transaction ID</strong></td>
<td>
<a asp-controller="BlockExplorer" asp-action="Transaction" asp-route-transactionId="@Model.transactionId">@Model.transactionId</a>
<a asp-controller="BlockExplorer" asp-action="Transaction" asp-route-transactionId="@Model.TransactionId">@Model.TransactionId</a>
<a href="#" class="copy-me"><i class="fa fa-copy"></i></a>
</td>
</tr>
<tr>
<td><strong>Block Hash</strong></td>
<td>
<a asp-controller="BlockExplorer" asp-action="BlockHash" asp-route-hash="@Model.blockHash">@Model.blockHash</a>
<a asp-controller="BlockExplorer" asp-action="BlockHash" asp-route-hash="@Model.BlockHash">@Model.BlockHash</a>
</td>
</tr>
<tr>
<td><strong>Block Height</strong></td>
<td>
<a asp-controller="BlockExplorer" asp-action="Block" asp-route-block="@Model.blockIndex">@Model.blockIndex</a>
<a asp-controller="BlockExplorer" asp-action="Block" asp-route-block="@Model.BlockIndex">@Model.BlockIndex</a>
</td>
</tr>
<tr>
<td><strong>Confirmations</strong></td>
<td>
@Model.confirmations
@Model.Confirmations
</td>
</tr>
<tr>
<td><strong>Block Date/Time</strong></td>
<td>@Model.timestamp.ToString("F")</td>
<td>@Model.Timestamp.ToString("F")</td>
</tr>
@if (Model.outputs != null)
@if (Model.Outputs != null)
{
<tr>
<td><strong>Total Output</strong></td>
......@@ -71,9 +71,9 @@
@{
double totalOutpus = 0;
}
@foreach (var t in Model.outputs)
@foreach (var t in Model.Outputs)
{
totalOutpus += (double) t.balance;
totalOutpus += (double) t.Balance;
}
@totalOutpus.ToString("N8") STRAT
</td>
......@@ -102,11 +102,11 @@
</tr>
</thead>
<tbody>
@foreach (var input in Model.inputs)
@foreach (var input in Model.Inputs)
{
<tr>
<td>@input.inputIndex</td>
<td><a asp-controller="BlockExplorer" asp-action="Transaction" asp-route-transactionId="@input.inputTransactionId">@input.inputTransactionId</a></td>
<td>@input.InputIndex</td>
<td><a asp-controller="BlockExplorer" asp-action="Transaction" asp-route-transactionId="@input.InputTransactionId">@input.InputTransactionId</a></td>
</tr>
}
</tbody>
......@@ -134,13 +134,13 @@
</tr>
</thead>
<tbody>
@foreach (var output in Model.outputs)
@foreach (var output in Model.Outputs)
{
<tr>
<td>@output.index</td>
<td><a asp-controller="BlockExplorer" asp-action="Address" asp-route-address="@output.address">@output.address</a></td>
<td>@output.outputType</td>
<td>@output.balance.ToString("N8") STRAT</td>
<td>@output.Index</td>
<td><a asp-controller="BlockExplorer" asp-action="Address" asp-route-address="@output.Address">@output.Address</a></td>
<td>@output.OutputType</td>
<td>@output.Balance.ToString("N8") STRAT</td>
</tr>
}
</tbody>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment