2026-10-05 06:20:18 +02:00
|
|
|
@inherits LocalizableComponentBase
|
|
|
|
|
|
|
|
|
|
@* PrivaPub's federation statistics (/clientapi/admin/statistics): the last days at a glance, the servers exchanged with
|
2026-10-17 04:02:20 +02:00
|
|
|
(by traffic, failures, latency or name, 50 a page; a server's name opens its detail, HostDetailDialog), how far the
|
|
|
|
|
crawler got, the ledger's events (by server, channel and outcome, 100 at a time, older ones from the last one's time)
|
|
|
|
|
and the server's own days (its client API, the documents it served, its ledger). Counted per server, never per
|
|
|
|
|
account. *@
|
2026-10-05 06:20:18 +02:00
|
|
|
<div class="flex flex-col gap-4 w-full" data-statistics>
|
|
|
|
|
<div class="flex flex-wrap gap-2 items-end">
|
|
|
|
|
<label class="flex flex-col gap-1 text-sm">
|
|
|
|
|
<span>@Localizer["Period"]</span>
|
|
|
|
|
<DSelect TValue="int" Size="DSize.Sm" Value="days" ValueChanged="ChangeDays" ValueExpression="() => days">
|
|
|
|
|
@foreach (var period in Periods)
|
|
|
|
|
{
|
|
|
|
|
<option value="@period">@Localizer["{0} days", period]</option>
|
|
|
|
|
}
|
|
|
|
|
</DSelect>
|
|
|
|
|
</label>
|
|
|
|
|
<label class="flex flex-col gap-1 text-sm">
|
|
|
|
|
<span>@Localizer["Servers by"]</span>
|
|
|
|
|
<DSelect TValue="string" Size="DSize.Sm" Value="sort" ValueChanged="ChangeSort" ValueExpression="() => sort">
|
|
|
|
|
<option value="traffic">@Localizer["Traffic"]</option>
|
|
|
|
|
<option value="failures">@Localizer["Failures"]</option>
|
|
|
|
|
<option value="latency">@Localizer["Latency"]</option>
|
|
|
|
|
<option value="host">@Localizer["Name"]</option>
|
|
|
|
|
</DSelect>
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
@if (IsLoading)
|
|
|
|
|
{
|
|
|
|
|
<p class="flex w-full items-center justify-center gap-2">
|
|
|
|
|
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
|
|
|
|
|
@Localizer["loading"]
|
|
|
|
|
</p>
|
|
|
|
|
}
|
|
|
|
|
else if (overview is null)
|
|
|
|
|
{
|
|
|
|
|
<p class="text-sm opacity-70">@Localizer["The statistics could not be read."]</p>
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
2026-10-07 00:20:56 +02:00
|
|
|
<DStats class="w-full overflow-x-auto neo-raised neo-xs" data-statistics-overview data-scroll-x>
|
2026-10-05 06:20:18 +02:00
|
|
|
<DStat>
|
|
|
|
|
<Heading>@Localizer["Deliveries that arrived"]</Heading>
|
|
|
|
|
<ChildContent>@(overview.DeliverySuccess is { } success ? $"{success * 100:0.#}%" : "—")</ChildContent>
|
|
|
|
|
<Desc>@Localizer["p50 {0} ms, p95 {1} ms", Ms(overview.DeliveryLatencyMs.P50), Ms(overview.DeliveryLatencyMs.P95)]</Desc>
|
|
|
|
|
</DStat>
|
|
|
|
|
<DStat>
|
|
|
|
|
<Heading><span class="capitalize">@Localizer["servers"]</span></Heading>
|
|
|
|
|
<ChildContent>@overview.Hosts.Active</ChildContent>
|
|
|
|
|
<Desc>@Localizer["{0} known", overview.Hosts.Known]</Desc>
|
|
|
|
|
</DStat>
|
|
|
|
|
<DStat>
|
|
|
|
|
<Heading>@Localizer["Accounts seen"]</Heading>
|
|
|
|
|
<ChildContent>@overview.Accounts</ChildContent>
|
|
|
|
|
<Desc>@Localizer["ledger {0} written, {1} dropped", overview.Ledger.Written, overview.Ledger.Dropped]</Desc>
|
|
|
|
|
</DStat>
|
|
|
|
|
</DStats>
|
|
|
|
|
|
|
|
|
|
<div class="flex flex-wrap gap-4 text-xs">
|
|
|
|
|
@foreach (var (title, counts) in Outcomes)
|
|
|
|
|
{
|
|
|
|
|
<div class="flex flex-col gap-1 min-w-40">
|
|
|
|
|
<b class="text-sm">@title</b>
|
|
|
|
|
@foreach (var (outcome, count) in counts.OrderByDescending(c => c.Value))
|
|
|
|
|
{
|
|
|
|
|
<span class="flex justify-between gap-4 tabular-nums"><span>@outcome</span><span>@count</span></span>
|
|
|
|
|
}
|
|
|
|
|
</div>
|
|
|
|
|
}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
@if (hosts is not null)
|
|
|
|
|
{
|
|
|
|
|
<DTable Size="DSize.Xs" Zebra="true" WrapperClass="max-h-[28rem] rounded-xl neo-inset neo-2xs" data-statistics-hosts>
|
|
|
|
|
<thead>
|
|
|
|
|
<tr>
|
|
|
|
|
<th>@Localizer["server"]</th>
|
|
|
|
|
<th>@Localizer["software"]</th>
|
|
|
|
|
<th class="text-right">@Localizer["In"]</th>
|
|
|
|
|
<th class="text-right">@Localizer["Out"]</th>
|
|
|
|
|
<th class="text-right">@Localizer["Refused"]</th>
|
|
|
|
|
<th class="text-right">@Localizer["Failures"]</th>
|
|
|
|
|
<th class="text-right">p50</th>
|
|
|
|
|
<th>@Localizer["Delivery"]</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
|
|
|
|
@foreach (var host in hosts.Hosts)
|
|
|
|
|
{
|
|
|
|
|
<tr data-statistics-host="@host.Host">
|
2026-10-17 04:02:20 +02:00
|
|
|
<td class="max-w-48">
|
|
|
|
|
<button type="button" class="link link-hover block max-w-full truncate text-left pointer-coarse:py-3.5" title="@host.Host"
|
|
|
|
|
@onclick="() => openHost = host.Host" data-statistics-host-open>@host.Host</button>
|
|
|
|
|
</td>
|
2026-10-05 06:20:18 +02:00
|
|
|
<td>@host.Software @host.Version</td>
|
|
|
|
|
<td class="text-right tabular-nums">@host.In</td>
|
|
|
|
|
<td class="text-right tabular-nums">@host.Out</td>
|
|
|
|
|
<td class="text-right tabular-nums">@host.Refused</td>
|
|
|
|
|
<td class="text-right tabular-nums">@host.Failures</td>
|
|
|
|
|
<td class="text-right tabular-nums">@Ms(host.LatencyP50)</td>
|
|
|
|
|
<td>
|
|
|
|
|
@if (host.Delivery?.UnavailableUntil is { } until && until > DateTime.UtcNow)
|
|
|
|
|
{
|
|
|
|
|
<DBadge Size="DSize.Xs" Color="DColor.Error" title="@until.ToLocalTime().ToString(SUtility.dateTimeFormat)">@Localizer["paused"]</DBadge>
|
|
|
|
|
}
|
|
|
|
|
else if (host.Delivery?.ConsecutiveFailures > 0)
|
|
|
|
|
{
|
|
|
|
|
<DBadge Size="DSize.Xs" Color="DColor.Warning">@Localizer["{0} failing", host.Delivery.ConsecutiveFailures]</DBadge>
|
|
|
|
|
}
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
}
|
|
|
|
|
</tbody>
|
|
|
|
|
</DTable>
|
|
|
|
|
@if (hosts.Total > PageSize)
|
|
|
|
|
{
|
|
|
|
|
<DPagination Size="DSize.Xs" Count="@((hosts.Total + PageSize - 1) / PageSize)" Current="page" CurrentChanged="ChangePage"
|
|
|
|
|
PreviousAriaLabel="@Localizer["Previous page"]" NextAriaLabel="@Localizer["Next page"]" />
|
|
|
|
|
}
|
|
|
|
|
<p class="text-xs opacity-70">@hosts.Attribution</p>
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-17 04:02:20 +02:00
|
|
|
@if (openHost is { } detailHost)
|
|
|
|
|
{
|
|
|
|
|
<HostDetailDialog HostName="@detailHost" Days="days" OnClosed="() => openHost = null" />
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-05 06:20:18 +02:00
|
|
|
@if (crawler is not null)
|
|
|
|
|
{
|
|
|
|
|
<div class="flex flex-col gap-1 text-xs" data-statistics-crawler>
|
|
|
|
|
<b class="text-sm">@Localizer["Crawler"]</b>
|
|
|
|
|
<span>
|
|
|
|
|
@(crawler.Enabled ? Localizer["on"] : Localizer["off"]) ·
|
|
|
|
|
@Localizer["{0} servers touched, {1} only crawled, {2} due, {3} refuse robots", crawler.Touched, crawler.Crawled, crawler.Due, crawler.RobotsDisallowed]
|
|
|
|
|
</span>
|
|
|
|
|
@foreach (var host in crawler.Recent)
|
|
|
|
|
{
|
|
|
|
|
<span class="flex flex-wrap gap-2">
|
|
|
|
|
<span class="truncate max-w-48">@host.Host</span>
|
|
|
|
|
<span class="opacity-70">@host.Software</span>
|
|
|
|
|
@if (host.LastCrawledAt is { } crawled)
|
|
|
|
|
{
|
|
|
|
|
<span class="opacity-70">@crawled.GetPassedTime(Localizer)</span>
|
|
|
|
|
}
|
|
|
|
|
@if (host.CrawlError is { Length: > 0 } crawlError)
|
|
|
|
|
{
|
|
|
|
|
<span class="text-error">@crawlError</span>
|
|
|
|
|
}
|
|
|
|
|
</span>
|
|
|
|
|
}
|
|
|
|
|
</div>
|
|
|
|
|
}
|
2026-10-17 04:02:20 +02:00
|
|
|
|
|
|
|
|
<div class="flex flex-col gap-2" data-statistics-events>
|
|
|
|
|
<b class="text-sm">@Localizer["Events"]</b>
|
|
|
|
|
<div class="flex flex-wrap gap-2 items-end">
|
|
|
|
|
<label class="flex flex-col gap-1 text-sm">
|
|
|
|
|
<span>@Localizer["server"]</span>
|
|
|
|
|
<DInput TValue="string" Size="DSize.Sm" @bind-Value="eventsHost" @bind-Value:after="ReloadEvents" autocomplete="off" maxlength="253"
|
|
|
|
|
placeholder="@Localizer["A server, e.g. example.social"]" data-statistics-events-host />
|
|
|
|
|
</label>
|
|
|
|
|
<label class="flex flex-col gap-1 text-sm">
|
|
|
|
|
<span>@Localizer["Channel"]</span>
|
|
|
|
|
<DSelect TValue="string" Size="DSize.Sm" Value="eventsChannel" ValueChanged="ChangeChannel" ValueExpression="() => eventsChannel"
|
|
|
|
|
data-statistics-events-channel>
|
|
|
|
|
<option value="">@Localizer["All"]</option>
|
|
|
|
|
@foreach (var channel in EventChannels)
|
|
|
|
|
{
|
|
|
|
|
<option value="@channel">@channel</option>
|
|
|
|
|
}
|
|
|
|
|
</DSelect>
|
|
|
|
|
</label>
|
|
|
|
|
<label class="flex flex-col gap-1 text-sm">
|
|
|
|
|
<span>@Localizer["Outcome"]</span>
|
|
|
|
|
<DSelect TValue="string" Size="DSize.Sm" Value="eventsOutcome" ValueChanged="ChangeOutcome" ValueExpression="() => eventsOutcome"
|
|
|
|
|
data-statistics-events-outcome>
|
|
|
|
|
<option value="">@Localizer["All"]</option>
|
|
|
|
|
@foreach (var outcome in EventOutcomes)
|
|
|
|
|
{
|
|
|
|
|
<option value="@outcome">@outcome</option>
|
|
|
|
|
}
|
|
|
|
|
</DSelect>
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
@if (events.Count == 0)
|
|
|
|
|
{
|
|
|
|
|
<p class="text-xs opacity-70" data-statistics-events-empty>@Localizer["Empty"]</p>
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
<EventsTable Events="events" />
|
|
|
|
|
}
|
|
|
|
|
@if (hasMoreEvents)
|
|
|
|
|
{
|
|
|
|
|
<DButton Size="DSize.Sm" class="self-center" Disabled="@(IsDefaultDisabled || isLoadingMore)" OnClick="MoreEvents" data-statistics-events-more>
|
|
|
|
|
@if (isLoadingMore)
|
|
|
|
|
{
|
|
|
|
|
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
|
|
|
|
|
}
|
|
|
|
|
<span>@Localizer["Load more"]</span>
|
|
|
|
|
</DButton>
|
|
|
|
|
}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="flex flex-col gap-2" data-statistics-server>
|
|
|
|
|
<b class="text-sm">@Localizer["Server days"]</b>
|
|
|
|
|
@if (serverDays.Count == 0)
|
|
|
|
|
{
|
|
|
|
|
<p class="text-xs opacity-70">@Localizer["Empty"]</p>
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
<DTable Size="DSize.Xs" Zebra="true" WrapperClass="max-h-[28rem] rounded-xl neo-inset neo-2xs" data-scroll-x>
|
|
|
|
|
<thead>
|
|
|
|
|
<tr>
|
|
|
|
|
<th>@Localizer["Day"]</th>
|
|
|
|
|
<th class="text-right">@Localizer["Client API"]</th>
|
|
|
|
|
<th class="text-right">4xx</th>
|
|
|
|
|
<th class="text-right">5xx</th>
|
|
|
|
|
<th class="text-right">@Localizer["Served"]</th>
|
|
|
|
|
<th class="text-right" title="@Localizer["written / dropped / failed"]">@Localizer["Ledger"]</th>
|
|
|
|
|
<th class="text-right">@Localizer["Accounts"]</th>
|
|
|
|
|
<th class="text-right"><span class="capitalize">@Localizer["servers"]</span></th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
|
|
|
|
@foreach (var day in Enumerable.Reverse(serverDays))
|
|
|
|
|
{
|
|
|
|
|
<tr data-statistics-server-day="@NerdRow.Time(day.Day)">
|
|
|
|
|
<td class="whitespace-nowrap">@day.Day.ToString(SUtility.dateTimeFormat)</td>
|
|
|
|
|
<td class="text-right tabular-nums">@ClientRequests(day, null)</td>
|
|
|
|
|
<td class="text-right tabular-nums">@ClientRequests(day, ":4xx")</td>
|
|
|
|
|
<td class="text-right tabular-nums">@ClientRequests(day, ":5xx")</td>
|
|
|
|
|
<td class="text-right tabular-nums">@day.Served.Values.Sum()</td>
|
|
|
|
|
<td class="text-right tabular-nums whitespace-nowrap" title="@Localizer["written / dropped / failed"]">
|
|
|
|
|
@day.LedgerWritten / @day.LedgerDropped / @day.LedgerFailed
|
|
|
|
|
</td>
|
|
|
|
|
<td class="text-right tabular-nums">@day.Accounts</td>
|
|
|
|
|
<td class="text-right tabular-nums">@day.Hosts</td>
|
|
|
|
|
</tr>
|
|
|
|
|
}
|
|
|
|
|
</tbody>
|
|
|
|
|
</DTable>
|
|
|
|
|
}
|
|
|
|
|
</div>
|
2026-10-05 06:20:18 +02:00
|
|
|
}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
@code {
|
|
|
|
|
[Inject] AdminService AdminService { get; set; }
|
|
|
|
|
|
|
|
|
|
const int PageSize = 50;
|
2026-10-17 04:02:20 +02:00
|
|
|
const int EventsPageSize = 100;
|
|
|
|
|
// the ledger's channels and outcomes, as it keys them
|
|
|
|
|
static readonly string[] EventChannels = ["recv", "in", "out", "http", "preview", "crawl"];
|
|
|
|
|
static readonly string[] EventOutcomes = ["queued", "accepted", "refused", "dropped", "rejected", "ok", "failed", "deferred", "retry", "dead"];
|
2026-10-05 06:20:18 +02:00
|
|
|
static readonly int[] Periods = [1, 7, 30, 90];
|
|
|
|
|
|
|
|
|
|
ViewStatisticsOverview overview;
|
|
|
|
|
ViewHostsPage hosts;
|
|
|
|
|
ViewCrawler crawler;
|
2026-10-17 04:02:20 +02:00
|
|
|
List<ViewInteractionEvent> events = [];
|
|
|
|
|
List<ViewServerDay> serverDays = [];
|
|
|
|
|
// the server whose detail is open
|
|
|
|
|
string openHost;
|
|
|
|
|
string eventsHost;
|
|
|
|
|
string eventsChannel;
|
|
|
|
|
string eventsOutcome;
|
|
|
|
|
bool hasMoreEvents;
|
|
|
|
|
bool isLoadingMore;
|
2026-10-05 06:20:18 +02:00
|
|
|
int days = 30;
|
|
|
|
|
string sort = "traffic";
|
|
|
|
|
int page = 1;
|
|
|
|
|
|
|
|
|
|
(string Title, Dictionary<string, long> Counts)[] Outcomes =>
|
|
|
|
|
[
|
|
|
|
|
(Localizer["Received"], overview.Inbound),
|
|
|
|
|
(Localizer["Delivered"], overview.Outbound),
|
|
|
|
|
(Localizer["Channels"], overview.Channels)
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
protected override async Task OnInitializedAsync()
|
|
|
|
|
{
|
|
|
|
|
if (CascadingState.IsOffline)
|
|
|
|
|
{
|
|
|
|
|
WhenOnline(LoadData);
|
|
|
|
|
StartOnlinePolling();
|
|
|
|
|
IsLoading = false;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await LoadData();
|
|
|
|
|
IsLoading = false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async Task LoadData()
|
2026-10-17 04:02:20 +02:00
|
|
|
{
|
|
|
|
|
await LoadDays();
|
|
|
|
|
crawler = await AdminService.Crawler();
|
|
|
|
|
await ReloadEvents();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// what the period chosen shows
|
|
|
|
|
async Task LoadDays()
|
2026-10-05 06:20:18 +02:00
|
|
|
{
|
|
|
|
|
overview = await AdminService.Overview(days);
|
|
|
|
|
hosts = await AdminService.Hosts(days, sort, page);
|
2026-10-17 04:02:20 +02:00
|
|
|
serverDays = await AdminService.ServerDays(days);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// the client API's requests of a day, those of one status class (":4xx") or all
|
|
|
|
|
static long ClientRequests(ViewServerDay day, string statusClass) =>
|
|
|
|
|
day.Client.Where(c => c.Key.EndsWith("xx", StringComparison.Ordinal) && (statusClass is null || c.Key.EndsWith(statusClass, StringComparison.Ordinal)))
|
|
|
|
|
.Sum(c => c.Value);
|
|
|
|
|
|
|
|
|
|
async Task ReloadEvents()
|
|
|
|
|
{
|
|
|
|
|
events = [];
|
|
|
|
|
hasMoreEvents = false;
|
|
|
|
|
await LoadEvents();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async Task LoadEvents()
|
|
|
|
|
{
|
|
|
|
|
isLoadingMore = true;
|
|
|
|
|
var page = await AdminService.Events(eventsHost?.Trim(), eventsChannel, eventsOutcome, null, events.LastOrDefault()?.At, EventsPageSize);
|
|
|
|
|
isLoadingMore = false;
|
|
|
|
|
events.AddRange(page);
|
|
|
|
|
hasMoreEvents = page.Count == EventsPageSize;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async Task MoreEvents() => await LoadEvents();
|
|
|
|
|
|
|
|
|
|
async Task ChangeChannel(string value)
|
|
|
|
|
{
|
|
|
|
|
eventsChannel = value;
|
|
|
|
|
await ReloadEvents();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async Task ChangeOutcome(string value)
|
|
|
|
|
{
|
|
|
|
|
eventsOutcome = value;
|
|
|
|
|
await ReloadEvents();
|
2026-10-05 06:20:18 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static string Ms(int? value) => value?.ToString() ?? "—";
|
|
|
|
|
|
|
|
|
|
async Task ChangeDays(int value)
|
|
|
|
|
{
|
|
|
|
|
days = value;
|
|
|
|
|
page = 1;
|
2026-10-17 04:02:20 +02:00
|
|
|
await LoadDays();
|
2026-10-05 06:20:18 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async Task ChangeSort(string value)
|
|
|
|
|
{
|
|
|
|
|
sort = value;
|
|
|
|
|
page = 1;
|
|
|
|
|
hosts = await AdminService.Hosts(days, sort, page);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async Task ChangePage(int value)
|
|
|
|
|
{
|
|
|
|
|
page = value;
|
|
|
|
|
hosts = await AdminService.Hosts(days, sort, page);
|
|
|
|
|
}
|
|
|
|
|
}
|