- 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>
577 lines
27 KiB
C#
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,
|
|
}));
|
|
}
|
|
}
|