chore: initial project import
This commit is contained in:
commit
26620707a0
347 files changed
+56172
No files matched your search
@@ -0,0 +1,213 @@
|
||||
using Fleet.Core;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Monitor.Backend.Application;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Api;
|
||||
|
||||
/// <summary>Agent 管理:列表、修改、审批、启停、接管、删除;加入令牌;Probe 身份。</summary>
|
||||
public static class AgentEndpoints
|
||||
{
|
||||
private const string AgentsTag = "admin-agents";
|
||||
private const string TokensTag = "admin-join-tokens";
|
||||
private const string ProbeTag = "admin-probe";
|
||||
|
||||
public static void Map(RouteGroupBuilder group)
|
||||
{
|
||||
group.MapGet("/agents", ListAsync)
|
||||
.Api("agents_list", "All agents including pending and disabled ones", AgentsTag).Returns<PageDto<AgentDto>>().ProducesProblems(401, 403);
|
||||
group.MapGet("/agents/{id}", GetAsync)
|
||||
.Api("agents_get", "One agent", AgentsTag).Returns<AgentDto>().ProducesProblems(401, 403, 404);
|
||||
group.MapPatch("/agents/{id}", PatchAsync)
|
||||
.Accepts<AgentPatchRequest>("application/merge-patch+json", "application/json")
|
||||
.Api("agents_update", "Update name, labels, public host or visibility (merge-patch, If-Match supported)", AgentsTag)
|
||||
.Returns<AgentDto>().ProducesProblems(400, 401, 403, 404, 409, 412);
|
||||
group.MapPost("/agents/{id}/approve", (HttpContext http, AgentService agents, string id) => TransitionAsync(http, id, (nodeId, ct) => agents.ApproveAsync(nodeId, ct)))
|
||||
.Api("agents_approve", "Approve a pending agent", AgentsTag).Returns<AgentDto>().ProducesProblems(401, 403, 404, 409);
|
||||
group.MapPost("/agents/{id}/disable", (HttpContext http, AgentService agents, string id) => TransitionAsync(http, id, (nodeId, ct) => agents.DisableAsync(nodeId, ct)))
|
||||
.Api("agents_disable", "Disable an agent and drop its connection", AgentsTag).Returns<AgentDto>().ProducesProblems(401, 403, 404, 409);
|
||||
group.MapPost("/agents/{id}/enable", (HttpContext http, AgentService agents, string id) => TransitionAsync(http, id, (nodeId, ct) => agents.EnableAsync(nodeId, ct)))
|
||||
.Api("agents_enable", "Enable a disabled agent", AgentsTag).Returns<AgentDto>().ProducesProblems(401, 403, 404, 409);
|
||||
group.MapPost("/agents/{id}/takeover", (HttpContext http, AgentService agents, string id) => TransitionAsync(http, id, (nodeId, ct) => agents.TakeoverAsync(nodeId, ct)))
|
||||
.Api("agents_takeover", "Replace the existing node of the same name with this pending one, keeping history", AgentsTag)
|
||||
.Returns<AgentDto>().ProducesProblems(401, 403, 404);
|
||||
group.MapDelete("/agents/{id}", DeleteAsync)
|
||||
.Api("agents_delete", "Delete an agent; history expires with the retention policy", AgentsTag)
|
||||
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403);
|
||||
|
||||
group.MapGet("/join-tokens", ListTokensAsync)
|
||||
.Api("join_tokens_list", "Join tokens", TokensTag).Returns<PageDto<JoinTokenDto>>().ProducesProblems(401, 403);
|
||||
group.MapPost("/join-tokens", CreateTokenAsync)
|
||||
.Api("join_tokens_create", "Create a join token; the plaintext is returned only once", TokensTag)
|
||||
.Returns<CreatedJoinTokenDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403);
|
||||
group.MapDelete("/join-tokens/{id}", RevokeTokenAsync)
|
||||
.Api("join_tokens_revoke", "Revoke a join token", TokensTag)
|
||||
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403);
|
||||
|
||||
group.MapGet("/probes", ListProbesAsync)
|
||||
.Api("probes_list", "All probes, with the agents each one is connected to", ProbeTag).Returns<PageDto<ProbeDto>>().ProducesProblems(401, 403);
|
||||
group.MapPost("/probes", CreateProbeAsync)
|
||||
.Api("probes_create", "Create a probe identity; the private key is returned only once", ProbeTag)
|
||||
.Returns<ProbeIdentityDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409);
|
||||
group.MapGet("/probes/{id}", GetProbeAsync)
|
||||
.Api("probes_get", "One probe", ProbeTag).Returns<ProbeDto>().ProducesProblems(401, 403, 404);
|
||||
group.MapPatch("/probes/{id}", PatchProbeAsync)
|
||||
.Api("probes_update", "Rename a probe", ProbeTag).Returns<ProbeDto>().ProducesProblems(400, 401, 403, 404, 409);
|
||||
group.MapPost("/probes/{id}/rotate", RotateProbeAsync)
|
||||
.Api("probes_rotate", "Give a probe a new key (same id): the old key stops working and its sessions are dropped; the new probe.json is returned only once", ProbeTag)
|
||||
.Returns<ProbeIdentityDto>().ProducesProblems(401, 403, 404, 409);
|
||||
group.MapPost("/probes/{id}/revoke", RevokeProbeAsync)
|
||||
.Api("probes_revoke", "Revoke a probe: every agent drops its session and refuses it; history stays until it expires", ProbeTag)
|
||||
.Returns<ProbeDto>().ProducesProblems(401, 403, 404);
|
||||
group.MapDelete("/probes/{id}", DeleteProbeAsync)
|
||||
.Api("probes_delete", "Delete a probe and its lines; stored data expires with the retention policy", ProbeTag)
|
||||
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403);
|
||||
group.MapPost("/agents/{id}/probes/{probe_id}/disconnect", DisconnectProbeAsync)
|
||||
.Api("agents_probe_disconnect", "Make an agent drop one probe's session (the probe reconnects unless it is revoked)", AgentsTag)
|
||||
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403, 404, 409);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListAsync(AgentService agents, CancellationToken ct) =>
|
||||
TypedResults.Json(new PageDto<AgentDto>([.. (await agents.ListAsync(ct)).Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoAgentDto);
|
||||
|
||||
private static async Task<IResult> GetAsync(HttpContext http, AgentService agents, string id, CancellationToken ct)
|
||||
{
|
||||
AgentView? view = await agents.GetAsync(new NodeId(id), ct);
|
||||
if (view is null)
|
||||
{
|
||||
return Problems.Create(http, MonitorErrors.AgentNotFound, "agent not found");
|
||||
}
|
||||
|
||||
Concurrency.SetETag(http, view.Agent.RowVersion);
|
||||
return TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.AgentDto);
|
||||
}
|
||||
|
||||
private static async Task<IResult> PatchAsync(HttpContext http, AgentService agents, AgentPatchRequest request, string id, CancellationToken ct)
|
||||
{
|
||||
Result<AgentView> result = await agents.PatchAsync(
|
||||
new NodeId(id), new AgentPatch { Name = request.Name, Labels = request.Labels, PublicHost = request.PublicHost, PublicVisible = request.PublicVisible }, Concurrency.ExpectedRowVersion(http), ct);
|
||||
if (!result.TryGet(out AgentView? view, out FleetError? error))
|
||||
{
|
||||
return Problems.From(http, error);
|
||||
}
|
||||
|
||||
Concurrency.SetETag(http, view.Agent.RowVersion);
|
||||
return TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.AgentDto);
|
||||
}
|
||||
|
||||
private static async Task<IResult> TransitionAsync(HttpContext http, string id, Func<NodeId, CancellationToken, ValueTask<Result<AgentView>>> action)
|
||||
{
|
||||
Result<AgentView> result = await action(new NodeId(id), http.RequestAborted);
|
||||
if (!result.TryGet(out AgentView? view, out FleetError? error))
|
||||
{
|
||||
return Problems.From(http, error);
|
||||
}
|
||||
|
||||
Concurrency.SetETag(http, view.Agent.RowVersion);
|
||||
return TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.AgentDto);
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteAsync(AgentService agents, string id, CancellationToken ct)
|
||||
{
|
||||
await agents.DeleteAsync(new NodeId(id), ct);
|
||||
return TypedResults.NoContent();
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListTokensAsync(JoinTokenService tokens, CancellationToken ct) =>
|
||||
TypedResults.Json(new PageDto<JoinTokenDto>([.. (await tokens.ListAsync(ct)).Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoJoinTokenDto);
|
||||
|
||||
private static async Task<IResult> CreateTokenAsync(HttpContext http, JoinTokenService tokens, CreateJoinTokenRequest request, BackendOptions options)
|
||||
{
|
||||
string baseUrl = options.PublicUrl ?? $"{http.Request.Scheme}://{http.Request.Host}";
|
||||
Result<CreatedJoinToken> result = await tokens.CreateAsync(
|
||||
new Application.CreateJoinTokenRequest(request.Note, request.Labels, request.AutoApprove ?? true, request.MaxUses ?? 1, request.ExpiresInHours ?? 24), baseUrl, http.RequestAborted);
|
||||
if (!result.TryGet(out CreatedJoinToken? created, out FleetError? error))
|
||||
{
|
||||
return Problems.From(http, error);
|
||||
}
|
||||
|
||||
http.Response.Headers.CacheControl = "no-store";
|
||||
http.Response.Headers.Location = "/api/admin/join-tokens";
|
||||
return TypedResults.Json(new CreatedJoinTokenDto(Mappers.ToDto(created.Info), created.Plaintext, created.InstallCommand), ApiJsonContext.Default.CreatedJoinTokenDto, statusCode: StatusCodes.Status201Created);
|
||||
}
|
||||
|
||||
private static async Task<IResult> RevokeTokenAsync(JoinTokenService tokens, string id, CancellationToken ct)
|
||||
{
|
||||
await tokens.RevokeAsync(id, ct);
|
||||
return TypedResults.NoContent();
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListProbesAsync(ProbeService probes, CancellationToken ct) =>
|
||||
TypedResults.Json(new PageDto<ProbeDto>([.. (await probes.ListAsync(ct)).Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoProbeDto);
|
||||
|
||||
private static async Task<IResult> CreateProbeAsync(HttpContext http, ProbeService probes, CreateProbeRequest? request, CancellationToken ct)
|
||||
{
|
||||
Result<ProbeIdentityFile> result = await probes.CreateAsync(request?.Name, ct);
|
||||
if (!result.TryGet(out ProbeIdentityFile? file, out FleetError? error))
|
||||
{
|
||||
return Problems.From(http, error);
|
||||
}
|
||||
|
||||
http.Response.Headers.CacheControl = "no-store";
|
||||
http.Response.Headers.Location = "/api/admin/probes/" + file.ProbeId.Value;
|
||||
return TypedResults.Json(ToIdentityDto(file), ApiJsonContext.Default.ProbeIdentityDto, statusCode: StatusCodes.Status201Created);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetProbeAsync(HttpContext http, ProbeService probes, string id, CancellationToken ct) =>
|
||||
await probes.GetAsync(id, ct) is { } view
|
||||
? TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.ProbeDto)
|
||||
: Problems.Create(http, MonitorErrors.ProbeNotFound, "probe not found");
|
||||
|
||||
private static async Task<IResult> PatchProbeAsync(HttpContext http, ProbeService probes, PatchProbeRequest request, string id, CancellationToken ct)
|
||||
{
|
||||
if (request.Name is null)
|
||||
{
|
||||
return Problems.From(http, ValidationError.Single("name", "is required"));
|
||||
}
|
||||
|
||||
Result<ProbeView> result = await probes.RenameAsync(id, request.Name, ct);
|
||||
return result.TryGet(out ProbeView? view, out FleetError? error) ? TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.ProbeDto) : Problems.From(http, error);
|
||||
}
|
||||
|
||||
private static async Task<IResult> RotateProbeAsync(HttpContext http, ProbeService probes, string id, CancellationToken ct)
|
||||
{
|
||||
Result<ProbeIdentityFile> result = await probes.RotateAsync(id, ct);
|
||||
if (!result.TryGet(out ProbeIdentityFile? file, out FleetError? error))
|
||||
{
|
||||
return Problems.From(http, error);
|
||||
}
|
||||
|
||||
http.Response.Headers.CacheControl = "no-store";
|
||||
return TypedResults.Json(ToIdentityDto(file), ApiJsonContext.Default.ProbeIdentityDto);
|
||||
}
|
||||
|
||||
private static async Task<IResult> RevokeProbeAsync(HttpContext http, ProbeService probes, string id, CancellationToken ct)
|
||||
{
|
||||
Result<ProbeView> result = await probes.RevokeAsync(id, ct);
|
||||
return result.TryGet(out ProbeView? view, out FleetError? error) ? TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.ProbeDto) : Problems.From(http, error);
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteProbeAsync(ProbeService probes, string id, CancellationToken ct)
|
||||
{
|
||||
await probes.DeleteAsync(id, ct);
|
||||
return TypedResults.NoContent();
|
||||
}
|
||||
|
||||
private static async Task<IResult> DisconnectProbeAsync(HttpContext http, ProbeService probes, string id, string probe_id, CancellationToken ct)
|
||||
{
|
||||
Result<bool> result = await probes.DisconnectAsync(probe_id, new NodeId(id), ct);
|
||||
if (!result.TryGet(out bool dropped, out FleetError? error))
|
||||
{
|
||||
return Problems.From(http, error);
|
||||
}
|
||||
|
||||
return dropped ? TypedResults.NoContent() : Problems.Create(http, MonitorErrors.ProbeOffline, "this probe has no session with this agent");
|
||||
}
|
||||
|
||||
private static ProbeIdentityDto ToIdentityDto(ProbeIdentityFile file) =>
|
||||
new(
|
||||
file.ProbeId.Value, file.PrivateKey, [.. file.Bootstrap.Select(b => new BootstrapAgentDto(b.AgentId.Value, b.Host, b.TcpPort, b.Spki))],
|
||||
new ProbeLimitsDto(file.Limits.MaxTestMbps, file.Limits.MaxTotalPps));
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using Fleet.Core;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Monitor.Backend.Application;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Api;
|
||||
|
||||
/// <summary>登录、退出、当前身份。登录接口限流,失败一律返回 401,不区分“用户不存在”和“密码错误”。</summary>
|
||||
public static class AuthEndpoints
|
||||
{
|
||||
private const string Tag = "admin-auth";
|
||||
|
||||
/// <summary>登录不需要认证:挂在没有守卫的分组上。</summary>
|
||||
public static void MapLogin(RouteGroupBuilder open)
|
||||
{
|
||||
open.MapPost("/auth/login", LoginAsync)
|
||||
.Api("auth_login", "Sign in with a username and password", Tag)
|
||||
.Returns<LoginResponse>().ProducesProblems(400, 401, 429);
|
||||
}
|
||||
|
||||
public static void MapSession(RouteGroupBuilder guarded)
|
||||
{
|
||||
guarded.MapPost("/auth/logout", LogoutAsync)
|
||||
.Api("auth_logout", "Sign out and clear the session cookie", Tag)
|
||||
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401);
|
||||
guarded.MapGet("/auth/me", Me)
|
||||
.Api("auth_me", "The current identity", Tag)
|
||||
.Returns<MeDto>().ProducesProblems(401);
|
||||
}
|
||||
|
||||
private static async Task<IResult> LoginAsync(HttpContext http, LoginRequest request, AuthService auth, LoginRateLimiter limiter, BackendOptions options)
|
||||
{
|
||||
string source = http.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||
if (!limiter.TryAcquire(source, out int retryAfter))
|
||||
{
|
||||
return Problems.RetryAfter(http, new FleetError(MonitorErrors.TooManyRequests, "too many sign-in attempts"), retryAfter);
|
||||
}
|
||||
|
||||
Result<LoginResult> result = await auth.LoginAsync(request.Username ?? "", request.Password ?? "", http.RequestAborted);
|
||||
if (!result.TryGet(out LoginResult? login, out FleetError? error))
|
||||
{
|
||||
return Problems.From(http, error);
|
||||
}
|
||||
|
||||
http.Response.Cookies.Append(
|
||||
PrincipalResolver.SessionCookie,
|
||||
login.SessionToken,
|
||||
new CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
Secure = options.SecureCookies,
|
||||
SameSite = SameSiteMode.Strict,
|
||||
Path = "/",
|
||||
Expires = DateTimeOffset.FromUnixTimeMilliseconds(login.ExpiresAtMs),
|
||||
});
|
||||
http.Response.Headers.CacheControl = "no-store";
|
||||
return TypedResults.Json(new LoginResponse(login.UserId, login.Username, login.CsrfToken, Mappers.Time(login.ExpiresAtMs)), ApiJsonContext.Default.LoginResponse);
|
||||
}
|
||||
|
||||
private static async Task<IResult> LogoutAsync(HttpContext http, AuthService auth)
|
||||
{
|
||||
if (http.Request.Cookies.TryGetValue(PrincipalResolver.SessionCookie, out string? session))
|
||||
{
|
||||
await auth.LogoutAsync(session, http.RequestAborted);
|
||||
}
|
||||
|
||||
http.Response.Cookies.Delete(PrincipalResolver.SessionCookie, new CookieOptions { Path = "/" });
|
||||
return TypedResults.NoContent();
|
||||
}
|
||||
|
||||
private static Microsoft.AspNetCore.Http.HttpResults.JsonHttpResult<MeDto> Me(HttpContext http)
|
||||
{
|
||||
Principal principal = http.GetPrincipal();
|
||||
return TypedResults.Json(
|
||||
new MeDto(principal.Kind.ToString().ToLowerInvariant(), principal.Scope.ToString().ToLowerInvariant(), principal.UserId, principal.Username, principal.CsrfToken),
|
||||
ApiJsonContext.Default.MeDto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using System.Text;
|
||||
using Fleet.Core;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Monitor.Backend.Application;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Api;
|
||||
|
||||
/// <summary>原始数据检索与下载、事件、指标重算进度。</summary>
|
||||
public static class DataEndpoints
|
||||
{
|
||||
private const string Tag = "admin-data";
|
||||
|
||||
public static void Map(RouteGroupBuilder group)
|
||||
{
|
||||
group.MapGet("/raw/chunks", ListChunksAsync)
|
||||
.Api("raw_chunks_list", "Raw chunk metadata", Tag).Returns<PageDto<RawChunkMetaDto>>().ProducesProblems(400, 401, 403);
|
||||
group.MapGet("/raw/chunks/{chunk_id}", DownloadChunkAsync)
|
||||
.Api("raw_chunks_get", "Download a raw chunk (application/vnd.monitor.rawchunk)", Tag)
|
||||
.Produces(StatusCodes.Status200OK, contentType: "application/vnd.monitor.rawchunk").ProducesProblems(401, 403, 404, 410);
|
||||
group.MapGet("/events", ListEventsAsync)
|
||||
.Api("events_list", "Agent events", Tag).Returns<PageDto<EventDto>>().ProducesProblems(400, 401, 403);
|
||||
group.MapGet("/analysis/recompute", RecomputeAsync)
|
||||
.Api("analysis_recompute_get", "Progress of the metric backfill", Tag).Returns<RecomputeDto>().ProducesProblems(401, 403);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListChunksAsync(
|
||||
HttpContext http, RawBrowserService raw, string? agent_id, string? kind, string? test_id, DateTimeOffset? from, DateTimeOffset? to, int? limit, string? cursor)
|
||||
{
|
||||
int size = Math.Clamp(limit ?? 50, 1, 500);
|
||||
long? after = null;
|
||||
if (cursor is not null)
|
||||
{
|
||||
if (!long.TryParse(cursor, out long parsed))
|
||||
{
|
||||
return Problems.From(http, ValidationError.Single("cursor", "is not a valid cursor"));
|
||||
}
|
||||
|
||||
after = parsed;
|
||||
}
|
||||
|
||||
var query = new RawQuery(
|
||||
agent_id is null ? null : new NodeId(agent_id), null, kind, test_id is null ? null : new TestId(test_id),
|
||||
from?.ToUnixTimeMilliseconds(), to?.ToUnixTimeMilliseconds(), after, size + 1);
|
||||
IReadOnlyList<RawChunkMeta> metas = await raw.ListAsync(query, http.RequestAborted);
|
||||
bool more = metas.Count > size;
|
||||
IReadOnlyList<RawChunkMeta> page = [.. metas.Take(size)];
|
||||
|
||||
// 游标按 start_ts 前进:下一页从最后一项的 start_ts + 1 开始(同一毫秒的多个块在同一页内给出)
|
||||
string? next = more ? (page[^1].StartTs + 1).ToString(System.Globalization.CultureInfo.InvariantCulture) : null;
|
||||
return TypedResults.Json(new PageDto<RawChunkMetaDto>([.. page.Select(Mappers.ToDto)], next), ApiJsonContext.Default.PageDtoRawChunkMetaDto);
|
||||
}
|
||||
|
||||
private static async Task<IResult> DownloadChunkAsync(HttpContext http, RawBrowserService raw, string chunk_id)
|
||||
{
|
||||
if (!Guid.TryParse(chunk_id, out Guid id))
|
||||
{
|
||||
return Problems.Create(http, MonitorErrors.ChunkNotFound, "chunk not found");
|
||||
}
|
||||
|
||||
Result<(RawChunkMeta Meta, byte[] Bytes)> result = await raw.GetAsync(new ChunkId(id), http.RequestAborted);
|
||||
if (!result.TryGet(out (RawChunkMeta Meta, byte[] Bytes) found, out FleetError? error))
|
||||
{
|
||||
return Problems.From(http, error);
|
||||
}
|
||||
|
||||
http.Response.Headers.CacheControl = "private, max-age=3600";
|
||||
return TypedResults.File(found.Bytes, "application/vnd.monitor.rawchunk", chunk_id + ".mnrc");
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListEventsAsync(
|
||||
HttpContext http, EventService events, TimeProvider time, string? agent_id, string? kind, DateTimeOffset? from, DateTimeOffset? to, int? limit, string? cursor)
|
||||
{
|
||||
long now = time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
long toMs = to?.ToUnixTimeMilliseconds() ?? now + 1;
|
||||
long fromMs = from?.ToUnixTimeMilliseconds() ?? toMs - Steps.Day;
|
||||
long? before = null;
|
||||
if (cursor is not null)
|
||||
{
|
||||
if (!long.TryParse(cursor, out long parsed))
|
||||
{
|
||||
return Problems.From(http, ValidationError.Single("cursor", "is not a valid cursor"));
|
||||
}
|
||||
|
||||
before = parsed;
|
||||
}
|
||||
|
||||
int size = Math.Clamp(limit ?? 50, 1, 500);
|
||||
IReadOnlyList<EventRecord> rows = await events.ListAsync(agent_id is null ? null : new NodeId(agent_id), kind, fromMs, toMs, before, size + 1, http.RequestAborted);
|
||||
bool more = rows.Count > size;
|
||||
IReadOnlyList<EventRecord> page = [.. rows.Take(size)];
|
||||
string? next = more ? page[^1].Id.ToString(System.Globalization.CultureInfo.InvariantCulture) : null;
|
||||
return TypedResults.Json(new PageDto<EventDto>([.. page.Select(Mappers.ToDto)], next), ApiJsonContext.Default.PageDtoEventDto);
|
||||
}
|
||||
|
||||
private static async Task<IResult> RecomputeAsync(OpsService ops, CancellationToken ct)
|
||||
{
|
||||
RecomputeProgress p = ops.RecomputeProgress;
|
||||
return TypedResults.Json(
|
||||
new RecomputeDto(p.State, p.Metrics, p.WindowsDone, p.WindowsTotal, Mappers.Time(p.FromMs), Mappers.Time(p.ToMs), await ops.QueueLengthAsync(ct)),
|
||||
ApiJsonContext.Default.RecomputeDto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using System.Text.Json;
|
||||
using Fleet.Core;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Monitor.Backend.Application;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Api;
|
||||
|
||||
/// <summary>API 令牌、站点设置与保留期、用户。令牌明文只在创建的响应里出现一次。</summary>
|
||||
public static class SystemEndpoints
|
||||
{
|
||||
private const string TokensTag = "admin-api-tokens";
|
||||
private const string SettingsTag = "admin-settings";
|
||||
private const string UsersTag = "admin-users";
|
||||
|
||||
public static void Map(RouteGroupBuilder group)
|
||||
{
|
||||
group.MapGet("/api-tokens", ListTokensAsync)
|
||||
.Api("api_tokens_list", "API tokens", TokensTag).Returns<PageDto<ApiTokenDto>>().ProducesProblems(401, 403);
|
||||
group.MapPost("/api-tokens", CreateTokenAsync)
|
||||
.Api("api_tokens_create", "Create an API token (scope read or admin); the plaintext is returned only once", TokensTag)
|
||||
.Returns<CreatedApiTokenDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403);
|
||||
group.MapDelete("/api-tokens/{id}", RevokeTokenAsync)
|
||||
.Api("api_tokens_revoke", "Revoke an API token", TokensTag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403);
|
||||
|
||||
group.MapGet("/settings", ListSettingsAsync)
|
||||
.Api("settings_list", "Site settings and retention periods", SettingsTag).Returns<PageDto<SettingDto>>().ProducesProblems(401, 403);
|
||||
group.MapPatch("/settings", PatchSettingsAsync)
|
||||
.Accepts<Dictionary<string, JsonElement>>("application/merge-patch+json", "application/json")
|
||||
.Api("settings_update", "Change settings (merge-patch)", SettingsTag).Returns<PageDto<SettingDto>>().ProducesProblems(400, 401, 403);
|
||||
|
||||
group.MapGet("/users", ListUsersAsync)
|
||||
.Api("users_list", "Panel users", UsersTag).Returns<PageDto<UserDto>>().ProducesProblems(401, 403);
|
||||
group.MapPost("/users", CreateUserAsync)
|
||||
.Api("users_create", "Create a panel user", UsersTag).Returns<UserDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409);
|
||||
group.MapPatch("/users/{id}", ChangePasswordAsync)
|
||||
.Api("users_update", "Change a user's password", UsersTag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(400, 401, 403);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListTokensAsync(AuthService auth, CancellationToken ct) =>
|
||||
TypedResults.Json(new PageDto<ApiTokenDto>([.. (await auth.ListApiTokensAsync(ct)).Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoApiTokenDto);
|
||||
|
||||
private static async Task<IResult> CreateTokenAsync(HttpContext http, AuthService auth, CreateApiTokenRequest request)
|
||||
{
|
||||
Result<CreatedApiToken> result = await auth.CreateApiTokenAsync(request.Name ?? "", request.Scope ?? "", http.RequestAborted);
|
||||
if (!result.TryGet(out CreatedApiToken? created, out FleetError? error))
|
||||
{
|
||||
return Problems.From(http, error);
|
||||
}
|
||||
|
||||
http.Response.Headers.CacheControl = "no-store";
|
||||
return TypedResults.Json(new CreatedApiTokenDto(Mappers.ToDto(created.Record), created.Plaintext), ApiJsonContext.Default.CreatedApiTokenDto, statusCode: StatusCodes.Status201Created);
|
||||
}
|
||||
|
||||
private static async Task<IResult> RevokeTokenAsync(AuthService auth, string id, CancellationToken ct)
|
||||
{
|
||||
await auth.RevokeApiTokenAsync(id, ct);
|
||||
return TypedResults.NoContent();
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListSettingsAsync(SettingsService settings, CancellationToken ct) =>
|
||||
TypedResults.Json(new PageDto<SettingDto>([.. (await settings.ListAsync(ct)).Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoSettingDto);
|
||||
|
||||
private static async Task<IResult> PatchSettingsAsync(HttpContext http, SettingsService settings, Dictionary<string, JsonElement> changes)
|
||||
{
|
||||
Result<IReadOnlyList<SettingView>> result = await settings.PatchAsync(changes, http.RequestAborted);
|
||||
return result.TryGet(out IReadOnlyList<SettingView>? views, out FleetError? error)
|
||||
? TypedResults.Json(new PageDto<SettingDto>([.. views.Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoSettingDto)
|
||||
: Problems.From(http, error);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListUsersAsync(AuthService auth, CancellationToken ct) =>
|
||||
TypedResults.Json(new PageDto<UserDto>([.. (await auth.ListUsersAsync(ct)).Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoUserDto);
|
||||
|
||||
private static async Task<IResult> CreateUserAsync(HttpContext http, AuthService auth, CreateUserRequest request)
|
||||
{
|
||||
Result<UserRecord> result = await auth.CreateUserAsync(request.Username ?? "", request.Password ?? "", http.RequestAborted);
|
||||
if (!result.TryGet(out UserRecord? user, out FleetError? error))
|
||||
{
|
||||
return Problems.From(http, error);
|
||||
}
|
||||
|
||||
http.Response.Headers.Location = "/api/admin/users";
|
||||
return TypedResults.Json(Mappers.ToDto(user), ApiJsonContext.Default.UserDto, statusCode: StatusCodes.Status201Created);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ChangePasswordAsync(HttpContext http, AuthService auth, ChangePasswordRequest request, string id)
|
||||
{
|
||||
Result<bool> result = await auth.ChangePasswordAsync(id, request.Password ?? "", http.RequestAborted);
|
||||
return result.IsSuccess ? TypedResults.NoContent() : Problems.From(http, result.Error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
using Fleet.Core;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Monitor.Backend.Application;
|
||||
using Monitor.Protocol;
|
||||
|
||||
namespace Monitor.Backend.Api;
|
||||
|
||||
/// <summary>任务(线路模板、单个 Agent 的覆盖、目标任务)与按需测试。参数按 Kind 的 ParamSpec 校验,并检查带宽预算。</summary>
|
||||
public static class TaskEndpoints
|
||||
{
|
||||
private const string TasksTag = "admin-tasks";
|
||||
private const string TestsTag = "admin-tests";
|
||||
|
||||
public static void Map(RouteGroupBuilder group)
|
||||
{
|
||||
group.MapGet("/tasks", ListAsync)
|
||||
.Api("tasks_list", "Tasks: line templates, per-agent overrides and target tasks", TasksTag).Returns<PageDto<TaskDto>>().ProducesProblems(401, 403);
|
||||
group.MapPost("/tasks", CreateAsync)
|
||||
.Api("tasks_create", "Create a task; params are validated against the kind", TasksTag)
|
||||
.Returns<TaskDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 404);
|
||||
group.MapGet("/tasks/{id}", GetAsync)
|
||||
.Api("tasks_get", "One task", TasksTag).Returns<TaskDto>().ProducesProblems(401, 403, 404);
|
||||
group.MapPatch("/tasks/{id}", PatchAsync)
|
||||
.Accepts<PatchTaskRequest>("application/merge-patch+json", "application/json")
|
||||
.Api("tasks_update", "Change params, schedule, target or enabled (merge-patch, If-Match supported)", TasksTag)
|
||||
.Returns<TaskDto>().ProducesProblems(400, 401, 403, 404, 412);
|
||||
group.MapDelete("/tasks/{id}", DeleteAsync)
|
||||
.Api("tasks_delete", "Delete a task", TasksTag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403);
|
||||
|
||||
group.MapPost("/lines/{line_id}/tests", StartTestAsync)
|
||||
.Api("tests_start", "Start an on-demand test on a line", TestsTag)
|
||||
.Returns<TestDto>(StatusCodes.Status202Accepted).ProducesProblems(400, 401, 403, 404, 409);
|
||||
group.MapGet("/lines/{line_id}/tests", ListTestsAsync)
|
||||
.Api("tests_list", "Recent tests of a line", TestsTag).Returns<PageDto<TestDto>>().ProducesProblems(401, 403);
|
||||
group.MapPost("/tests/{id}/cancel", CancelAsync)
|
||||
.Api("tests_cancel", "Cancel a running test", TestsTag).Returns<TestDto>().ProducesProblems(401, 403, 404);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListAsync(TaskService tasks, string? agent_id, string? scope, CancellationToken ct)
|
||||
{
|
||||
IEnumerable<TaskRecord> all = await tasks.ListAsync(ct);
|
||||
if (agent_id is not null)
|
||||
{
|
||||
all = all.Where(t => t.AgentId?.Value == agent_id);
|
||||
}
|
||||
|
||||
if (scope is not null)
|
||||
{
|
||||
all = all.Where(t => t.Scope == scope);
|
||||
}
|
||||
|
||||
return TypedResults.Json(new PageDto<TaskDto>([.. all.Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoTaskDto);
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateAsync(HttpContext http, TaskService tasks, CreateTaskRequest request, CancellationToken ct)
|
||||
{
|
||||
Result<TaskRecord> result = await tasks.CreateAsync(
|
||||
new TaskInput(
|
||||
request.Scope ?? "", request.Kind ?? "", request.Target, request.Params, request.Schedule, request.Enabled,
|
||||
request.AgentId is null ? null : new NodeId(request.AgentId), request.BaseTaskId is null ? null : new TaskId(request.BaseTaskId)),
|
||||
ct);
|
||||
if (!result.TryGet(out TaskRecord? task, out FleetError? error))
|
||||
{
|
||||
return Problems.From(http, error);
|
||||
}
|
||||
|
||||
http.Response.Headers.Location = "/api/admin/tasks/" + task.Id.Value;
|
||||
Concurrency.SetETag(http, task.RowVersion);
|
||||
return TypedResults.Json(Mappers.ToDto(task), ApiJsonContext.Default.TaskDto, statusCode: StatusCodes.Status201Created);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetAsync(HttpContext http, TaskService tasks, string id, CancellationToken ct)
|
||||
{
|
||||
TaskRecord? task = await tasks.GetAsync(new TaskId(id), ct);
|
||||
if (task is null)
|
||||
{
|
||||
return Problems.Create(http, MonitorErrors.TaskNotFound, "task not found");
|
||||
}
|
||||
|
||||
Concurrency.SetETag(http, task.RowVersion);
|
||||
return TypedResults.Json(Mappers.ToDto(task), ApiJsonContext.Default.TaskDto);
|
||||
}
|
||||
|
||||
private static async Task<IResult> PatchAsync(HttpContext http, TaskService tasks, PatchTaskRequest request, string id, CancellationToken ct)
|
||||
{
|
||||
Result<TaskRecord> result = await tasks.UpdateAsync(new TaskId(id), new TaskPatch(request.Params, request.Schedule, request.Enabled, request.Target), Concurrency.ExpectedRowVersion(http), ct);
|
||||
if (!result.TryGet(out TaskRecord? task, out FleetError? error))
|
||||
{
|
||||
return Problems.From(http, error);
|
||||
}
|
||||
|
||||
Concurrency.SetETag(http, task.RowVersion);
|
||||
return TypedResults.Json(Mappers.ToDto(task), ApiJsonContext.Default.TaskDto);
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteAsync(TaskService tasks, string id, CancellationToken ct)
|
||||
{
|
||||
await tasks.DeleteAsync(new TaskId(id), ct);
|
||||
return TypedResults.NoContent();
|
||||
}
|
||||
|
||||
private static async Task<IResult> StartTestAsync(HttpContext http, TestService tests, LineService lines, StartTestRequestDto request, string line_id, CancellationToken ct)
|
||||
{
|
||||
Result<LineView> found = await lines.ResolveAsync(line_id, ct);
|
||||
if (!found.TryGet(out LineView? line, out FleetError? lineError))
|
||||
{
|
||||
return Problems.From(http, lineError);
|
||||
}
|
||||
|
||||
Result<TestRecord> result = await tests.StartAsync(line.Key.Agent, line.Key.Probe, new StartTestRequest(request.Plan ?? "", request.Params), "api", ct);
|
||||
if (!result.TryGet(out TestRecord? test, out FleetError? error))
|
||||
{
|
||||
return Problems.From(http, error);
|
||||
}
|
||||
|
||||
http.Response.Headers.Location = "/api/public/tests/" + test.Id.Value;
|
||||
return TypedResults.Json(Mappers.ToDto(test), ApiJsonContext.Default.TestDto, statusCode: StatusCodes.Status202Accepted);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListTestsAsync(HttpContext http, TestService tests, LineService lines, string line_id, int? limit, CancellationToken ct)
|
||||
{
|
||||
// 只给 Agent id(并且有多个 Probe)时列出这个 Agent 上所有 Probe 的测试
|
||||
NodeId agent;
|
||||
ProbeId? probe = null;
|
||||
if (LineKey.TryParse(line_id, out LineKey key))
|
||||
{
|
||||
agent = key.Agent;
|
||||
probe = key.Probe;
|
||||
}
|
||||
else
|
||||
{
|
||||
agent = new NodeId(line_id);
|
||||
}
|
||||
|
||||
return TypedResults.Json(
|
||||
new PageDto<TestDto>([.. (await tests.ListAsync(agent, probe, Math.Clamp(limit ?? 50, 1, 500), ct)).Select(Mappers.ToDto)], null),
|
||||
ApiJsonContext.Default.PageDtoTestDto);
|
||||
}
|
||||
|
||||
private static async Task<IResult> CancelAsync(HttpContext http, TestService tests, string id, CancellationToken ct)
|
||||
{
|
||||
Result<TestRecord> result = await tests.CancelAsync(new TestId(id), ct);
|
||||
return result.TryGet(out TestRecord? test, out FleetError? error)
|
||||
? TypedResults.Json(Mappers.ToDto(test), ApiJsonContext.Default.TestDto)
|
||||
: Problems.From(http, error);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user