mirror of
https://github.com/aljazceru/BTCPayServerPlugins.git
synced 2025-12-17 07:34:24 +01:00
559 lines
30 KiB
Plaintext
559 lines
30 KiB
Plaintext
@using BTCPayServer.Abstractions.Contracts
|
|
@using BTCPayServer.Abstractions.TagHelpers
|
|
@using BTCPayServer.Client
|
|
@using BTCPayServer.Common
|
|
@using BTCPayServer.Components.TruncateCenter
|
|
@using BTCPayServer.Plugins.Wabisabi
|
|
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
|
@using Microsoft.Extensions.Logging
|
|
@using NBitcoin
|
|
@using WalletWasabi.WabiSabi.Backend.Rounds
|
|
@using WalletWasabi.WabiSabi.Client
|
|
@using WalletWasabi.WabiSabi.Models
|
|
@model object
|
|
@inject IScopeProvider ScopeProvider
|
|
@inject WabisabiService WabisabiService;
|
|
@inject WalletProvider WalletProvider;
|
|
@inject WabisabiCoordinatorClientInstanceManager WabisabiCoordinatorClientInstanceManager
|
|
|
|
<script src="~/Resources/chart.js" type="text/javascript"> </script>
|
|
|
|
@{
|
|
var available = true;
|
|
@if (((dynamic) Model).CryptoCode != "BTC" || ((dynamic) Model).WalletEnabled is not true)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var storeId = ScopeProvider.GetCurrentStoreId();
|
|
Context.Items["cjlite"] = true;
|
|
}
|
|
@if (available)
|
|
{
|
|
{
|
|
var settings = await WabisabiService.GetWabisabiForStore(storeId);
|
|
var enabledSettings = settings.Settings.Where(coordinatorSettings => coordinatorSettings.Enabled);
|
|
var cjHistory = (await WabisabiService.GetCoinjoinHistory(storeId)).Take(5).ToList();
|
|
|
|
@if (!enabledSettings.Any())
|
|
{
|
|
<div class="widget" permission="@Policies.CanModifyStoreSettings">
|
|
<partial name="../WabisabiStore/UpdateWabisabiStoreSettings" model="@settings"/>
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<div class="widget store-wallet-balance">
|
|
<header>
|
|
<h3>Recent Coinjoins</h3>
|
|
@if (cjHistory.Any())
|
|
{
|
|
<a asp-controller="WabisabiStore" asp-action="ListCoinjoins" asp-route-storeId="@storeId">View All</a>
|
|
}
|
|
</header>
|
|
@if (!cjHistory.Any())
|
|
{
|
|
<p class="text-secondary mt-3 mb-0">
|
|
There are no recent transactions.
|
|
</p>
|
|
}
|
|
else
|
|
{
|
|
<partial name="Wabisabi/CoinjoinHistoryTable" model="cjHistory"/>
|
|
}
|
|
</div>
|
|
|
|
var wallet = (BTCPayWallet?) await WalletProvider.GetWalletAsync(storeId);
|
|
|
|
var coins = await wallet.GetAllCoins();
|
|
var privacy = wallet.GetPrivacyPercentage(coins, wallet.AnonScoreTarget);
|
|
|
|
var privacyPercentage = Math.Round(privacy * 100);
|
|
var data = new
|
|
{
|
|
privacyProgress = privacyPercentage,
|
|
targetScore = wallet.AnonScoreTarget,
|
|
coins = coins.Select(coin => new
|
|
{
|
|
value = coin.Amount.ToDecimal(MoneyUnit.BTC),
|
|
score = coin.AnonymitySet,
|
|
isPrivate = coin.CoinColor(wallet) == AnonsetType.Green,
|
|
confirmed = coin.Confirmed,
|
|
id = coin.Outpoint.ToString(),
|
|
coinjoinInProgress = coin.CoinJoinInProgress
|
|
}).OrderBy(coin => coin.isPrivate).ThenBy(coin => coin.score),
|
|
};
|
|
@if (coins.Any())
|
|
{
|
|
<script>
|
|
|
|
document.addEventListener("DOMContentLoaded", function () {
|
|
|
|
function getColor(isPrivate, score, maxScore) {
|
|
let normalizedScore = Math.min(Math.max(score, 0), maxScore) / maxScore;
|
|
return isPrivate ? `rgb(81, 177, 62)` : `rgb(255, ${Math.floor(128 * normalizedScore)}, 0)`;
|
|
}
|
|
|
|
function prepareDatasets(data) {
|
|
const coins = data.coins;
|
|
const confirmedCoins = coins;
|
|
const inProgressCoins = coins.filter(coin => coin.coinjoinInProgress);
|
|
|
|
return [
|
|
{
|
|
id: "coins",
|
|
label: "Coins",
|
|
coins: confirmedCoins,
|
|
data: confirmedCoins.map(coin => coin.value),
|
|
backgroundColor: confirmedCoins.map(coin => getColor(coin.isPrivate, coin.score, data.targetScore)),
|
|
borderColor: "transparent",
|
|
borderWidth: 3
|
|
},
|
|
{
|
|
id: "inprogresscoins",
|
|
label: "In Progress Coins",
|
|
data: inProgressCoins.map(coin => coin.value),
|
|
backgroundColor: inProgressCoins.map(() => "rgb(56, 151, 37)"),
|
|
alternativeBackgroundColor: inProgressCoins.map(() => "rgb(28, 113, 11)"),
|
|
borderColor: "transparent",
|
|
borderWidth: 3,
|
|
coins: inProgressCoins
|
|
}
|
|
];
|
|
}
|
|
|
|
|
|
const data = @Json.Serialize(data);
|
|
const chartDataset = {
|
|
labels: data.coins.map(coin => coin.id),
|
|
datasets: prepareDatasets(data)
|
|
};
|
|
|
|
|
|
|
|
|
|
const config = {
|
|
type: "doughnut",
|
|
data: chartDataset,
|
|
options: {
|
|
cutout: "70%",
|
|
plugins: {
|
|
legend: { display: false },
|
|
tooltip: {
|
|
callbacks: {
|
|
labelColor: ()=>{ return undefined },
|
|
label: function (context) {
|
|
const coin = context.dataset.coins[context.dataIndex];
|
|
return [
|
|
`${coin.value.toFixed(8)} BTC`,
|
|
`${coin.score.toFixed(2)} anonset`,
|
|
`${coin.confirmed ? "confirmed" : "unconfirmed"}`,
|
|
coin.isPrivate ? "private" : "not private",
|
|
...(coin.coinjoinInProgress ? ["mixing"] : []),
|
|
...(!coin.isPrivate && coin.score >= data.targetScore? ["Coin too close to entry to deem private just yet"] : [])
|
|
];
|
|
},
|
|
title: function (context) {
|
|
const coin = context[0].dataset.coins[context[0].dataIndex];
|
|
//truncated shoudl have first few chars and then ... and then last few chars
|
|
|
|
const truncatedId = coin.id.substring(0, 8) + "..." + coin.id.substring(coin.id.length - 8);
|
|
return truncatedId;
|
|
},
|
|
footer: function (context) {}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
plugins: [{
|
|
beforeDraw: function (chart) {
|
|
let ctx = chart.ctx;
|
|
ctx.save();
|
|
let width = chart.width;
|
|
let height = chart.height;
|
|
ctx.textBaseline = "middle";
|
|
ctx.fillStyle = getComputedStyle(document.body).getPropertyValue('color');
|
|
|
|
function calculateFontSize(text, maxWidth, initialFontSize) {
|
|
let fontSize = initialFontSize;
|
|
ctx.font = fontSize + "em sans-serif";
|
|
while (ctx.measureText(text).width > maxWidth && fontSize > 0) {
|
|
fontSize -= 0.1;
|
|
ctx.font = fontSize + "em sans-serif";
|
|
}
|
|
return fontSize;
|
|
}
|
|
|
|
function getTextHeight(text, fontSize) {
|
|
ctx.font = fontSize + "em sans-serif";
|
|
let metrics = ctx.measureText(text);
|
|
return metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent +5;
|
|
}
|
|
|
|
function drawCenteredText(text, posY, maxWidth, fontSize) {
|
|
ctx.font = fontSize + "em sans-serif";
|
|
let textX = (width - ctx.measureText(text).width) / 2;
|
|
ctx.fillText(text, textX, posY);
|
|
return getTextHeight(text, fontSize);
|
|
}
|
|
|
|
let pfs = height / 114;
|
|
let textY = height / 4;
|
|
let maxWidth = width * 0.6;
|
|
|
|
|
|
let lineTexts = [];
|
|
const totalPrivateSum = data.coins.filter(coin => coin.isPrivate).length;
|
|
const totalPrivateValueSum = data.coins.filter(coin => coin.isPrivate).reduce((sum, coin) => sum + coin.value, 0);
|
|
if (totalPrivateSum > 0)
|
|
lineTexts.push(`${totalPrivateSum} coins(${totalPrivateValueSum.toFixed(8)}BTC) private`);
|
|
const totalNonPrivateSum = data.coins.filter(coin => !coin.isPrivate).length;
|
|
const totalNonPrivateValueSum = data.coins.filter(coin => !coin.isPrivate).reduce((sum, coin) => sum + coin.value, 0);
|
|
if (totalNonPrivateSum > 0)
|
|
lineTexts.push(`${totalNonPrivateSum} coins(${totalNonPrivateValueSum.toFixed(8)}BTC) semi/not private`);
|
|
const totalInProgressSum = data.coins.filter(coin => coin.coinjoinInProgress).length;
|
|
const totalInProgressValueSum = data.coins.filter(coin => coin.coinjoinInProgress).reduce((sum, coin) => sum + coin.value, 0);
|
|
if (totalInProgressSum > 0)
|
|
lineTexts.push(`${totalInProgressSum} coins(${totalInProgressValueSum.toFixed(8)}BTC) mixing`);
|
|
|
|
let commonFontSize = lineTexts.reduce(
|
|
(size, text) => Math.min(size, calculateFontSize(text, maxWidth, pfs * 0.7)),
|
|
pfs * 0.7
|
|
);
|
|
|
|
let totalTextHeight = getTextHeight(`${data.privacyProgress}%`, pfs) +
|
|
getTextHeight("Private",pfs) + 10 +
|
|
lineTexts.reduce((totalHeight, text) => totalHeight + getTextHeight(text, commonFontSize), 0);
|
|
|
|
// Adjust initial Y position for vertical centering
|
|
textY = (height - totalTextHeight) / 2;
|
|
|
|
// Draw the main text (privacy progress) and additional summary text
|
|
textY += drawCenteredText(`${data.privacyProgress}%`, textY, maxWidth, pfs);
|
|
textY += drawCenteredText("Private",textY, maxWidth, pfs) +10;
|
|
lineTexts.forEach(text => {
|
|
textY += drawCenteredText(text, textY, maxWidth, commonFontSize);
|
|
});
|
|
|
|
ctx.restore();
|
|
}
|
|
}]
|
|
};
|
|
|
|
const ctx = document.getElementById("cjchart").getContext("2d");
|
|
const myChart = new Chart(ctx, config);
|
|
|
|
function updateInProgressAnimation(chart) {
|
|
chart.data.datasets.forEach(dataset => {
|
|
if (dataset.id === "inprogresscoins") {
|
|
dataset.backgroundColor = dataset.backgroundColor.map((c, i) => {
|
|
const alt = dataset.alternativeBackgroundColor[i];
|
|
const current = dataset.backgroundColor[i];
|
|
dataset.alternativeBackgroundColor[i] = current;
|
|
return alt;
|
|
});
|
|
}
|
|
});
|
|
chart.update();
|
|
setTimeout(() => updateInProgressAnimation(chart), 1000);
|
|
}
|
|
|
|
updateInProgressAnimation(myChart);
|
|
|
|
});
|
|
</script>
|
|
}
|
|
|
|
<div class="widget store-numbers">
|
|
|
|
@if (wallet is { })
|
|
{
|
|
@if (!((BTCPayKeyChain) wallet.KeyChain).KeysAvailable)
|
|
{
|
|
<div class="alert alert-danger d-flex align-items-center" role="alert">
|
|
<vc:icon symbol="warning"/>
|
|
<span class="ms-3">This wallet is either not a hot wallet, or enabled in yout store settings and will not be able to participate in coinjoins.</span>
|
|
</div>
|
|
}
|
|
}
|
|
<header>
|
|
<h4>Coinjoin stats</h4>
|
|
<div class="d-flex gap-1">
|
|
<a asp-controller="WabisabiStore" asp-action="ToggleActive" asp-route-storeId="@storeId" asp-route-returnUrl="@Context.Request.GetCurrentUrl()" class="fw-semibold">
|
|
@(settings.Active ? "Deactivate" : "Activate")
|
|
</a>
|
|
-
|
|
<a asp-controller="WabisabiStore" asp-action="UpdateWabisabiStoreSettings" asp-route-storeId="@storeId" class="fw-semibold" permission="@Policies.CanModifyStoreSettings">
|
|
Configure
|
|
</a>
|
|
</div>
|
|
</header>
|
|
<div class="w-100">
|
|
|
|
@if (coins.Any())
|
|
{
|
|
<div class="d-flex justify-content-center mb-4" style="max-height: 400px; ">
|
|
<canvas id="cjchart"></canvas>
|
|
</div>
|
|
}
|
|
|
|
<div class="modal modal-lg fade" id="coins" data-bs-keyboard="false" tabindex="-1">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h1 class="modal-title fs-5">Your coins</h1>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
|
|
<table class="table">
|
|
<thead>
|
|
|
|
<tr>
|
|
<th colspan="3">Coins</th>
|
|
</tr>
|
|
<tr>
|
|
<th>
|
|
Anonset
|
|
</th>
|
|
<th>
|
|
Value
|
|
</th>
|
|
<th>
|
|
Labels
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
|
|
@foreach (var coin in coins.OrderByDescending(coin => coin.AnonymitySet).ThenByDescending(coin => coin.Amount))
|
|
{
|
|
<tr>
|
|
<td>
|
|
@coin.AnonymitySet.ToString("0.##")
|
|
@if (!coin.IsSufficientlyDistancedFromExternalKeys)
|
|
{
|
|
<span>(too close to entry)</span>
|
|
}
|
|
</td>
|
|
<td>
|
|
@coin.Amount.ToDecimal(MoneyUnit.BTC) BTC
|
|
</td>
|
|
<td>
|
|
|
|
@for (var index = 0; index < coin.HdPubKey.Labels.Count; index++)
|
|
{
|
|
var label = coin.HdPubKey.Labels.ElementAt(index);
|
|
<vc:truncate-center text="@label" classes="truncate-center-id"></vc:truncate-center>
|
|
}
|
|
</td>
|
|
</tr>
|
|
}
|
|
</table>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="list-group list-group-flush mb-3">
|
|
@if (enabledSettings.Count() > 1)
|
|
{
|
|
<h5 class="list-group-item-heading text-muted">Enabled coordinators</h5>
|
|
}
|
|
@{
|
|
foreach (var setting in enabledSettings)
|
|
{
|
|
if (!WabisabiCoordinatorClientInstanceManager.HostedServices.TryGetValue(setting.Coordinator, out var coordinator))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
RoundState currentRound = null;
|
|
CoinJoinTracker tracker = null;
|
|
if (coordinator.CoinJoinManager.TrackedCoinJoins?.TryGetValue(wallet.WalletId, out tracker) is true &&
|
|
tracker?.CoinJoinClient?.CurrentRoundId is { } &&
|
|
tracker?.CoinJoinClient?.RoundStatusUpdater?.RoundStates?.TryGetValue(tracker?.CoinJoinClient?.CurrentRoundId, out currentRound) is true)
|
|
{
|
|
}
|
|
|
|
var statusMsg = coordinator.WasabiCoordinatorStatusFetcher.Connected ? $"Connected to {(coordinator.Coordinator?.ToString() ?? "local")}" : $"Not connected to {(coordinator.Coordinator?.ToString() ?? "local")}";
|
|
<div class="list-group-item">
|
|
<div class="d-flex flex-wrap align-items-center justify-content-between gap-3">
|
|
<div class="d-flex flex-wrap align-items-center justify-content-between gap-3" data-bs-toggle="tooltip" title="@statusMsg">
|
|
<span class="btcpay-status btcpay-status--@(coordinator.WasabiCoordinatorStatusFetcher.Connected ? "enabled" : "disabled")"></span>
|
|
<h6>@coordinator.CoordinatorDisplayName</h6>
|
|
</div>
|
|
@if (currentRound is not null)
|
|
{
|
|
<div class="timer cursor-pointer" data-bs-toggle="collapse" data-bs-target="#cj-@currentRound.Id">
|
|
<span class="spinner-border spinner-border-sm" role="status">
|
|
<span class="visually-hidden"></span>
|
|
</span>
|
|
<span class="h6">Mixing</span>
|
|
<vc:icon symbol="caret-down"/>
|
|
</div>
|
|
}
|
|
else if (coordinator.WasabiCoordinatorStatusFetcher.Connected)
|
|
{
|
|
var roundParameters = coordinator.RoundStateUpdater.RoundStates.LastOrDefault(pair => pair.Value.BlameOf == uint256.Zero).Value?.CoinjoinState.Parameters;
|
|
coordinator.CoinJoinManager.TrackedWallets.TryGetValue(wallet.WalletId, out var trackedWallet);
|
|
if(trackedWallet is {} && setting.RoundWhenEnabled is not null && roundParameters is not null && !BTCPayWallet.IsRoundOk(roundParameters, setting))
|
|
{
|
|
<a asp-controller="WabisabiStore" asp-action="UpdateWabisabiStoreSettings" asp-route-storeId="@storeId" class="h6 text-danger">
|
|
New terms detected
|
|
</a>
|
|
}else if (trackedWallet is {})
|
|
{
|
|
<span class="h6">Idle</span>
|
|
}
|
|
else
|
|
{
|
|
<span class="h6">Inactive</span>
|
|
}
|
|
}
|
|
</div>
|
|
@{
|
|
if (coordinator.CoinPrison is not null)
|
|
{
|
|
var bannedCoins = coins.Where(coin => coordinator.CoinPrison.IsBanned(coin.Outpoint)).ToArray();
|
|
@if (bannedCoins.Any())
|
|
{
|
|
<div class="text-muted">@bannedCoins.Count() banned coins(for disrupting rounds)</div>
|
|
}
|
|
}
|
|
|
|
|
|
if (currentRound is not null)
|
|
{
|
|
<div class="collapse table-responsive @(enabledSettings.Count() == 1 ? "show" : "") m-0 w-100" id="cj-@currentRound.Id">
|
|
<table class="table w-100">
|
|
<tr>
|
|
<th scope="">Status</th>
|
|
<td class="text-truncate">@currentRound.Phase.ToString().ToSentenceCase()</td>
|
|
</tr>
|
|
<tr>
|
|
<th scope="">Round id</th>
|
|
<td class="text-truncate">
|
|
<vc:truncate-center text="@currentRound.Id.ToString()" classes="truncate-center-id"></vc:truncate-center>
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<th scope="">Mining feerate</th>
|
|
<td >@currentRound.CoinjoinState.Parameters.MiningFeeRate.ToString()</td>
|
|
</tr>
|
|
<tr>
|
|
<th scope="">Coinjoin total inputs</th>
|
|
<td >@currentRound.CoinjoinState.Inputs.Count() inputs (@currentRound.CoinjoinState.Inputs.Sum(coin => coin.Amount.ToDecimal(MoneyUnit.BTC)) BTC)</td>
|
|
</tr>
|
|
@if (!tracker.CoinJoinClient.CoinsToRegister.IsEmpty)
|
|
{
|
|
var statement = $"Registered {tracker.CoinJoinClient.CoinsInCriticalPhase.Count()} inputs ({tracker.CoinJoinClient.CoinsInCriticalPhase.Sum(coin => coin.Amount.ToDecimal(MoneyUnit.BTC))} BTC)";
|
|
if (tracker.CoinJoinClient.CoinsInCriticalPhase.Count() != tracker.CoinJoinClient.CoinsToRegister.Count())
|
|
{
|
|
statement += $" / {tracker.CoinJoinClient.CoinsToRegister.Count()} inputs ({tracker.CoinJoinClient.CoinsToRegister.Sum(coin => coin.Amount.ToDecimal(MoneyUnit.BTC))} BTC)";
|
|
}
|
|
|
|
var inputToolTip = tracker.CoinJoinClient.CoinsToRegister.Aggregate("", (current, coin) => current + $"{coin.Amount.ToDecimal(MoneyUnit.BTC)} BTC\n");
|
|
<tr data-bs-toggle="tooltip" title="@inputToolTip">
|
|
<th scope="">Your inputs</th>
|
|
<td class="">
|
|
<span class="w-100">@statement</span>
|
|
@if (tracker.BannedCoins.Any())
|
|
{
|
|
<span class="w-100 text-danger">but got @tracker.BannedCoins.Count() inputs (@tracker.BannedCoins.Sum(coin => coin.Coin.Amount.ToDecimal(MoneyUnit.BTC)) BTC) banned</span>
|
|
}
|
|
</td>
|
|
</tr>
|
|
}
|
|
|
|
@if (currentRound.Phase >= Phase.OutputRegistration)
|
|
{
|
|
<tr>
|
|
<th scope="">Coinjoin total outputs</th>
|
|
<td >@currentRound.CoinjoinState.Outputs.Count() outputs (@currentRound.CoinjoinState.Outputs.Sum(coin => coin.Value.ToDecimal(MoneyUnit.BTC)) BTC)</td>
|
|
</tr>
|
|
if (tracker.CoinJoinClient.OutputTxOuts is { } outputs)
|
|
{
|
|
var statement = $"{outputs.outputTxOuts.Count()} outputs ({outputs.outputTxOuts.Sum(coin => coin.Value.ToDecimal(MoneyUnit.BTC))} BTC {(outputs.batchedPayments.Any() ? $"{outputs.batchedPayments.Count()} batched payments" : "")}";
|
|
var outputToolTip = outputs.outputTxOuts.Aggregate("", (current, output) => current + $"{output.Value.ToDecimal(MoneyUnit.BTC)} BTC\n");
|
|
|
|
|
|
<tr data-bs-toggle="tooltip" title="@outputToolTip">
|
|
<th scope="">Your outputs</th>
|
|
<td >@statement</td>
|
|
</tr>
|
|
}
|
|
}
|
|
</table>
|
|
|
|
</div>
|
|
}
|
|
}
|
|
</div>
|
|
}
|
|
}
|
|
</div>
|
|
|
|
@if (coins.Any())
|
|
{
|
|
<button type="button" class="btn btn-text p-1" data-bs-toggle="modal" data-bs-target="#coins">
|
|
View coins
|
|
</button>
|
|
}
|
|
@if (wallet.LastLogs.Any())
|
|
{
|
|
<button type="button" class="btn btn-text p-1" data-bs-toggle="modal" data-bs-target="#logs">
|
|
Recent logs
|
|
</button>
|
|
<div class="modal modal-lg fade" id="logs" data-bs-keyboard="false" tabindex="-1">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h3 class="mb-0">Coinjoin logs</h3>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<div class="modal-body mt-0">
|
|
<div class="table-responsive">
|
|
|
|
<table class="table">
|
|
<thead>
|
|
<tr>
|
|
<th>Time</th>
|
|
<th>Coordinator</th>
|
|
<th>Message</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach (var evt in wallet.LastLogs)
|
|
{
|
|
string cssClass = evt.level <= (LogLevel) 2 ? "info" : evt.level == (LogLevel) 4 ? "warning" : "danger";
|
|
<tr class="text-@cssClass">
|
|
<td>
|
|
<small class="text-muted" data-timeago-unixms="@evt.time.ToUnixTimeMilliseconds()">@evt.time.ToTimeAgo()</small>
|
|
</td>
|
|
<td>
|
|
<pre>@evt.coordinator</pre>
|
|
</td>
|
|
<td>
|
|
<pre>@evt.message</pre>
|
|
</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
</div>
|
|
</div>
|
|
}
|
|
}
|
|
} |