sqlinsight-pro/src/SQLInsight.Web/Api/ServerApiEndpoints.cs
Søren Eggert Lundsteen Olsen 1c18bcfab8 Web: Windows-servere and Syntetiske tjek pages, and the same in API v1 (scope config:write)
- Menu group "Servere" with Windows-servere (/servere) and Syntetiske tjek
  (/syntetiske-tjek).
- /servere: every server with status (overvåges, delvist målbar, kan ikke læses),
  CPU, free memory, the fullest disk and the nearest "fuld om ca. N dage", findings;
  add application/AOS/file servers. /servere/{id}: findings and what is not
  measurable and why, CPU/memory for the last day, disks with forecasts, watched
  and stopped services, grouped event log errors, scheduled tasks, rights per area
  with the latest test, the least-privilege PowerShell script (copy or download,
  UTF-8 with BOM), role and service lists, pause and remove. /servere/fund/{id}.
- /syntetiske-tjek: checks with status and response time; one form for every kind
  (TCP, HTTP(S), SQL login + query, DNS, AX logon) with an optional server and
  service; a SQL login password is sealed for the agent at once and never shown.
  /syntetiske-tjek/{id}: latest result (incl. "kun porten er testet"), findings,
  response times, history, edit, run now, pause, remove.
- API v1: /windows-servers (+ test, grant-script, performance, disks, services,
  events, tasks, rule-states), /server-findings, /synthetic-checks (+ run, results,
  rule-states); all in OpenAPI. Passwords are write-only; event samples, failure
  messages and sensitive evidence are "[udeladt]" unless
  SQLInsight:Web:ApiIncludesSensitiveEvidence is on. /api/v1/rules lists the new rules.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 14:58:52 +02:00

577 lines
27 KiB
C#

using System.Text.Json;
using Microsoft.Extensions.Options;
using SQLInsight.Core.Configuration;
using SQLInsight.Core.Findings;
using SQLInsight.Core.Servers;
using SQLInsight.Core.SyntheticChecks;
using SQLInsight.Repository;
namespace SQLInsight.Web.Api;
/// <summary>Body of POST /api/v1/windows-servers.</summary>
public sealed record WindowsServerBody(string? Name, string? Host, string? Role, IReadOnlyList<string>? WatchedServices, IReadOnlyList<string>? IgnoredServices);
/// <summary>Body of PATCH /api/v1/windows-servers/{id}. Omitted values are left as they are.</summary>
public sealed record WindowsServerPatch(bool? Paused, string? Role, IReadOnlyList<string>? WatchedServices, IReadOnlyList<string>? IgnoredServices);
/// <summary>Body of POST and PUT /api/v1/synthetic-checks. The password is write-only (SQL checks with a SQL login).</summary>
public sealed record SyntheticCheckBody(
string? Name,
string? Kind,
int? ServerId,
int? IntervalSeconds,
int? TimeoutSeconds,
SyntheticCheckSettings? Settings,
string? Password);
/// <summary>Body of PATCH /api/v1/synthetic-checks/{id}.</summary>
public sealed record SyntheticCheckPatch(bool? Paused);
/// <summary>
/// Windows servers and synthetic checks through the API: everything the pages Windows-servere and Syntetiske tjek
/// show and do. Reading needs the scope read; changing needs config:write for tokens, or SQLInsight:Web:AdminGroups for
/// Windows users. Tests and check runs are queued for the agent (202 with a request id for /api/v1/agent-requests/{id}).
/// Passwords are write-only. Event messages, failure messages and evidence marked sensitive are "[udeladt]" unless
/// SQLInsight:Web:ApiIncludesSensitiveEvidence is on.
/// </summary>
public static class ServerApiEndpoints
{
public static void Map(IEndpointRouteBuilder app)
{
ArgumentNullException.ThrowIfNull(app);
var api = app.MapGroup("/api/v1").DisableAntiforgery().WithTags("SQLInsight Pro API v1").RequireRead();
const string write = " Needs scope config:write for tokens, or SQLInsight:Web:AdminGroups for Windows users.";
api.MapGet("/windows-servers", ListServersAsync).WithSummary("Windows-servere med status / Windows servers");
api.MapPost("/windows-servers", AddServerAsync).WithSummary("Tilføj en Windows-server / Add a Windows server")
.WithDescription("201 Created. The agent tests the connection and rights at once (serverTestRequestId)." + write);
api.MapGet("/windows-servers/{id:int}", GetServerAsync).WithSummary("Én Windows-server / One Windows server");
api.MapPatch("/windows-servers/{id:int}", PatchServerAsync)
.WithSummary("Pause, rolle, overvågede og ignorerede tjenester / Pause, role, watched and ignored services").WithDescription(write.Trim());
api.MapDelete("/windows-servers/{id:int}", RemoveServerAsync).WithSummary("Stop overvågningen af en server / Remove a Windows server")
.WithDescription("204. Collected data is kept. 409 for a SQL Server host (it follows its instances; pause it instead)." + write);
api.MapPost("/windows-servers/{id:int}/test", TestServerAsync).WithSummary("Test forbindelse og rettigheder fra agenten / Test the connection and rights from the agent")
.WithDescription("202 Accepted with requestId." + write);
api.MapGet("/windows-servers/{id:int}/test", LatestTestAsync).WithSummary("Seneste test og manglende rettigheder / Latest test and missing rights");
api.MapGet("/windows-servers/{id:int}/grant-script", GrantScriptAsync)
.WithSummary("PowerShell-script med least-privilege-rettigheder til agentens konto / Least-privilege grant script for the agent's account");
api.MapGet("/windows-servers/{id:int}/performance", PerformanceAsync).WithSummary("CPU og hukommelse pr. minut (hours, standard 24) / CPU and memory per minute");
api.MapGet("/windows-servers/{id:int}/disks", DisksAsync).WithSummary("Diske med ledig plads og prognose / Disks with free space and forecast");
api.MapGet("/windows-servers/{id:int}/services", ServicesAsync).WithSummary("Tjenester / Services");
api.MapGet("/windows-servers/{id:int}/events", EventsAsync).WithSummary("Grupperede fejl fra hændelsesloggen (days, standard 1) / Grouped event log errors");
api.MapGet("/windows-servers/{id:int}/tasks", TasksAsync).WithSummary("Overvågede planlagte opgaver / Watched scheduled tasks");
api.MapGet("/windows-servers/{id:int}/rule-states", (int id, InsightRepository repository, CancellationToken ct) => RuleStatesAsync(FindingScope.Server, id, repository, ct))
.WithSummary("Reglernes udfald på serveren / Rule outcomes on the server");
api.MapGet("/server-findings", FindingsAsync)
.WithSummary("Fund på Windows-servere og syntetiske tjek / Findings on Windows servers and synthetic checks")
.WithDescription("status=active|resolved|all (default active), scope=server|check, id (with scope).");
api.MapGet("/server-findings/{id:long}", FindingAsync).WithSummary("Ét fund på en server eller et tjek / One server or check finding");
api.MapGet("/synthetic-checks", ListChecksAsync).WithSummary("Syntetiske tjek med seneste resultat / Synthetic checks");
api.MapPost("/synthetic-checks", AddCheckAsync).WithSummary("Tilføj et syntetisk tjek / Add a synthetic check")
.WithDescription("201 Created; the agent runs it at once (runRequestId). Kinds: Tcp, Http, Sql, Dns, AxLogon." + write);
api.MapGet("/synthetic-checks/{id:int}", GetCheckAsync).WithSummary("Ét syntetisk tjek / One synthetic check");
api.MapPut("/synthetic-checks/{id:int}", UpdateCheckAsync).WithSummary("Ret et syntetisk tjek / Change a synthetic check")
.WithDescription("The kind cannot change. A SQL login password must be given again when server, port or login change." + write);
api.MapPatch("/synthetic-checks/{id:int}", PatchCheckAsync).WithSummary("Sæt på pause eller genoptag / Pause or resume").WithDescription(write.Trim());
api.MapDelete("/synthetic-checks/{id:int}", RemoveCheckAsync).WithSummary("Fjern et syntetisk tjek / Remove a synthetic check").WithDescription("204." + write);
api.MapPost("/synthetic-checks/{id:int}/run", RunCheckAsync).WithSummary("Kør tjekket nu fra agenten / Run the check now from the agent")
.WithDescription("202 Accepted with requestId." + write);
api.MapGet("/synthetic-checks/{id:int}/results", ResultsAsync).WithSummary("Seneste kørsler (hours, standard 24) / Latest runs");
api.MapGet("/synthetic-checks/{id:int}/rule-states", (int id, InsightRepository repository, CancellationToken ct) => RuleStatesAsync(FindingScope.Check, id, repository, ct))
.WithSummary("Reglernes udfald på tjekket / Rule outcomes on the check");
}
private static IResult Forbidden() => Results.Problem(statusCode: StatusCodes.Status403Forbidden, title: "Ikke tilladt",
detail: "Ændringer kræver scope \"config:write\" (API-token) eller medlemskab af en gruppe i SQLInsight:Web:AdminGroups.");
private static IResult Invalid(string detail) => Results.Problem(statusCode: StatusCodes.Status422UnprocessableEntity, title: "Ugyldig", detail: detail);
private static string? Redact(string? value, IOptions<WebOptions> options, bool sensitive = true) =>
sensitive && !options.Value.ApiIncludesSensitiveEvidence && value is not null ? ApiV1Endpoints.Redacted : value;
private static object Json(WindowsServerRow s) => new
{
Id = s.ServerId,
s.Name,
s.Host,
s.Role,
Source = s.IsSqlHost ? "sqlHost" : "web",
s.Paused,
WatchedServices = s.Watched,
IgnoredServices = s.Ignored,
s.Reachable,
s.LastContactAt,
s.LastErrorAt,
s.LastError,
s.Protocol,
s.OperatingSystem,
s.OsVersion,
s.LastBootUpTime,
s.TotalMemoryMb,
s.LogicalProcessors,
Areas = s.Areas.Select(a => new { a.Area, a.Ok, a.Detail, a.Right }),
s.CreatedAt,
s.CreatedBy,
s.UpdatedAt,
s.UpdatedBy,
};
private static object Json(SyntheticCheckRow c, IOptions<WebOptions> options) => new
{
Id = c.CheckId,
c.Name,
c.Kind,
c.ServerId,
c.ServerName,
Target = c.Settings.Target(c.Kind),
c.IntervalSeconds,
c.TimeoutSeconds,
c.Paused,
Settings = c.Settings,
HasPassword = c.HasSecret,
c.SecretError,
c.LastRunAt,
c.LastSuccess,
c.LastDurationMs,
LastMessage = c.LastSuccess == false ? Redact(c.LastMessage, options) : c.LastMessage,
c.ConsecutiveFailures,
c.CreatedAt,
c.CreatedBy,
c.UpdatedAt,
c.UpdatedBy,
};
private static object Json(StoredServerFinding f, IOptions<WebOptions> options) => new
{
Id = f.FindingId,
Scope = f.Scope == FindingScope.Server ? "server" : "check",
ScopeId = f.ScopeId,
Name = f.ScopeName,
f.RuleId,
f.Subject,
Severity = f.Severity.ToString(),
f.Title,
f.Explanation,
f.Recommendation,
Evidence = f.Evidence.Select(e => new { e.Name, Value = Redact(e.Value, options, e.Sensitive) }),
References = f.References.Select(r => new { r.Title, r.Url }),
f.FirstSeenAt,
f.LastSeenAt,
f.ResolvedAt,
};
private static object Json(AgentRequestRow r) => new
{
Id = r.RequestId,
r.Kind,
r.Status,
r.RequestedBy,
r.RequestedAt,
r.StartedAt,
r.CompletedAt,
r.Error,
Result = r.Result is { Length: > 0 } result ? JsonDocument.Parse(result).RootElement.Clone() : (JsonElement?)null,
};
// ---- Windows servers --------------------------------------------------------------------------------------
private static async Task<IResult> ListServersAsync(InsightRepository repository, CancellationToken ct) =>
Results.Ok((await repository.GetWindowsServersAsync(ct).ConfigureAwait(false)).Select(Json));
private static async Task<IResult> GetServerAsync(int id, InsightRepository repository, CancellationToken ct) =>
await repository.GetWindowsServerAsync(id, ct).ConfigureAwait(false) is { } row ? Results.Ok(Json(row)) : Results.NotFound();
private static async Task<IResult> AddServerAsync(WindowsServerBody body, HttpContext http, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct)
{
if (!InstanceSetup.MayChange(http.User, options.Value))
{
return Forbidden();
}
var outcome = await ServerSetup.AddServerAsync(repository, new ServerInput(body.Name, body.Host, body.Role, body.WatchedServices, body.IgnoredServices),
http.User.Identity?.Name, ct).ConfigureAwait(false);
if (outcome.Error is { } error)
{
return Invalid(error);
}
var row = (await repository.GetWindowsServerAsync(outcome.Id!.Value, ct).ConfigureAwait(false))!;
return Results.Created($"/api/v1/windows-servers/{row.ServerId}", new { Server = Json(row), ServerTestRequestId = outcome.RequestId });
}
private static async Task<IResult> PatchServerAsync(int id, WindowsServerPatch body, HttpContext http, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct)
{
if (!InstanceSetup.MayChange(http.User, options.Value))
{
return Forbidden();
}
var by = http.User.Identity?.Name;
var outcome = await ServerSetup.UpdateServerAsync(repository, id, new ServerInput(null, null, body.Role, body.WatchedServices, body.IgnoredServices), by, ct)
.ConfigureAwait(false);
if (outcome.NotFound)
{
return Results.NotFound();
}
if (outcome.Error is { } error)
{
return Invalid(error);
}
if (body.Paused is { } paused)
{
await repository.SetWindowsServerPausedAsync(id, paused, by, DateTimeOffset.UtcNow, ct).ConfigureAwait(false);
}
return Results.Ok(Json((await repository.GetWindowsServerAsync(id, ct).ConfigureAwait(false))!));
}
private static async Task<IResult> RemoveServerAsync(int id, HttpContext http, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct)
{
if (!InstanceSetup.MayChange(http.User, options.Value))
{
return Forbidden();
}
return await repository.RemoveWindowsServerAsync(id, http.User.Identity?.Name, DateTimeOffset.UtcNow, ct).ConfigureAwait(false) switch
{
ConfigurationChangeOutcome.Changed => Results.NoContent(),
ConfigurationChangeOutcome.FromConfigFile => Results.Problem(statusCode: StatusCodes.Status409Conflict, title: "SQL Server-host",
detail: "Serveren følger de overvågede SQL Server-instanser og fjernes sammen med dem. Den kan sættes på pause med PATCH."),
_ => Results.NotFound(),
};
}
private static async Task<IResult> TestServerAsync(int id, HttpContext http, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct)
{
if (!InstanceSetup.MayChange(http.User, options.Value))
{
return Forbidden();
}
var outcome = await ServerSetup.RequestServerTestAsync(repository, id, http.User.Identity?.Name, ct).ConfigureAwait(false);
if (outcome.NotFound)
{
return Results.NotFound();
}
var request = (await repository.GetAgentRequestAsync(outcome.RequestId!.Value, ct).ConfigureAwait(false))!;
return Results.Accepted($"/api/v1/agent-requests/{request.RequestId}", Json(request));
}
private static async Task<IResult> LatestTestAsync(int id, InsightRepository repository, CancellationToken ct)
{
if (await repository.GetWindowsServerAsync(id, ct).ConfigureAwait(false) is null)
{
return Results.NotFound();
}
var test = await repository.GetLatestServerRequestAsync(ServerRequestKind.ServerTest, id, ct).ConfigureAwait(false);
if (test is null)
{
return Results.NotFound();
}
var result = AgentRequestJson.Deserialize<WindowsServerTestResult>(test.Result);
return Results.Ok(new { Request = Json(test), MissingAreas = result?.Missing.Select(a => new { a.Area, a.Right, a.Detail }).ToList() ?? [] });
}
private static async Task<IResult> GrantScriptAsync(int id, InsightRepository repository, CancellationToken ct)
{
if (await repository.GetWindowsServerAsync(id, ct).ConfigureAwait(false) is not { } row)
{
return Results.NotFound();
}
var (account, script, note) = await ServerSetup.GrantScriptAsync(repository, row, ct).ConfigureAwait(false);
return Results.Ok(new { Account = account, Script = script, Note = note });
}
private static async Task<IResult> PerformanceAsync(int id, InsightRepository repository, CancellationToken ct, int? hours = null)
{
if (await repository.GetWindowsServerAsync(id, ct).ConfigureAwait(false) is null)
{
return Results.NotFound();
}
var from = DateTimeOffset.UtcNow.AddHours(-Math.Clamp(hours ?? 24, 1, 24 * 31));
return Results.Ok(await repository.GetServerPerformanceAsync(id, from, ct).ConfigureAwait(false));
}
private static async Task<IResult> DisksAsync(int id, InsightRepository repository, CancellationToken ct)
{
if (await repository.GetWindowsServerAsync(id, ct).ConfigureAwait(false) is null)
{
return Results.NotFound();
}
var snapshot = await repository.GetServerSnapshotAsync<ServerDiskSnapshot>(id, ServerArea.Disks, ct).ConfigureAwait(false);
if (snapshot is null)
{
return Results.Ok(new { CapturedAt = (DateTimeOffset?)null, Disks = Array.Empty<object>() });
}
return Results.Ok(new
{
snapshot.CapturedAt,
Disks = snapshot.Disks.Select(d =>
{
var forecast = snapshot.Forecasts.FirstOrDefault(f => string.Equals(f.Volume, d.Volume, StringComparison.OrdinalIgnoreCase));
return new
{
d.Volume,
d.Label,
d.FileSystem,
d.SizeGb,
d.FreeGb,
d.FreePercent,
Forecast = forecast is null ? null : new
{
forecast.DaysUntilFull,
FullAt = forecast.FullAt(snapshot.CapturedAt),
forecast.GrowthGbPerDay,
forecast.Samples,
forecast.HistoryDays,
forecast.Fit,
forecast.Note,
Text = forecast.Describe(),
},
};
}),
});
}
private static async Task<IResult> ServicesAsync(int id, InsightRepository repository, CancellationToken ct)
{
if (await repository.GetWindowsServerAsync(id, ct).ConfigureAwait(false) is not { } row)
{
return Results.NotFound();
}
var snapshot = await repository.GetServerSnapshotAsync<ServerServiceSnapshot>(id, ServerArea.Services, ct).ConfigureAwait(false);
return Results.Ok(new
{
CapturedAt = snapshot?.CapturedAt,
WatchedServices = row.Watched,
IgnoredServices = row.Ignored,
Services = snapshot?.Services.Select(s => new { s.Name, s.DisplayName, s.State, s.StartMode, s.DelayedAutoStart, s.ExitCode }) ?? [],
});
}
private static async Task<IResult> EventsAsync(int id, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct, int? days = null)
{
if (await repository.GetWindowsServerAsync(id, ct).ConfigureAwait(false) is null)
{
return Results.NotFound();
}
var now = DateTimeOffset.UtcNow;
var events = await repository.GetServerEventsAsync(id, now.AddDays(-Math.Clamp(days ?? 1, 1, 90)), now, cancellationToken: ct).ConfigureAwait(false);
return Results.Ok(events.Select(e => new
{
e.CollectedAt,
Log = e.LogName,
e.Source,
e.EventId,
e.Level,
e.Category,
e.Count,
e.FirstAt,
e.LastAt,
Sample = Redact(e.Sample, options),
}));
}
private static async Task<IResult> TasksAsync(int id, InsightRepository repository, CancellationToken ct)
{
if (await repository.GetWindowsServerAsync(id, ct).ConfigureAwait(false) is null)
{
return Results.NotFound();
}
var snapshot = await repository.GetServerSnapshotAsync<ServerTaskSnapshot>(id, ServerArea.Tasks, ct).ConfigureAwait(false);
return Results.Ok(new
{
CapturedAt = snapshot?.CapturedAt,
Visible = snapshot?.Visible ?? 0,
snapshot?.Coverage,
Tasks = snapshot?.Tasks.Select(t => new
{
Path = t.FullName,
t.State,
t.LastRunTime,
LastTaskResult = t.LastTaskResult is { } r ? TaskResults.Hex(r) : null,
LastResultText = t.LastTaskResult is { } text ? TaskResults.Describe(text) : null,
Failed = TaskResults.IsFailure(t.LastTaskResult),
t.NextRunTime,
t.MissedRuns,
}) ?? [],
});
}
private static async Task<IResult> RuleStatesAsync(string scope, int id, InsightRepository repository, CancellationToken ct)
{
var exists = scope == FindingScope.Server
? await repository.GetWindowsServerAsync(id, ct).ConfigureAwait(false) is not null
: await repository.GetSyntheticCheckAsync(id, ct).ConfigureAwait(false) is not null;
if (!exists)
{
return Results.NotFound();
}
return Results.Ok((await repository.GetServerRuleStatesAsync(scope, id, ct).ConfigureAwait(false)).Select(s => new
{
s.RuleId,
s.RuleTitle,
Outcome = char.ToLowerInvariant(s.Outcome.ToString()[0]) + s.Outcome.ToString()[1..],
s.Reason,
s.EvaluatedAt,
}));
}
private static async Task<IResult> FindingsAsync(InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct, string? status = null, string? scope = null,
int? id = null)
{
var findingStatus = status?.ToLowerInvariant() switch
{
null or "" or "active" => (FindingStatus?)FindingStatus.Active,
"resolved" => FindingStatus.Resolved,
"all" => FindingStatus.All,
_ => null,
};
var findingScope = scope?.ToLowerInvariant() switch
{
null or "" => (string?)null,
"server" => FindingScope.Server,
"check" => FindingScope.Check,
_ => "?",
};
if (findingStatus is null || findingScope == "?" || (id is not null && findingScope is null))
{
return Results.BadRequest("status skal være active, resolved eller all; scope skal være server eller check (og angives sammen med id).");
}
var findings = await repository.GetServerFindingsAsync(findingScope, id, findingStatus.Value, cancellationToken: ct).ConfigureAwait(false);
return Results.Ok(findings.Select(f => Json(f, options)));
}
private static async Task<IResult> FindingAsync(long id, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct) =>
await repository.GetServerFindingAsync(id, ct).ConfigureAwait(false) is { } finding ? Results.Ok(Json(finding, options)) : Results.NotFound();
// ---- Synthetic checks -------------------------------------------------------------------------------------
private static CheckInput Input(SyntheticCheckBody body) =>
new(body.Name, body.Kind, body.ServerId, body.Settings ?? new SyntheticCheckSettings(), body.Password, body.IntervalSeconds, body.TimeoutSeconds);
private static async Task<IResult> ListChecksAsync(InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct) =>
Results.Ok((await repository.GetSyntheticChecksAsync(ct).ConfigureAwait(false)).Select(c => Json(c, options)));
private static async Task<IResult> GetCheckAsync(int id, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct) =>
await repository.GetSyntheticCheckAsync(id, ct).ConfigureAwait(false) is { } row ? Results.Ok(Json(row, options)) : Results.NotFound();
private static async Task<IResult> AddCheckAsync(SyntheticCheckBody body, HttpContext http, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct)
{
if (!InstanceSetup.MayChange(http.User, options.Value))
{
return Forbidden();
}
var outcome = await ServerSetup.AddCheckAsync(repository, Input(body), http.User.Identity?.Name, ct).ConfigureAwait(false);
if (outcome.Error is { } error)
{
return Invalid(error);
}
var row = (await repository.GetSyntheticCheckAsync(outcome.Id!.Value, ct).ConfigureAwait(false))!;
return Results.Created($"/api/v1/synthetic-checks/{row.CheckId}", new { Check = Json(row, options), RunRequestId = outcome.RequestId });
}
private static async Task<IResult> UpdateCheckAsync(int id, SyntheticCheckBody body, HttpContext http, InsightRepository repository, IOptions<WebOptions> options,
CancellationToken ct)
{
if (!InstanceSetup.MayChange(http.User, options.Value))
{
return Forbidden();
}
var outcome = await ServerSetup.UpdateCheckAsync(repository, id, Input(body), http.User.Identity?.Name, ct).ConfigureAwait(false);
if (outcome.NotFound)
{
return Results.NotFound();
}
return outcome.Error is { } error ? Invalid(error) : Results.Ok(Json((await repository.GetSyntheticCheckAsync(id, ct).ConfigureAwait(false))!, options));
}
private static async Task<IResult> PatchCheckAsync(int id, SyntheticCheckPatch body, HttpContext http, InsightRepository repository, IOptions<WebOptions> options,
CancellationToken ct)
{
if (!InstanceSetup.MayChange(http.User, options.Value))
{
return Forbidden();
}
if (await repository.GetSyntheticCheckAsync(id, ct).ConfigureAwait(false) is null)
{
return Results.NotFound();
}
if (body.Paused is { } paused)
{
await repository.SetSyntheticCheckPausedAsync(id, paused, http.User.Identity?.Name, DateTimeOffset.UtcNow, ct).ConfigureAwait(false);
}
return Results.Ok(Json((await repository.GetSyntheticCheckAsync(id, ct).ConfigureAwait(false))!, options));
}
private static async Task<IResult> RemoveCheckAsync(int id, HttpContext http, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct)
{
if (!InstanceSetup.MayChange(http.User, options.Value))
{
return Forbidden();
}
return await repository.RemoveSyntheticCheckAsync(id, http.User.Identity?.Name, DateTimeOffset.UtcNow, ct).ConfigureAwait(false) ? Results.NoContent() : Results.NotFound();
}
private static async Task<IResult> RunCheckAsync(int id, HttpContext http, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct)
{
if (!InstanceSetup.MayChange(http.User, options.Value))
{
return Forbidden();
}
var outcome = await ServerSetup.RequestCheckRunAsync(repository, id, http.User.Identity?.Name, ct).ConfigureAwait(false);
if (outcome.NotFound)
{
return Results.NotFound();
}
var request = (await repository.GetAgentRequestAsync(outcome.RequestId!.Value, ct).ConfigureAwait(false))!;
return Results.Accepted($"/api/v1/agent-requests/{request.RequestId}", Json(request));
}
private static async Task<IResult> ResultsAsync(int id, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct, int? hours = null)
{
if (await repository.GetSyntheticCheckAsync(id, ct).ConfigureAwait(false) is null)
{
return Results.NotFound();
}
var results = await repository.GetSyntheticCheckResultsAsync(id, 5000, DateTimeOffset.UtcNow.AddHours(-Math.Clamp(hours ?? 24, 1, 24 * 31)), ct).ConfigureAwait(false);
return Results.Ok(results.Select(r => new
{
r.RunAt,
r.Success,
r.DurationMs,
Message = r.Success ? r.Message : Redact(r.Message, options),
Details = r.Details.Select(d => new { d.Name, Value = Redact(d.Value, options, d.Sensitive) }),
r.CertificateExpiresAt,
r.CertificateSubject,
r.ServiceState,
r.Partial,
}));
}
}