R4 server pieces: typed admin statistics, DELETE media, accounts by ids, hashtag history in search
Build / Build (push) Successful in 32s
Build / Build (push) Successful in 32s
Completion program R4 (the client catching up), the server's part.
- /clientapi/admin/statistics/hosts/{host}, /events and /server answer typed views (ViewHostDetail, ViewHostInstance,
ViewHostDay, ViewInteractionEvent, ViewServerDay in ClientModels) instead of an anonymous object and raw entities:
the instance's public description, never its raw instance API document; the events without their actor hash. The
JSON names stay, so lib/interop.sh's stats_check reads them as before.
- DELETE /api/v1/media/{id} (Mastodon 4.4): a persona's own upload goes to the trash while nothing holds it; a posted
or scheduled one answers 422.
- GET /api/v1/accounts?id[]= (Mastodon 4.3): up to 40 accounts, those that exist.
- /api/v2/search's hashtags carry their week of use (TagsController.History), not an empty history.
Verified: dotnet build PrivaPub.sln -c Release; the pasture: gts 64/64 (statistics included); by hand, an unattached
upload deleted (then 404), a posted one refused (422), accounts by ids, a hashtag's history.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
This commit is contained in:
1 parent
1f3bb27e17
commit
f67e2ae35e
5 files changed
+155
-12
No files matched your search
@@ -96,4 +96,96 @@ namespace PrivaPub.ClientModels.Admin
|
|||||||
public string CrawlError { get; set; }
|
public string CrawlError { get; set; }
|
||||||
public string Software { get; set; }
|
public string Software { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// /clientapi/admin/statistics/hosts/{host}: one server, what it says of itself, its days and its latest events
|
||||||
|
public class ViewHostDetail
|
||||||
|
{
|
||||||
|
public string Host { get; set; }
|
||||||
|
public ViewHostInstance Instance { get; set; }
|
||||||
|
public List<ViewHostDay> Days { get; set; } = [];
|
||||||
|
public List<ViewInteractionEvent> Events { get; set; } = [];
|
||||||
|
public string Attribution { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ViewHostInstance
|
||||||
|
{
|
||||||
|
public string Software { get; set; }
|
||||||
|
public string SoftwareVersion { get; set; }
|
||||||
|
public string NodeName { get; set; }
|
||||||
|
public string Title { get; set; }
|
||||||
|
public string NodeDescription { get; set; }
|
||||||
|
public bool? OpenRegistrations { get; set; }
|
||||||
|
public string RegistrationMode { get; set; }
|
||||||
|
public long? UsersTotal { get; set; }
|
||||||
|
public long? UsersActiveMonth { get; set; }
|
||||||
|
public long? LocalPosts { get; set; }
|
||||||
|
public List<string> Languages { get; set; } = [];
|
||||||
|
public string Seen { get; set; }
|
||||||
|
public DateTime? FirstSeenAt { get; set; }
|
||||||
|
public DateTime? LastSeenAt { get; set; }
|
||||||
|
public DateTime? DescribedAt { get; set; }
|
||||||
|
public string DescriptionError { get; set; }
|
||||||
|
public DateTime? LastSuccessAt { get; set; }
|
||||||
|
public DateTime? LastFailureAt { get; set; }
|
||||||
|
public string LastError { get; set; }
|
||||||
|
public int ConsecutiveFailures { get; set; }
|
||||||
|
public DateTime? UnavailableUntil { get; set; }
|
||||||
|
public string Country { get; set; }
|
||||||
|
public string City { get; set; }
|
||||||
|
public int? Asn { get; set; }
|
||||||
|
public string AsnOrg { get; set; }
|
||||||
|
public string Cdn { get; set; }
|
||||||
|
public string InstanceApi { get; set; }
|
||||||
|
public string SourceUrl { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ViewHostDay
|
||||||
|
{
|
||||||
|
public DateTime Day { get; set; }
|
||||||
|
public Dictionary<string, long> Counters { get; set; } = new();
|
||||||
|
public Dictionary<string, long> Latency { get; set; } = new();
|
||||||
|
public Dictionary<string, long> Bytes { get; set; } = new();
|
||||||
|
public Dictionary<string, long> Reads { get; set; } = new();
|
||||||
|
public int Accounts { get; set; }
|
||||||
|
public bool Live { get; set; }//today's or a day not rolled up yet, folded from its events
|
||||||
|
}
|
||||||
|
|
||||||
|
// one ledger entry: what was received, delivered or fetched, and how it ended; never who
|
||||||
|
public class ViewInteractionEvent
|
||||||
|
{
|
||||||
|
public DateTime At { get; set; }
|
||||||
|
public string Host { get; set; }
|
||||||
|
public string Channel { get; set; }
|
||||||
|
public string Activity { get; set; }
|
||||||
|
public string Object { get; set; }
|
||||||
|
public string Purpose { get; set; }
|
||||||
|
public string Trigger { get; set; }
|
||||||
|
public string Outcome { get; set; }
|
||||||
|
public string Reason { get; set; }
|
||||||
|
public int? Status { get; set; }
|
||||||
|
public int? LatencyMs { get; set; }
|
||||||
|
public int? WaitMs { get; set; }
|
||||||
|
public long? Bytes { get; set; }
|
||||||
|
public int? Attempt { get; set; }
|
||||||
|
public string Audience { get; set; }
|
||||||
|
public string LocalKind { get; set; }
|
||||||
|
public string Inbox { get; set; }
|
||||||
|
public string Signature { get; set; }
|
||||||
|
public List<string> Features { get; set; } = [];
|
||||||
|
public long? AgeSeconds { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// /clientapi/admin/statistics/server: the server's own day, its client API and the documents it served
|
||||||
|
public class ViewServerDay
|
||||||
|
{
|
||||||
|
public DateTime Day { get; set; }
|
||||||
|
public Dictionary<string, long> Client { get; set; } = new();
|
||||||
|
public Dictionary<string, long> ClientLatency { get; set; } = new();
|
||||||
|
public Dictionary<string, long> Served { get; set; } = new();
|
||||||
|
public long LedgerWritten { get; set; }
|
||||||
|
public long LedgerDropped { get; set; }
|
||||||
|
public long LedgerFailed { get; set; }
|
||||||
|
public int Accounts { get; set; }
|
||||||
|
public int Hosts { get; set; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -181,6 +181,17 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
|||||||
? await search.Followed(MyId, Params.Get("q"), Limit(), token)
|
? await search.Followed(MyId, Params.Get("q"), Limit(), token)
|
||||||
: await search.Find(Params.Get("q"), Params.Bool("resolve") == true && MyId != default, Limit(), token));
|
: await search.Find(Params.Get("q"), Params.Bool("resolve") == true && MyId != default, Limit(), token));
|
||||||
|
|
||||||
|
// Mastodon 4.3: several accounts at once (at most 40), those that exist, in no promised order
|
||||||
|
[HttpGet("/api/v1/accounts"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||||
|
public async Task<IActionResult> Many(CancellationToken token)
|
||||||
|
{
|
||||||
|
var accounts = new List<object>();
|
||||||
|
foreach (var id in Params.List("id").Distinct().Take(40))
|
||||||
|
if (await _mapper.Account(id, token) is { } account)
|
||||||
|
accounts.Add(account);
|
||||||
|
return Json(accounts);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("/api/v1/accounts/{id}"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
[HttpGet("/api/v1/accounts/{id}"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||||
public async Task<IActionResult> Get(string id, CancellationToken token)
|
public async Task<IActionResult> Get(string id, CancellationToken token)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -64,6 +64,20 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mastodon 4.4: a persona's own upload, while nothing holds it (not posted, not scheduled, not a profile picture), goes to
|
||||||
|
// the trash as the janitor would take it
|
||||||
|
[HttpDelete("/api/v1/media/{id}"), Scope("write:media")]
|
||||||
|
public async Task<IActionResult> Delete(string id, CancellationToken token)
|
||||||
|
{
|
||||||
|
var attachment = await DB.Default.Find<MediaAttachment>().Match(m => m.ID == id && m.OwnerAvatarId == MyId && m.TrashedAt == null && m.ProfileOfAvatarId == null).ExecuteFirstAsync(token);
|
||||||
|
if (attachment == default)
|
||||||
|
return NotFoundError();
|
||||||
|
if (attachment.PostId != null || attachment.ScheduledStatusId != null)
|
||||||
|
return Error(StatusCodes.Status422UnprocessableEntity, "Media attachment is currently used by a status");
|
||||||
|
await _media.Trash(m => m.ID == attachment.ID && m.PostId == null && m.ScheduledStatusId == null, "deleted by its owner", token);
|
||||||
|
return Json(new { });
|
||||||
|
}
|
||||||
|
|
||||||
// as on Mastodon, only media not posted yet: a posted one's description and focus change by editing its post (media_attributes)
|
// as on Mastodon, only media not posted yet: a posted one's description and focus change by editing its post (media_attributes)
|
||||||
[HttpPut("/api/v1/media/{id}"), Scope("write:media")]
|
[HttpPut("/api/v1/media/{id}"), Scope("write:media")]
|
||||||
public async Task<IActionResult> Update(string id, CancellationToken token)
|
public async Task<IActionResult> Update(string id, CancellationToken token)
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
|||||||
if (type is null or "statuses" && MyId != default && !q.StartsWith('#') && !Handle().IsMatch(q))
|
if (type is null or "statuses" && MyId != default && !q.StartsWith('#') && !Handle().IsMatch(q))
|
||||||
results.Statuses = await Words(q, Math.Max(0, offset), Limit(), token);
|
results.Statuses = await Words(q, Math.Max(0, offset), Limit(), token);
|
||||||
if (type is null or "hashtags" && TagsController.Normalise(q) is var tag && TagsController.IsHashtag(tag))
|
if (type is null or "hashtags" && TagsController.Normalise(q) is var tag && TagsController.IsHashtag(tag))
|
||||||
results.Hashtags.Add(new { name = tag, url = $"{_localActors.BaseAddress}/tags/{Uri.EscapeDataString(tag)}", history = Array.Empty<object>() });
|
results.Hashtags.Add(new { name = tag, url = $"{_localActors.BaseAddress}/tags/{Uri.EscapeDataString(tag)}", history = await TagsController.History(_dbEntities, tag, token) });
|
||||||
return Json(results);
|
return Json(results);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ namespace PrivaPub.Domain.Statistics
|
|||||||
return new ViewHostsPage { Total = list.Count, Page = Math.Max(page, 1), Hosts = list.Skip((Math.Max(page, 1) - 1) * limit).Take(limit).ToList(), Attribution = Attribution };
|
return new ViewHostsPage { Total = list.Count, Page = Math.Max(page, 1), Hosts = list.Skip((Math.Max(page, 1) - 1) * limit).Take(limit).ToList(), Attribution = Attribution };
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<object> Host(string host, int days, CancellationToken token)
|
public async Task<ViewHostDetail> Host(string host, int days, CancellationToken token)
|
||||||
{
|
{
|
||||||
host = Interactions.Host(host);
|
host = Interactions.Host(host);
|
||||||
var instance = await DB.Default.Find<RemoteInstance>().Match(i => i.Host == host).ExecuteFirstAsync(token);
|
var instance = await DB.Default.Find<RemoteInstance>().Match(i => i.Host == host).ExecuteFirstAsync(token);
|
||||||
@@ -136,20 +136,41 @@ namespace PrivaPub.Domain.Statistics
|
|||||||
var events = await DB.Default.Find<InteractionEvent>().Match(e => e.Host == host).Sort(e => e.At, Order.Descending).Limit(100).ExecuteAsync(token);
|
var events = await DB.Default.Find<InteractionEvent>().Match(e => e.Host == host).Sort(e => e.At, Order.Descending).Limit(100).ExecuteAsync(token);
|
||||||
if (instance == default && hostDays.Count == 0 && events.Count == 0)
|
if (instance == default && hostDays.Count == 0 && events.Count == 0)
|
||||||
return default;
|
return default;
|
||||||
return new
|
return new ViewHostDetail
|
||||||
{
|
{
|
||||||
Host = host,
|
Host = host,
|
||||||
Instance = instance,
|
Instance = instance == default ? default : new ViewHostInstance
|
||||||
Days = hostDays.Select(d => new { d.Day, d.Counters, d.Latency, d.Bytes, d.Reads, d.Accounts, d.Live }),
|
{
|
||||||
Events = events,
|
Software = instance.Software, SoftwareVersion = instance.SoftwareVersion, NodeName = instance.NodeName, Title = instance.Title,
|
||||||
Attribution
|
NodeDescription = instance.NodeDescription, OpenRegistrations = instance.OpenRegistrations, RegistrationMode = instance.RegistrationMode,
|
||||||
|
UsersTotal = instance.UsersTotal, UsersActiveMonth = instance.UsersActiveMonth, LocalPosts = instance.LocalPosts,
|
||||||
|
Languages = instance.Languages ?? [], Seen = instance.Seen, FirstSeenAt = instance.FirstSeenAt, LastSeenAt = instance.LastSeenAt,
|
||||||
|
DescribedAt = instance.DescribedAt, DescriptionError = instance.DescriptionError, LastSuccessAt = instance.LastSuccessAt,
|
||||||
|
LastFailureAt = instance.LastFailureAt, LastError = instance.LastError, ConsecutiveFailures = instance.ConsecutiveFailures,
|
||||||
|
UnavailableUntil = instance.UnavailableUntil, Country = instance.Geo?.Country, City = instance.Geo?.City, Asn = instance.Geo?.Asn,
|
||||||
|
AsnOrg = instance.Geo?.AsnOrg, Cdn = instance.Geo?.Cdn, InstanceApi = instance.InstanceApi, SourceUrl = instance.SourceUrl
|
||||||
|
},
|
||||||
|
Days = hostDays.Select(d => new ViewHostDay
|
||||||
|
{
|
||||||
|
Day = d.Day, Counters = new(d.Counters), Latency = new(d.Latency), Bytes = new(d.Bytes), Reads = new(d.Reads ?? new Dictionary<string, long>()),
|
||||||
|
Accounts = d.Accounts, Live = d.Live
|
||||||
|
}).ToList(),
|
||||||
|
Events = events.Select(View).ToList(),
|
||||||
|
Attribution = Attribution
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<InteractionEvent>> Events(string host, string channel, string outcome, string reason, DateTime? before, int limit, CancellationToken token)
|
static ViewInteractionEvent View(InteractionEvent e) => new()
|
||||||
|
{
|
||||||
|
At = e.At, Host = e.Host, Channel = e.Channel, Activity = e.Activity, Object = e.Object, Purpose = e.Purpose, Trigger = e.Trigger,
|
||||||
|
Outcome = e.Outcome, Reason = e.Reason, Status = e.Status, LatencyMs = e.LatencyMs, WaitMs = e.WaitMs, Bytes = e.Bytes, Attempt = e.Attempt,
|
||||||
|
Audience = e.Audience, LocalKind = e.LocalKind, Inbox = e.Inbox, Signature = e.Signature, Features = e.Features ?? [], AgeSeconds = e.AgeSeconds
|
||||||
|
};
|
||||||
|
|
||||||
|
public async Task<List<ViewInteractionEvent>> Events(string host, string channel, string outcome, string reason, DateTime? before, int limit, CancellationToken token)
|
||||||
{
|
{
|
||||||
var hostName = host == default ? default : Interactions.Host(host);
|
var hostName = host == default ? default : Interactions.Host(host);
|
||||||
return await DB.Default.Find<InteractionEvent>()
|
return (await DB.Default.Find<InteractionEvent>()
|
||||||
.Match(e => hostName == null || e.Host == hostName)
|
.Match(e => hostName == null || e.Host == hostName)
|
||||||
.Match(e => channel == null || e.Channel == channel)
|
.Match(e => channel == null || e.Channel == channel)
|
||||||
.Match(e => outcome == null || e.Outcome == outcome)
|
.Match(e => outcome == null || e.Outcome == outcome)
|
||||||
@@ -157,7 +178,7 @@ namespace PrivaPub.Domain.Statistics
|
|||||||
.Match(e => before == null || e.At < before)
|
.Match(e => before == null || e.At < before)
|
||||||
.Sort(e => e.At, Order.Descending)
|
.Sort(e => e.At, Order.Descending)
|
||||||
.Limit(Math.Clamp(limit, 1, 200))
|
.Limit(Math.Clamp(limit, 1, 200))
|
||||||
.ExecuteAsync(token);
|
.ExecuteAsync(token)).Select(View).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ViewCrawler> Crawler(bool enabled, int revisitDays, CancellationToken token)
|
public async Task<ViewCrawler> Crawler(bool enabled, int revisitDays, CancellationToken token)
|
||||||
@@ -183,10 +204,15 @@ namespace PrivaPub.Domain.Statistics
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<ServerDay>> Server(int days, CancellationToken token)
|
public async Task<List<ViewServerDay>> Server(int days, CancellationToken token)
|
||||||
{
|
{
|
||||||
var since = DateTime.UtcNow.Date.AddDays(1 - Math.Clamp(days, 1, 3660));
|
var since = DateTime.UtcNow.Date.AddDays(1 - Math.Clamp(days, 1, 3660));
|
||||||
return await DB.Default.Find<ServerDay>().Match(d => d.Day >= since).Sort(d => d.Day, Order.Ascending).ExecuteAsync(token);
|
return (await DB.Default.Find<ServerDay>().Match(d => d.Day >= since).Sort(d => d.Day, Order.Ascending).ExecuteAsync(token))
|
||||||
|
.Select(d => new ViewServerDay
|
||||||
|
{
|
||||||
|
Day = d.Day, Client = d.Client, ClientLatency = d.ClientLatency, Served = d.Served, LedgerWritten = d.LedgerWritten,
|
||||||
|
LedgerDropped = d.LedgerDropped, LedgerFailed = d.LedgerFailed, Accounts = d.Accounts, Hosts = d.Hosts
|
||||||
|
}).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Dictionary<string, long> Outcomes(IReadOnlyDictionary<string, long> counters, string channel)
|
public static Dictionary<string, long> Outcomes(IReadOnlyDictionary<string, long> counters, string channel)
|
||||||
|
|||||||
Reference in new issue
Block a user