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 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.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]
|
||||
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)
|
||||
[HttpPut("/api/v1/media/{id}"), Scope("write:media")]
|
||||
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))
|
||||
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))
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
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);
|
||||
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);
|
||||
if (instance == default && hostDays.Count == 0 && events.Count == 0)
|
||||
return default;
|
||||
return new
|
||||
return new ViewHostDetail
|
||||
{
|
||||
Host = host,
|
||||
Instance = instance,
|
||||
Days = hostDays.Select(d => new { d.Day, d.Counters, d.Latency, d.Bytes, d.Reads, d.Accounts, d.Live }),
|
||||
Events = events,
|
||||
Attribution
|
||||
Instance = instance == default ? default : new ViewHostInstance
|
||||
{
|
||||
Software = instance.Software, SoftwareVersion = instance.SoftwareVersion, NodeName = instance.NodeName, Title = instance.Title,
|
||||
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);
|
||||
return await DB.Default.Find<InteractionEvent>()
|
||||
return (await DB.Default.Find<InteractionEvent>()
|
||||
.Match(e => hostName == null || e.Host == hostName)
|
||||
.Match(e => channel == null || e.Channel == channel)
|
||||
.Match(e => outcome == null || e.Outcome == outcome)
|
||||
@@ -157,7 +178,7 @@ namespace PrivaPub.Domain.Statistics
|
||||
.Match(e => before == null || e.At < before)
|
||||
.Sort(e => e.At, Order.Descending)
|
||||
.Limit(Math.Clamp(limit, 1, 200))
|
||||
.ExecuteAsync(token);
|
||||
.ExecuteAsync(token)).Select(View).ToList();
|
||||
}
|
||||
|
||||
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));
|
||||
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)
|
||||
|
||||
Reference in new issue
Block a user