using Fleet.Core; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using MoShiro.Backend.Application; using MoShiro.Protocol; namespace MoShiro.Backend.Api; /// 任务(线路模板、单个 Agent 的覆盖、目标任务)与按需测试。参数按 Kind 的 ParamSpec 校验,并检查带宽预算。 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>().ProducesProblems(401, 403); group.MapPost("/tasks", CreateAsync) .Api("tasks_create", "Create a task; params are validated against the kind", TasksTag) .Returns(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 404); group.MapGet("/tasks/{id}", GetAsync) .Api("tasks_get", "One task", TasksTag).Returns().ProducesProblems(401, 403, 404); group.MapPatch("/tasks/{id}", PatchAsync) .Accepts("application/merge-patch+json", "application/json") .Api("tasks_update", "Change params, schedule, target or enabled (merge-patch, If-Match supported)", TasksTag) .Returns().ProducesProblems(400, 401, 403, 404, 412); group.MapDelete("/tasks/{id}", DeleteAsync) .Api("tasks_delete", "Delete a task", TasksTag).Returns(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(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>().ProducesProblems(401, 403); group.MapPost("/tests/{id}/cancel", CancelAsync) .Api("tests_cancel", "Cancel a running test", TestsTag).Returns().ProducesProblems(401, 403, 404); } private static async Task ListAsync(TaskService tasks, string? agent_id, string? scope, CancellationToken ct) { IEnumerable 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([.. all.Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoTaskDto); } private static async Task CreateAsync(HttpContext http, TaskService tasks, CreateTaskRequest request, CancellationToken ct) { Result 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 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, MoShiroErrors.TaskNotFound, "task not found"); } Concurrency.SetETag(http, task.RowVersion); return TypedResults.Json(Mappers.ToDto(task), ApiJsonContext.Default.TaskDto); } private static async Task PatchAsync(HttpContext http, TaskService tasks, PatchTaskRequest request, string id, CancellationToken ct) { Result 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 DeleteAsync(TaskService tasks, string id, CancellationToken ct) { await tasks.DeleteAsync(new TaskId(id), ct); return TypedResults.NoContent(); } private static async Task StartTestAsync(HttpContext http, TestService tests, LineService lines, StartTestRequestDto request, string line_id, CancellationToken ct) { Result found = await lines.ResolveAsync(line_id, ct); if (!found.TryGet(out LineView? line, out FleetError? lineError)) { return Problems.From(http, lineError); } Result result = await tests.StartAsync(line.Key.Agent, line.Key.Probe, new StartTestRequest(request.Plan ?? "", request.Params), "api", line.IpVersion, 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 ListTestsAsync(HttpContext http, TestService tests, LineService lines, string line_id, int? limit, CancellationToken ct) { // 只给 Agent id(并且有多个 Probe)时列出这个 Agent 上所有 Probe 的测试 NodeId agent; ProbeId? probe = null; string? ipVersion = null; if (LineKey.TryParse(line_id, out LineKey key)) { agent = key.Agent; probe = key.Probe; ipVersion = key.IpVersion; } else { agent = new NodeId(line_id); } return TypedResults.Json( new PageDto([.. (await tests.ListAsync(agent, probe, Math.Clamp(limit ?? 50, 1, 500), ipVersion, ct)).Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoTestDto); } private static async Task CancelAsync(HttpContext http, TestService tests, string id, CancellationToken ct) { Result 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); } }