mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
backend: one query-parameter parser (QueryParams) with a lenient and a strict contract, ratcheted (D-202)
Helpers/QueryParams replaces fourteen private days/limit parsers, the three TryParseUtc copies, eleven identical pageSize blocks and ~50 inline TryParse sites under Functions/ and Pagination/. Lenient (Int/IntOrNull/UtcInstant): absent, garbage or below-minimum means the call site's default, above-maximum clamps. Strict (TryInt/TryPageSize/TryUtcInstant): absent is null, anything else invalid is a 400 with a message that names the parameter. Integers are invariant-culture, instants are invariant UTC (AssumeUniversal | AdjustToUniversal), whitespace counts as absent. Defaults and ranges stay arguments at every call site. Settled differences: the Apps windows clamp above 365 instead of falling back to 30, days=0 or negative yields the default everywhere, the two global geo endpoints and the session list cap days at 365, SLA months is capped at SlaMetricsService.MaxMonths on the HTTP layer, a whitespace date means no filter on every route, the admin batch sizes reject values above their maximum, and the three culture-dependent DateTime.TryParse sites parse as UTC. QueryParamGuardTests ratchets TryParse out of Functions/ and Pagination/ (baseline: headers, raw cells, the agent version major, the se= of a stored SAS URL); QueryParamsTests carries the cases the removed copies pinned. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -726,17 +726,6 @@ public class DeviceJourneyAndFtrTests
|
||||
Assert.Equal(3, totals.ExcludedSessions);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, 30)] // default window
|
||||
[InlineData("garbage", 30)]
|
||||
[InlineData("7", 7)]
|
||||
[InlineData("0", 1)] // clamped low
|
||||
[InlineData("999", 180)] // clamped to aggregate retention
|
||||
public void ClampDays_DefaultsAndClamps(string? raw, int expected)
|
||||
{
|
||||
Assert.Equal(expected, DeviceJourneyMetricsResponseBuilder.ClampDays(raw));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InclusiveWindowStart_YieldsExactlyNCalendarDays()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Ratchet: HTTP query parameters are parsed through <c>Helpers/QueryParams.cs</c> only. A
|
||||
/// <c>int/long/DateTime/DateTimeOffset.TryParse</c> under <c>Functions/</c> or <c>Pagination/</c>
|
||||
/// is a new private parser copy — the state the 2026-09-04 API audit found six times over for
|
||||
/// <c>days</c> alone, each with its own default, cap and out-of-range rule. The baseline lists
|
||||
/// the sites that parse something other than a query parameter (a header, a stored cell, a
|
||||
/// version string); it only shrinks.
|
||||
/// </summary>
|
||||
public class QueryParamGuardTests
|
||||
{
|
||||
private static readonly Regex TryParseCall = new(
|
||||
@"\b(int|long|DateTime|DateTimeOffset)\.TryParse(Exact)?\(", RegexOptions.Compiled);
|
||||
|
||||
/// <summary>Non-query parses, reviewed: file → why it is allowed.</summary>
|
||||
private static readonly IReadOnlyDictionary<string, string> Baseline = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["Functions/Config/GetAgentConfigFunction.cs"] = "major of the X-Agent-Version header",
|
||||
["Functions/Ingest/IngestTelemetryFunction.cs"] = "X-Send-Time-Utc header (RoundtripKind, deliberately not a query instant)",
|
||||
["Functions/Raw/QueryRawEventsFunction.cs"] = "Severity / Sequence cells of raw table rows",
|
||||
["Functions/Diagnostics/GetDiagnosticsUploadUrlFunction.cs"] = "se= expiry inside the tenant's stored SAS URL",
|
||||
// Content-Length header guard on body-taking routes (long.TryParse on the header value).
|
||||
["Functions/Admin/SubmitOffboardingFeedbackFunction.cs"] = "Content-Length header",
|
||||
["Functions/Bootstrap/BootstrapRegisterSessionFunction.cs"] = "Content-Length header",
|
||||
["Functions/Config/PatchTenantConfigurationFieldsFunction.cs"] = "Content-Length header",
|
||||
["Functions/Config/UpdateAdminConfigurationFunction.cs"] = "Content-Length header",
|
||||
["Functions/Config/UpdateTenantConfigurationFunction.cs"] = "Content-Length header",
|
||||
["Functions/Feedback/FeedbackFunction.cs"] = "Content-Length header",
|
||||
["Functions/Infrastructure/SignalRAddToGroupFunction.cs"] = "Content-Length header",
|
||||
["Functions/Infrastructure/SignalRRemoveFromGroupFunction.cs"] = "Content-Length header",
|
||||
["Functions/Ingest/ReportAgentErrorFunction.cs"] = "Content-Length header",
|
||||
["Functions/Ingest/ReportDistressFunction.cs"] = "Content-Length header",
|
||||
["Functions/Reports/SubmitDiagFilesReportFunction.cs"] = "Content-Length header",
|
||||
["Functions/Reports/SubmitSessionReportFunction.cs"] = "Content-Length header",
|
||||
["Functions/Rules/AnalyzeRulesFunction.cs"] = "Content-Length header",
|
||||
["Functions/Rules/DryRunAnalyzeRuleFunction.cs"] = "Content-Length header",
|
||||
["Functions/Rules/GatherRulesFunction.cs"] = "Content-Length header",
|
||||
["Functions/Rules/GlobalRulesFunction.cs"] = "Content-Length header",
|
||||
["Functions/Rules/ImeLogPatternsFunction.cs"] = "Content-Length header",
|
||||
["Functions/Rules/TestLogPatternFunction.cs"] = "Content-Length header",
|
||||
["Functions/Sessions/RegisterSessionFunction.cs"] = "Content-Length header",
|
||||
};
|
||||
|
||||
private static string FunctionsRoot()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
|
||||
dir = dir.Parent;
|
||||
Assert.NotNull(dir);
|
||||
return Path.Combine(dir!.FullName, "src", "Backend", "AutopilotMonitor.Functions");
|
||||
}
|
||||
|
||||
private static IEnumerable<string> ScannedFiles(string root) =>
|
||||
new[] { "Functions", "Pagination" }
|
||||
.SelectMany(sub => Directory.EnumerateFiles(Path.Combine(root, sub), "*.cs", SearchOption.AllDirectories))
|
||||
.Select(p => Path.GetRelativePath(root, p).Replace('\\', '/'))
|
||||
.OrderBy(p => p, StringComparer.Ordinal);
|
||||
|
||||
[Fact]
|
||||
public void Query_parameters_are_parsed_through_QueryParams_only()
|
||||
{
|
||||
var root = FunctionsRoot();
|
||||
var offenders = new List<string>();
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var rel in ScannedFiles(root))
|
||||
{
|
||||
var source = File.ReadAllText(Path.Combine(root, rel));
|
||||
if (!TryParseCall.IsMatch(source)) continue;
|
||||
seen.Add(rel);
|
||||
if (!Baseline.ContainsKey(rel)) offenders.Add(rel);
|
||||
}
|
||||
|
||||
Assert.True(offenders.Count == 0,
|
||||
"TryParse outside Helpers/QueryParams.cs — parse query parameters through QueryParams " +
|
||||
"(Int / TryInt / TryPageSize / UtcInstant / TryUtcInstant), or add the file to the baseline " +
|
||||
"with the non-query reason:\n " + string.Join("\n ", offenders));
|
||||
|
||||
var stale = Baseline.Keys.Where(k => !seen.Contains(k)).ToList();
|
||||
Assert.True(stale.Count == 0,
|
||||
"Baseline entries without a TryParse any more — remove them (the ratchet only shrinks):\n " +
|
||||
string.Join("\n ", stale));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
using System.Globalization;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The one query-parameter parser (<see cref="QueryParams"/>). The lenient theories carry the
|
||||
/// cases the former per-function copies pinned (RuleHitSessions, DeviceJourney, VerdictCalibration,
|
||||
/// the Apps reject-to-default variant) so their behaviour is decided once, here: garbage and
|
||||
/// below-minimum mean default, above-maximum clamps. The strict theories pin the 400 messages
|
||||
/// the paginators and admin routes return.
|
||||
/// </summary>
|
||||
public class QueryParamsTests
|
||||
{
|
||||
private static readonly DateTime Occurred = new(2026, 8, 29, 20, 11, 0, DateTimeKind.Utc);
|
||||
|
||||
// ── Lenient ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, 14)]
|
||||
[InlineData("", 14)]
|
||||
[InlineData(" ", 14)]
|
||||
[InlineData("garbage", 14)]
|
||||
[InlineData("14", 14)]
|
||||
[InlineData("1", 1)]
|
||||
[InlineData("0", 14)] // below min ⇒ default (the old ClampDays copies answered 1)
|
||||
[InlineData("-5", 14)]
|
||||
[InlineData("90", 90)]
|
||||
[InlineData("365", 90)] // above max ⇒ clamped (the old Apps copies answered the default)
|
||||
[InlineData("2147483648", 14)] // overflow ⇒ default
|
||||
public void Int_defaults_below_min_and_clamps_above_max(string? raw, int expected)
|
||||
{
|
||||
Assert.Equal(expected, QueryParams.Int(raw, @default: 14, min: 1, max: 90));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Int_reads_the_named_parameter_from_a_query_collection()
|
||||
{
|
||||
var query = System.Web.HttpUtility.ParseQueryString("days=7&limit=abc");
|
||||
Assert.Equal(7, QueryParams.Int(query, "days", 30, 1, 365));
|
||||
Assert.Equal(100, QueryParams.Int(query, "limit", 100, 1, 2000));
|
||||
Assert.Equal(30, QueryParams.Int((System.Collections.Specialized.NameValueCollection?)null, "days", 30, 1, 365));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Int_is_culture_invariant()
|
||||
{
|
||||
var previous = CultureInfo.CurrentCulture;
|
||||
try
|
||||
{
|
||||
CultureInfo.CurrentCulture = new CultureInfo("de-DE");
|
||||
Assert.Equal(1000, QueryParams.Int("1000", 1, 1, 5000));
|
||||
Assert.Equal(1, QueryParams.Int("1.000", 1, 1, 5000)); // thousands separator is not a number
|
||||
}
|
||||
finally
|
||||
{
|
||||
CultureInfo.CurrentCulture = previous;
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, null)]
|
||||
[InlineData("", null)]
|
||||
[InlineData("x", null)]
|
||||
[InlineData("-3", -3)]
|
||||
[InlineData("12", 12)]
|
||||
public void IntOrNull_has_no_range(string? raw, int? expected)
|
||||
{
|
||||
Assert.Equal(expected, QueryParams.IntOrNull(raw));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UtcInstant_is_null_for_absent_or_garbage_and_utc_otherwise()
|
||||
{
|
||||
Assert.Null(QueryParams.UtcInstant(null));
|
||||
Assert.Null(QueryParams.UtcInstant(" "));
|
||||
Assert.Null(QueryParams.UtcInstant("yesterday"));
|
||||
var parsed = QueryParams.UtcInstant("2026-08-29T22:11:00+02:00");
|
||||
Assert.Equal(Occurred, parsed);
|
||||
Assert.Equal(DateTimeKind.Utc, parsed!.Value.Kind);
|
||||
}
|
||||
|
||||
// ── Strict ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void TryInt_absent_is_null_and_ok(string? raw)
|
||||
{
|
||||
Assert.True(QueryParams.TryInt(raw, "days", 1, 365, out var value, out var error));
|
||||
Assert.Null(value);
|
||||
Assert.Null(error);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("banana", "days must be an integer")]
|
||||
[InlineData("1.5", "days must be an integer")]
|
||||
[InlineData("0", "days must be between 1 and 365")]
|
||||
[InlineData("-1", "days must be between 1 and 365")]
|
||||
[InlineData("366", "days must be between 1 and 365")]
|
||||
public void TryInt_rejects_garbage_and_out_of_range_with_a_named_message(string raw, string expectedError)
|
||||
{
|
||||
Assert.False(QueryParams.TryInt(raw, "days", 1, 365, out var value, out var error));
|
||||
Assert.Null(value);
|
||||
Assert.Equal(expectedError, error);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("1", 1)]
|
||||
[InlineData("365", 365)]
|
||||
public void TryInt_accepts_the_range_ends(string raw, int expected)
|
||||
{
|
||||
Assert.True(QueryParams.TryInt(raw, "days", 1, 365, out var value, out var error));
|
||||
Assert.Equal(expected, value);
|
||||
Assert.Null(error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryInt_without_an_upper_bound_names_only_the_minimum()
|
||||
{
|
||||
Assert.False(QueryParams.TryInt("-1", "skip", 0, int.MaxValue, out _, out var error));
|
||||
Assert.Equal("skip must be an integer of at least 0", error);
|
||||
Assert.True(QueryParams.TryInt("2147483647", "skip", 0, int.MaxValue, out var max, out _));
|
||||
Assert.Equal(int.MaxValue, max);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, null, null)]
|
||||
[InlineData("1", 1, null)]
|
||||
[InlineData("1000", 1000, null)]
|
||||
[InlineData("1001", null, "pageSize must be between 1 and 1000")]
|
||||
[InlineData("0", null, "pageSize must be between 1 and 1000")]
|
||||
[InlineData("ten", null, "pageSize must be an integer")]
|
||||
public void TryPageSize_is_strict_within_one_to_MaxPageSize(string? raw, int? expected, string? expectedError)
|
||||
{
|
||||
var ok = QueryParams.TryPageSize(raw, out var pageSize, out var error);
|
||||
Assert.Equal(expectedError == null, ok);
|
||||
Assert.Equal(expected, pageSize);
|
||||
Assert.Equal(expectedError, error);
|
||||
}
|
||||
|
||||
// ── Instants ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void TryUtcInstant_reads_offset_bare_and_date_only_values_as_utc()
|
||||
{
|
||||
Assert.True(QueryParams.TryUtcInstant("2026-08-29T20:11:00Z", "d", out var z, out _));
|
||||
Assert.True(QueryParams.TryUtcInstant("2026-08-29T22:11:00+02:00", "d", out var offset, out _));
|
||||
Assert.True(QueryParams.TryUtcInstant("2026-08-29T20:11:00", "d", out var bare, out _));
|
||||
Assert.True(QueryParams.TryUtcInstant("2026-08-29", "d", out var dateOnly, out _));
|
||||
|
||||
Assert.Equal(Occurred, z);
|
||||
Assert.Equal(Occurred, offset);
|
||||
Assert.Equal(Occurred, bare);
|
||||
Assert.Equal(Occurred.Date, dateOnly);
|
||||
Assert.Equal(DateTimeKind.Utc, z!.Value.Kind);
|
||||
Assert.Equal(DateTimeKind.Utc, bare!.Value.Kind);
|
||||
Assert.Equal(DateTimeKind.Utc, dateOnly!.Value.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryUtcInstant_treats_absent_and_whitespace_as_no_filter_and_garbage_as_error()
|
||||
{
|
||||
Assert.True(QueryParams.TryUtcInstant(null, "dateFrom", out var none, out var noneError));
|
||||
Assert.Null(none);
|
||||
Assert.Null(noneError);
|
||||
Assert.True(QueryParams.TryUtcInstant(" ", "dateFrom", out var blank, out _));
|
||||
Assert.Null(blank);
|
||||
Assert.False(QueryParams.TryUtcInstant("yesterday", "dateFrom", out var bad, out var error));
|
||||
Assert.Null(bad);
|
||||
Assert.Equal("dateFrom must be an ISO 8601 date or date-time", error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryUtcInstant_is_culture_invariant()
|
||||
{
|
||||
var previous = CultureInfo.CurrentCulture;
|
||||
try
|
||||
{
|
||||
// de-DE would read "08/29/2026" day-first and fail; invariant parsing does not.
|
||||
CultureInfo.CurrentCulture = new CultureInfo("de-DE");
|
||||
Assert.True(QueryParams.TryUtcInstant("08/29/2026 20:11", "d", out var parsed, out _));
|
||||
Assert.Equal(Occurred, parsed);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CultureInfo.CurrentCulture = previous;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -116,30 +116,4 @@ public class RawEventTimeTests
|
||||
Assert.Equal(Occurred - expectedSlack, QueryRawEventsPagination.IndexWrittenAfterHint(Occurred));
|
||||
Assert.Null(QueryRawEventsPagination.IndexWrittenAfterHint(null));
|
||||
}
|
||||
|
||||
// ===== TryParseUtc =====
|
||||
|
||||
[Fact]
|
||||
public void TryParseUtc_reads_offset_and_bare_values_as_utc()
|
||||
{
|
||||
Assert.True(QueryRawEventsPagination.TryParseUtc("2026-08-29T20:11:00Z", out var z));
|
||||
Assert.True(QueryRawEventsPagination.TryParseUtc("2026-08-29T22:11:00+02:00", out var offset));
|
||||
Assert.True(QueryRawEventsPagination.TryParseUtc("2026-08-29T20:11:00", out var bare));
|
||||
|
||||
Assert.Equal(Occurred, z);
|
||||
Assert.Equal(Occurred, offset);
|
||||
Assert.Equal(Occurred, bare);
|
||||
Assert.Equal(DateTimeKind.Utc, z!.Value.Kind);
|
||||
Assert.Equal(DateTimeKind.Utc, bare!.Value.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseUtc_treats_empty_as_no_filter_and_garbage_as_error()
|
||||
{
|
||||
Assert.True(QueryRawEventsPagination.TryParseUtc(null, out var none));
|
||||
Assert.Null(none);
|
||||
Assert.True(QueryRawEventsPagination.TryParseUtc(" ", out var blank));
|
||||
Assert.Null(blank);
|
||||
Assert.False(QueryRawEventsPagination.TryParseUtc("yesterday", out _));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,21 +10,6 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
/// </summary>
|
||||
public class RuleHitSessionsFunctionTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(null, 14)] // not supplied → default window
|
||||
[InlineData("", 14)] // empty → default
|
||||
[InlineData("garbage", 14)] // non-numeric → default
|
||||
[InlineData("14", 14)]
|
||||
[InlineData("1", 1)]
|
||||
[InlineData("0", 1)] // below range → clamped up
|
||||
[InlineData("-5", 1)]
|
||||
[InlineData("90", 90)]
|
||||
[InlineData("365", 90)] // above range → clamped down
|
||||
public void ParseDays_Defaults_And_Clamps(string? raw, int expected)
|
||||
{
|
||||
Assert.Equal(expected, RuleHitSessionsFunction.ParseDays(raw));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaxSessionIds_Matches_Repository_Default_Cap()
|
||||
{
|
||||
|
||||
@@ -289,13 +289,4 @@ public class VerdictCalibrationTests
|
||||
Assert.Equal(0, json.GetProperty("totals").GetProperty("sessions").GetInt32());
|
||||
Assert.Equal(JsonValueKind.Null, json.GetProperty("computedAt").ValueKind);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, 30)]
|
||||
[InlineData("abc", 30)]
|
||||
[InlineData("0", 1)]
|
||||
[InlineData("500", 180)]
|
||||
[InlineData("14", 14)]
|
||||
public void ClampDays_bounds_the_window(string? raw, int expected)
|
||||
=> Assert.Equal(expected, VerdictCalibrationResponseBuilder.ClampDays(raw));
|
||||
}
|
||||
|
||||
+3
-7
@@ -57,15 +57,11 @@ namespace AutopilotMonitor.Functions.Functions.Admin
|
||||
var dryRun = !string.Equals(req.Query["dryRun"], "false", StringComparison.OrdinalIgnoreCase);
|
||||
var continuation = req.Query["continuation"];
|
||||
|
||||
var maxRows = DefaultMaxRows;
|
||||
if (!string.IsNullOrEmpty(req.Query["maxRows"]))
|
||||
if (!QueryParams.TryInt(req.Query["maxRows"], "maxRows", 1, MaxMaxRows, out var maxRowsOrNull, out var maxRowsError))
|
||||
{
|
||||
if (!int.TryParse(req.Query["maxRows"], out maxRows) || maxRows < 1)
|
||||
{
|
||||
return await req.BadRequestAsync($"maxRows must be a positive integer (max {MaxMaxRows})");
|
||||
}
|
||||
maxRows = Math.Min(maxRows, MaxMaxRows);
|
||||
return await req.BadRequestAsync(maxRowsError!);
|
||||
}
|
||||
var maxRows = maxRowsOrNull ?? DefaultMaxRows;
|
||||
|
||||
_logger.LogInformation(
|
||||
"OccurredUtc backfill requested by {User}: table={Table} dryRun={DryRun} maxRows={MaxRows} hasContinuation={HasContinuation}",
|
||||
|
||||
@@ -36,9 +36,7 @@ namespace AutopilotMonitor.Functions.Functions.Admin
|
||||
try
|
||||
{
|
||||
var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty);
|
||||
var windowMinutes = DefaultWindowMinutes;
|
||||
if (int.TryParse(query["windowMinutes"], out var parsed))
|
||||
windowMinutes = Math.Clamp(parsed, MinWindowMinutes, MaxWindowMinutes);
|
||||
var windowMinutes = QueryParams.Int(query["windowMinutes"], DefaultWindowMinutes, MinWindowMinutes, MaxWindowMinutes);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var active = await _metricsRepo.GetActivePresenceAsync(TimeSpan.FromMinutes(windowMinutes));
|
||||
|
||||
+3
-7
@@ -59,15 +59,11 @@ namespace AutopilotMonitor.Functions.Functions.Admin
|
||||
return await req.BadRequestAsync("tenantId must be a GUID");
|
||||
}
|
||||
|
||||
var maxSessions = DefaultMaxSessions;
|
||||
if (!string.IsNullOrEmpty(req.Query["maxSessions"]))
|
||||
if (!QueryParams.TryInt(req.Query["maxSessions"], "maxSessions", 1, MaxMaxSessions, out var maxSessionsOrNull, out var maxSessionsError))
|
||||
{
|
||||
if (!int.TryParse(req.Query["maxSessions"], out maxSessions) || maxSessions < 1)
|
||||
{
|
||||
return await req.BadRequestAsync($"maxSessions must be a positive integer (max {MaxMaxSessions})");
|
||||
}
|
||||
maxSessions = Math.Min(maxSessions, MaxMaxSessions);
|
||||
return await req.BadRequestAsync(maxSessionsError!);
|
||||
}
|
||||
var maxSessions = maxSessionsOrNull ?? DefaultMaxSessions;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Legacy reclassification requested by {User}: mode={Mode} dryRun={DryRun} tenantScope={TenantScope} maxSessions={MaxSessions}",
|
||||
|
||||
+7
-11
@@ -51,18 +51,14 @@ namespace AutopilotMonitor.Functions.Functions.Admin
|
||||
var dateParam = req.Query["date"];
|
||||
var aggregateOnlyParam = req.Query["aggregateOnly"];
|
||||
|
||||
DateTime? targetDate = null;
|
||||
if (!string.IsNullOrEmpty(dateParam))
|
||||
if (!QueryParams.TryUtcInstant(dateParam, "date", out var dateInstant, out _))
|
||||
{
|
||||
if (DateTime.TryParse(dateParam, out var parsedDate))
|
||||
{
|
||||
targetDate = parsedDate.Date;
|
||||
_logger.LogInformation($"Manual maintenance for date: {targetDate:yyyy-MM-dd}");
|
||||
}
|
||||
else
|
||||
{
|
||||
return await req.BadRequestAsync("Invalid date format. Use yyyy-MM-dd");
|
||||
}
|
||||
return await req.BadRequestAsync("Invalid date format. Use yyyy-MM-dd");
|
||||
}
|
||||
DateTime? targetDate = dateInstant?.Date;
|
||||
if (targetDate.HasValue)
|
||||
{
|
||||
_logger.LogInformation($"Manual maintenance for date: {targetDate:yyyy-MM-dd}");
|
||||
}
|
||||
|
||||
bool aggregateOnly = aggregateOnlyParam?.ToLower() == "true";
|
||||
|
||||
@@ -51,19 +51,15 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
/// </summary>
|
||||
public static AppsPaging ParseAppsPaging(NameValueCollection query)
|
||||
{
|
||||
var pageSizeRaw = query["pageSize"];
|
||||
if (string.IsNullOrEmpty(pageSizeRaw))
|
||||
if (!QueryParams.TryInt(query["pageSize"], "pageSize", 1, MaxAppsPageSize, out var pageSize, out var error))
|
||||
return new AppsPaging { Error = error };
|
||||
if (pageSize == null)
|
||||
return new AppsPaging { PageSize = null, Skip = 0 };
|
||||
|
||||
if (!int.TryParse(pageSizeRaw, out var pageSize) || pageSize < 1 || pageSize > MaxAppsPageSize)
|
||||
return new AppsPaging { Error = $"pageSize must be between 1 and {MaxAppsPageSize}" };
|
||||
if (!QueryParams.TryInt(query["skip"], "skip", 0, int.MaxValue, out var skip, out error))
|
||||
return new AppsPaging { Error = error };
|
||||
|
||||
var skip = 0;
|
||||
var skipRaw = query["skip"];
|
||||
if (!string.IsNullOrEmpty(skipRaw) && (!int.TryParse(skipRaw, out skip) || skip < 0))
|
||||
return new AppsPaging { Error = "skip must be a non-negative integer" };
|
||||
|
||||
return new AppsPaging { PageSize = pageSize, Skip = skip };
|
||||
return new AppsPaging { PageSize = pageSize, Skip = skip ?? 0 };
|
||||
}
|
||||
|
||||
// ── Data loaders ────────────────────────────────────────────────────
|
||||
|
||||
@@ -47,9 +47,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
}
|
||||
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
int days = 30;
|
||||
if (int.TryParse(query["days"], out var parsedDays) && parsedDays > 0 && parsedDays <= 365)
|
||||
days = parsedDays;
|
||||
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
|
||||
|
||||
var summaries = await AppsAnalyticsHelper.LoadSummariesAsync(_metricsRepo, tenantId, days);
|
||||
// Active duration-regression episodes for this app (fail-soft: empty on error).
|
||||
|
||||
@@ -46,21 +46,15 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
}
|
||||
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
int days = 30;
|
||||
if (int.TryParse(query["days"], out var parsedDays) && parsedDays > 0 && parsedDays <= 365)
|
||||
days = parsedDays;
|
||||
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
|
||||
|
||||
var statusFilter = (query["status"] ?? "all").Trim().ToLowerInvariant();
|
||||
var modelFilter = query["model"];
|
||||
var versionFilter = query["version"];
|
||||
|
||||
int offset = 0;
|
||||
if (int.TryParse(query["offset"], out var parsedOffset) && parsedOffset >= 0)
|
||||
offset = parsedOffset;
|
||||
var offset = QueryParams.Int(query["offset"], @default: 0, min: 0, max: int.MaxValue);
|
||||
|
||||
int limit = DefaultLimit;
|
||||
if (int.TryParse(query["limit"], out var parsedLimit) && parsedLimit > 0)
|
||||
limit = Math.Min(parsedLimit, MaxLimit);
|
||||
var limit = QueryParams.Int(query["limit"], DefaultLimit, 1, MaxLimit);
|
||||
|
||||
var summaries = await AppsAnalyticsHelper.LoadSummariesAsync(_metricsRepo, tenantId, days);
|
||||
var body = await AppsAnalyticsHelper.BuildSessionsResponseAsync(
|
||||
|
||||
@@ -34,9 +34,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
var tenantId = TenantHelper.GetTenantId(req);
|
||||
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
int days = 30;
|
||||
if (int.TryParse(query["days"], out var parsedDays) && parsedDays > 0 && parsedDays <= 365)
|
||||
days = parsedDays;
|
||||
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
|
||||
|
||||
var paging = AppsAnalyticsHelper.ParseAppsPaging(query);
|
||||
if (paging.Error != null)
|
||||
|
||||
+1
-3
@@ -52,9 +52,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
{
|
||||
return await req.BadRequestAsync("tenantId must be a valid GUID");
|
||||
}
|
||||
int days = 30;
|
||||
if (int.TryParse(query["days"], out var parsedDays) && parsedDays > 0 && parsedDays <= 365)
|
||||
days = parsedDays;
|
||||
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Global apps/{App}/analytics requested (user: {User}, tenantId: {TenantId}, days: {Days})",
|
||||
|
||||
+3
-9
@@ -51,21 +51,15 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
{
|
||||
return await req.BadRequestAsync("tenantId must be a valid GUID");
|
||||
}
|
||||
int days = 30;
|
||||
if (int.TryParse(query["days"], out var parsedDays) && parsedDays > 0 && parsedDays <= 365)
|
||||
days = parsedDays;
|
||||
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
|
||||
|
||||
var statusFilter = (query["status"] ?? "all").Trim().ToLowerInvariant();
|
||||
var modelFilter = query["model"];
|
||||
var versionFilter = query["version"];
|
||||
|
||||
int offset = 0;
|
||||
if (int.TryParse(query["offset"], out var parsedOffset) && parsedOffset >= 0)
|
||||
offset = parsedOffset;
|
||||
var offset = QueryParams.Int(query["offset"], @default: 0, min: 0, max: int.MaxValue);
|
||||
|
||||
int limit = DefaultLimit;
|
||||
if (int.TryParse(query["limit"], out var parsedLimit) && parsedLimit > 0)
|
||||
limit = Math.Min(parsedLimit, MaxLimit);
|
||||
var limit = QueryParams.Int(query["limit"], DefaultLimit, 1, MaxLimit);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Global apps/{App}/sessions requested (user: {User}, tenantId: {TenantId})",
|
||||
|
||||
@@ -39,9 +39,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
{
|
||||
return await req.BadRequestAsync("tenantId must be a valid GUID");
|
||||
}
|
||||
int days = 30;
|
||||
if (int.TryParse(query["days"], out var parsedDays) && parsedDays > 0 && parsedDays <= 365)
|
||||
days = parsedDays;
|
||||
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Global apps/list requested (user: {User}, tenantId: {TenantId}, days: {Days})",
|
||||
|
||||
+1
-3
@@ -44,9 +44,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
var requestCtx = req.GetRequestContext();
|
||||
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var max = int.TryParse(query["max"], out var parsed)
|
||||
? Math.Clamp(parsed, 1, MaxListSize)
|
||||
: MaxListSize;
|
||||
var max = QueryParams.Int(query["max"], MaxListSize, 1, MaxListSize);
|
||||
|
||||
var backups = await _backupRepo.ListByPartitionAsync(requestCtx.TargetTenantId, max);
|
||||
|
||||
|
||||
@@ -31,12 +31,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
// Optional time filter: ?days=7 (default: 30, clamped to [1, 365] for consistency
|
||||
// with metrics/summary, metrics/usage, and metrics/platform).
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var daysParam = query["days"];
|
||||
int days = 30;
|
||||
if (!string.IsNullOrEmpty(daysParam) && int.TryParse(daysParam, out var parsedDays) && parsedDays > 0)
|
||||
days = parsedDays;
|
||||
if (days < 1) days = 1;
|
||||
if (days > 365) days = 365;
|
||||
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
|
||||
|
||||
var cutoff = DateTime.UtcNow.AddDays(-days);
|
||||
|
||||
|
||||
+2
-8
@@ -124,7 +124,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware
|
||||
var tenantId = TenantHelper.GetTenantId(req);
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var days = DeviceJourneyMetricsResponseBuilder.ClampDays(query["days"]);
|
||||
var days = QueryParams.Int(query["days"], DeviceJourneyMetricsResponseBuilder.DefaultWindowDays, 1, DeviceJourneyMetricsResponseBuilder.MaxWindowDays);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(await DeviceJourneyMetricsResponseBuilder.BuildAsync(
|
||||
@@ -169,7 +169,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
// Authentication + GlobalReadOrAdmin authorization enforced by PolicyEnforcementMiddleware
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var tenantIdFilter = query["tenantId"];
|
||||
var days = DeviceJourneyMetricsResponseBuilder.ClampDays(query["days"]);
|
||||
var days = QueryParams.Int(query["days"], DeviceJourneyMetricsResponseBuilder.DefaultWindowDays, 1, DeviceJourneyMetricsResponseBuilder.MaxWindowDays);
|
||||
var partition = string.IsNullOrWhiteSpace(tenantIdFilter) ? "global" : tenantIdFilter!;
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
@@ -196,12 +196,6 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
internal const int MaxWindowDays = 180; // aggregate retention — older rows no longer exist
|
||||
internal const int MaxRepeatDevices = 10;
|
||||
|
||||
internal static int ClampDays(string? raw)
|
||||
{
|
||||
if (!int.TryParse(raw, out var days)) return DefaultWindowDays;
|
||||
return Math.Clamp(days, 1, MaxWindowDays);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// "Last N days" = exactly N calendar day keys including today — both range ends are
|
||||
/// inclusive, so subtracting the full N would return N+1 days (Codex review: days=1
|
||||
|
||||
+1
-6
@@ -35,12 +35,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
var tenantId = TenantHelper.GetTenantId(req);
|
||||
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var daysParam = query["days"];
|
||||
int days = 30;
|
||||
if (!string.IsNullOrEmpty(daysParam) && int.TryParse(daysParam, out var parsedDays) && parsedDays > 0)
|
||||
days = parsedDays;
|
||||
if (days < 1) days = 1;
|
||||
if (days > 365) days = 365;
|
||||
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
|
||||
|
||||
_logger.LogInformation("Fetching fleet health for tenant {TenantId} (days={Days})", tenantId, days);
|
||||
|
||||
|
||||
+1
-6
@@ -46,12 +46,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
return await req.BadRequestAsync("locationKey or country parameter is required");
|
||||
}
|
||||
|
||||
var daysParam = query["days"];
|
||||
int days = 30;
|
||||
if (!string.IsNullOrEmpty(daysParam) && int.TryParse(daysParam, out var parsedDays) && parsedDays > 0)
|
||||
days = parsedDays;
|
||||
if (days < 1) days = 1;
|
||||
if (days > 365) days = 365;
|
||||
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
|
||||
|
||||
var groupBy = query["groupBy"] ?? "city";
|
||||
// Default response shape is lean (~15 fields/row) so MCP-driven callers fit
|
||||
|
||||
+1
-6
@@ -36,12 +36,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
_logger.LogInformation("Fetching geographic metrics for tenant {TenantId}", tenantId);
|
||||
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var daysParam = query["days"];
|
||||
int days = 30;
|
||||
if (!string.IsNullOrEmpty(daysParam) && int.TryParse(daysParam, out var parsedDays) && parsedDays > 0)
|
||||
days = parsedDays;
|
||||
if (days < 1) days = 1;
|
||||
if (days > 365) days = 365;
|
||||
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
|
||||
|
||||
var groupBy = query["groupBy"] ?? "city";
|
||||
|
||||
|
||||
+2
-12
@@ -35,8 +35,8 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
// Authentication + GlobalReadOrAdmin authorization enforced by PolicyEnforcementMiddleware
|
||||
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var days = ParseIntParam(query["days"], defaultValue: 30, min: 1, max: 365);
|
||||
var limit = ParseIntParam(query["limit"], defaultValue: 500, min: 1, max: 2000);
|
||||
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
|
||||
var limit = QueryParams.Int(query["limit"], @default: 500, min: 1, max: 2000);
|
||||
|
||||
var tenantIdRaw = query["tenantId"];
|
||||
string? tenantId = null;
|
||||
@@ -62,15 +62,5 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
return await req.InternalServerErrorAsync(_logger, ex, "GetGlobalAgentEfficiency");
|
||||
}
|
||||
}
|
||||
|
||||
private static int ParseIntParam(string? raw, int defaultValue, int min, int max)
|
||||
{
|
||||
var value = defaultValue;
|
||||
if (!string.IsNullOrEmpty(raw) && int.TryParse(raw, out var parsed) && parsed > 0)
|
||||
value = parsed;
|
||||
if (value < min) value = min;
|
||||
if (value > max) value = max;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-6
@@ -32,14 +32,9 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
_logger.LogInformation($"Fetching global app metrics (User: {userEmail})");
|
||||
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var daysParam = query["days"];
|
||||
int days = 30;
|
||||
if (!string.IsNullOrEmpty(daysParam) && int.TryParse(daysParam, out var parsedDays) && parsedDays > 0)
|
||||
days = parsedDays;
|
||||
// Clamped to [1, 365] like the tenant variant and the other metrics endpoints — an
|
||||
// unbounded days= here was the one way to request an unlimited cross-tenant scan.
|
||||
if (days < 1) days = 1;
|
||||
if (days > 365) days = 365;
|
||||
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
|
||||
|
||||
var tenantIdFilter = query["tenantId"];
|
||||
|
||||
|
||||
+1
-6
@@ -35,12 +35,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
var userEmail = TenantHelper.GetUserIdentifier(req);
|
||||
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var daysParam = query["days"];
|
||||
int days = 30;
|
||||
if (!string.IsNullOrEmpty(daysParam) && int.TryParse(daysParam, out var parsedDays) && parsedDays > 0)
|
||||
days = parsedDays;
|
||||
if (days < 1) days = 1;
|
||||
if (days > 365) days = 365;
|
||||
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
|
||||
|
||||
var tenantIdFilter = query["tenantId"];
|
||||
|
||||
|
||||
+1
-4
@@ -45,10 +45,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
return await req.BadRequestAsync("locationKey or country parameter is required");
|
||||
}
|
||||
|
||||
var daysParam = query["days"];
|
||||
int days = 30;
|
||||
if (!string.IsNullOrEmpty(daysParam) && int.TryParse(daysParam, out var parsedDays) && parsedDays > 0)
|
||||
days = parsedDays;
|
||||
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
|
||||
|
||||
var groupBy = query["groupBy"] ?? "city";
|
||||
var full = string.Equals(query["full"], "1", StringComparison.Ordinal);
|
||||
|
||||
+1
-4
@@ -36,10 +36,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
_logger.LogInformation("Fetching global geographic metrics (User: {UserEmail})", userEmail);
|
||||
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var daysParam = query["days"];
|
||||
int days = 30;
|
||||
if (!string.IsNullOrEmpty(daysParam) && int.TryParse(daysParam, out var parsedDays) && parsedDays > 0)
|
||||
days = parsedDays;
|
||||
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
|
||||
|
||||
var groupBy = query["groupBy"] ?? "city";
|
||||
var tenantIdFilter = query["tenantId"];
|
||||
|
||||
+7
-32
@@ -34,8 +34,13 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
{
|
||||
// Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware
|
||||
|
||||
var days = ParseDays(req);
|
||||
var limit = ParseLimit(req);
|
||||
var days = QueryParams.Int(req.Query["days"], @default: 90, min: 1, max: 365);
|
||||
// limit drives the per-session work the service performs (each session
|
||||
// triggers its own GetSessionEventsAsync call). The UI dropdown sets it
|
||||
// explicitly so the user controls the analysis depth (e.g. 90 days x 1000
|
||||
// sessions); the service bounds its concurrency internally, and the hard
|
||||
// ceiling of 2000 protects against runaway query strings.
|
||||
var limit = QueryParams.Int(req.Query["limit"], @default: 100, min: 1, max: 2000);
|
||||
var metrics = await _platformMetricsService.ComputePlatformMetricsAsync(days, limit);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
@@ -48,35 +53,5 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
return await req.InternalServerErrorAsync(_logger, ex, "GetGlobalPlatformMetrics");
|
||||
}
|
||||
}
|
||||
|
||||
private static int ParseDays(HttpRequestData req)
|
||||
{
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var raw = query["days"];
|
||||
var days = 90;
|
||||
if (!string.IsNullOrEmpty(raw) && int.TryParse(raw, out var parsed) && parsed > 0)
|
||||
days = parsed;
|
||||
if (days < 1) days = 1;
|
||||
if (days > 365) days = 365;
|
||||
return days;
|
||||
}
|
||||
|
||||
// Drives the per-session work the service performs (each session
|
||||
// triggers its own GetSessionEventsAsync call). The UI dropdown sets
|
||||
// this explicitly so the user controls the analysis depth (e.g.
|
||||
// 90 days × 1000 sessions). The service uses bounded concurrency
|
||||
// internally so even large limits don't blow the Function timeout.
|
||||
// Hard ceiling 2000 protects against runaway query strings.
|
||||
private static int ParseLimit(HttpRequestData req)
|
||||
{
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var raw = query["limit"];
|
||||
var limit = 100;
|
||||
if (!string.IsNullOrEmpty(raw) && int.TryParse(raw, out var parsed) && parsed > 0)
|
||||
limit = parsed;
|
||||
if (limit < 1) limit = 1;
|
||||
if (limit > 2000) limit = 2000;
|
||||
return limit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -50,8 +50,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
return await req.BadRequestAsync("tenantId query parameter is required");
|
||||
}
|
||||
|
||||
if (int.TryParse(qs.Get("months"), out var parsedMonths))
|
||||
months = parsedMonths;
|
||||
months = QueryParams.Int(qs.Get("months"), months, 1, SlaMetricsService.MaxMonths);
|
||||
|
||||
fresh = qs.Get("fresh") == "1";
|
||||
|
||||
|
||||
+1
-7
@@ -42,7 +42,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
// Authentication + GlobalReadOrAdmin authorization enforced by PolicyEnforcementMiddleware
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var tenantIdFilter = query["tenantId"];
|
||||
var days = VerdictCalibrationResponseBuilder.ClampDays(query["days"]);
|
||||
var days = QueryParams.Int(query["days"], VerdictCalibrationResponseBuilder.DefaultWindowDays, 1, VerdictCalibrationResponseBuilder.MaxWindowDays);
|
||||
var partition = "global";
|
||||
if (!string.IsNullOrWhiteSpace(tenantIdFilter))
|
||||
{
|
||||
@@ -85,12 +85,6 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
internal const int TrendBaselineDays = 28;
|
||||
internal const int TrendHorizonDays = TrendWindowDays + TrendBaselineDays;
|
||||
|
||||
internal static int ClampDays(string? raw)
|
||||
{
|
||||
if (!int.TryParse(raw, out var days)) return DefaultWindowDays;
|
||||
return Math.Clamp(days, 1, MaxWindowDays);
|
||||
}
|
||||
|
||||
/// <summary>"Last N days" = exactly N calendar day keys including today (both range ends inclusive).</summary>
|
||||
internal static DateTime InclusiveWindowStart(DateTime today, int days) => today.AddDays(-(days - 1));
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ public class MetricsSummaryFunction
|
||||
try
|
||||
{
|
||||
var tenantId = TenantHelper.GetTenantId(req);
|
||||
var days = ParseDays(req);
|
||||
var days = QueryParams.Int(req.Query["days"], @default: 30, min: 1, max: 365);
|
||||
var summary = await _metricsRepo.GetMetricsSummaryAsync(tenantId, days);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
@@ -45,7 +45,7 @@ public class MetricsSummaryFunction
|
||||
{
|
||||
try
|
||||
{
|
||||
var days = ParseDays(req);
|
||||
var days = QueryParams.Int(req.Query["days"], @default: 30, min: 1, max: 365);
|
||||
// Optional tenantId filter — when set, GA scopes the cross-tenant summary to one
|
||||
// tenant. When absent, returns the full cross-tenant view (null → all tenants).
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
@@ -73,16 +73,4 @@ public class MetricsSummaryFunction
|
||||
return await req.InternalServerErrorAsync(_logger, ex, "Get global metrics summary");
|
||||
}
|
||||
}
|
||||
|
||||
private static int ParseDays(HttpRequestData req)
|
||||
{
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var raw = query["days"];
|
||||
var days = 30;
|
||||
if (!string.IsNullOrEmpty(raw) && int.TryParse(raw, out var parsed) && parsed > 0)
|
||||
days = parsed;
|
||||
if (days < 1) days = 1;
|
||||
if (days > 365) days = 365;
|
||||
return days;
|
||||
}
|
||||
}
|
||||
|
||||
+1
-12
@@ -41,7 +41,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
// Optional tenantId query parameter: when provided, return tenant-specific metrics
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var tenantId = query["tenantId"];
|
||||
var days = ParseDays(query);
|
||||
var days = QueryParams.Int(query["days"], @default: 90, min: 1, max: 365);
|
||||
|
||||
var metrics = !string.IsNullOrEmpty(tenantId)
|
||||
? await _usageMetricsService.ComputeTenantUsageMetricsAsync(tenantId, days)
|
||||
@@ -57,16 +57,5 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
return await req.InternalServerErrorAsync(_logger, ex, "PlatformUsageMetrics");
|
||||
}
|
||||
}
|
||||
|
||||
private static int ParseDays(System.Collections.Specialized.NameValueCollection query)
|
||||
{
|
||||
var raw = query["days"];
|
||||
var days = 90;
|
||||
if (!string.IsNullOrEmpty(raw) && int.TryParse(raw, out var parsed) && parsed > 0)
|
||||
days = parsed;
|
||||
if (days < 1) days = 1;
|
||||
if (days > 365) days = 365;
|
||||
return days;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-12
@@ -48,7 +48,9 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
return await req.BadRequestAsync("ruleId is required");
|
||||
}
|
||||
|
||||
var days = ParseDays(query["days"]);
|
||||
// Window in days: default 14, capped at 90 (RuleResults follow the session
|
||||
// retention cascade, so anything past 90 days cannot widen the result).
|
||||
var days = QueryParams.Int(query["days"], @default: 14, min: 1, max: 90);
|
||||
var sinceUtc = DateTime.UtcNow.AddDays(-days);
|
||||
var sessionIds = await _ruleRepo.GetRuleHitSessionIdsAsync(
|
||||
requestCtx.TargetTenantId, ruleId, sinceUtc, MaxSessionIds);
|
||||
@@ -68,16 +70,5 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
return await req.InternalServerErrorAsync(_logger, ex, "RuleHitSessions");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Window in days: default 14, clamped to 1..90 (RuleResults follow the session
|
||||
/// retention cascade, so anything past 90 days cannot widen the result).
|
||||
/// </summary>
|
||||
internal static int ParseDays(string? raw)
|
||||
{
|
||||
if (string.IsNullOrEmpty(raw) || !int.TryParse(raw, out var parsed))
|
||||
return 14;
|
||||
return Math.Clamp(parsed, 1, 90);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,8 +43,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
{
|
||||
var qs = System.Web.HttpUtility.ParseQueryString(req.Url.Query ?? "");
|
||||
|
||||
if (int.TryParse(qs.Get("months"), out var parsedMonths))
|
||||
months = parsedMonths;
|
||||
months = QueryParams.Int(qs.Get("months"), months, 1, SlaMetricsService.MaxMonths);
|
||||
|
||||
fresh = qs.Get("fresh") == "1";
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
string tenantId = TenantHelper.GetTenantId(req);
|
||||
string userIdentifier = TenantHelper.GetUserIdentifier(req);
|
||||
|
||||
var days = ParseDays(req);
|
||||
var days = QueryParams.Int(req.Query["days"], @default: 90, min: 1, max: 365);
|
||||
_logger.LogInformation("Fetching usage metrics for tenant {TenantId} by user {User} (days={Days})", tenantId, userIdentifier, days);
|
||||
|
||||
var metrics = await _usageMetricsService.ComputeTenantUsageMetricsAsync(tenantId, days);
|
||||
@@ -55,17 +55,5 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
return await req.InternalServerErrorAsync(_logger, ex, "UsageMetrics");
|
||||
}
|
||||
}
|
||||
|
||||
private static int ParseDays(HttpRequestData req)
|
||||
{
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var raw = query["days"];
|
||||
var days = 90;
|
||||
if (!string.IsNullOrEmpty(raw) && int.TryParse(raw, out var parsed) && parsed > 0)
|
||||
days = parsed;
|
||||
if (days < 1) days = 1;
|
||||
if (days > 365) days = 365;
|
||||
return days;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,10 +87,13 @@ namespace AutopilotMonitor.Functions.Functions.Raw
|
||||
|
||||
// Date window: an unparsable value is an error, not a silently dropped filter —
|
||||
// a caller that believes it narrowed the window must never get the whole table.
|
||||
if (!QueryRawEventsPagination.TryParseUtc(startedAfter, out var afterUtc)
|
||||
|| !QueryRawEventsPagination.TryParseUtc(startedBefore, out var beforeUtc))
|
||||
if (!QueryParams.TryUtcInstant(startedAfter, "startedAfter", out var afterUtc, out var dateError))
|
||||
{
|
||||
return await req.BadRequestAsync("startedAfter/startedBefore must be ISO 8601 datetimes");
|
||||
return await req.BadRequestAsync(dateError!);
|
||||
}
|
||||
if (!QueryParams.TryUtcInstant(startedBefore, "startedBefore", out var beforeUtc, out dateError))
|
||||
{
|
||||
return await req.BadRequestAsync(dateError!);
|
||||
}
|
||||
|
||||
var callerTenantId = TenantHelper.GetTenantId(req);
|
||||
|
||||
@@ -115,10 +115,8 @@ namespace AutopilotMonitor.Functions.Functions.Raw
|
||||
OsBuild = string.IsNullOrEmpty(osBuild) ? null : osBuild,
|
||||
GeoCountry = string.IsNullOrEmpty(geoCountry) ? null : geoCountry,
|
||||
};
|
||||
if (!string.IsNullOrEmpty(startedAfter) && DateTime.TryParse(startedAfter, out var after))
|
||||
filter.StartedAfter = after;
|
||||
if (!string.IsNullOrEmpty(startedBefore) && DateTime.TryParse(startedBefore, out var before))
|
||||
filter.StartedBefore = before;
|
||||
filter.StartedAfter = QueryParams.UtcInstant(startedAfter);
|
||||
filter.StartedBefore = QueryParams.UtcInstant(startedBefore);
|
||||
if (!string.IsNullOrEmpty(isPreProvisioned) && bool.TryParse(isPreProvisioned, out var pp))
|
||||
filter.IsPreProvisioned = pp;
|
||||
if (!string.IsNullOrEmpty(isHybridJoin) && bool.TryParse(isHybridJoin, out var hj))
|
||||
@@ -127,8 +125,8 @@ namespace AutopilotMonitor.Functions.Functions.Raw
|
||||
filter.IsSelfDeployingProfile = sdp;
|
||||
if (!string.IsNullOrEmpty(isCloudPc) && bool.TryParse(isCloudPc, out var icp))
|
||||
filter.IsCloudPc = icp;
|
||||
if (int.TryParse(query["rebootCountMin"], out var rcMin)) filter.RebootCountMin = rcMin;
|
||||
if (int.TryParse(query["rebootCountMax"], out var rcMax)) filter.RebootCountMax = rcMax;
|
||||
filter.RebootCountMin = QueryParams.IntOrNull(query["rebootCountMin"]);
|
||||
filter.RebootCountMax = QueryParams.IntOrNull(query["rebootCountMax"]);
|
||||
var connectionType = query["connectionType"];
|
||||
if (!string.IsNullOrEmpty(connectionType)) filter.ConnectionType = connectionType;
|
||||
|
||||
|
||||
+7
-12
@@ -61,19 +61,14 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
return await req.BadRequestAsync("Query parameter 'state' must be one of: Preparing, Queued, Running, Poisoned.");
|
||||
}
|
||||
|
||||
int? strandedSinceMinutes = null;
|
||||
var strandedRaw = query["strandedSinceMinutes"];
|
||||
if (!string.IsNullOrEmpty(strandedRaw))
|
||||
// 10080 minutes = 7 days.
|
||||
if (!QueryParams.TryInt(query["strandedSinceMinutes"], "strandedSinceMinutes", 1, 10080, out var strandedSinceMinutes, out var strandedError))
|
||||
{
|
||||
if (state != SessionDeletionState.Queued)
|
||||
{
|
||||
return await req.BadRequestAsync("strandedSinceMinutes is only valid with state=Queued.");
|
||||
}
|
||||
if (!int.TryParse(strandedRaw, out var minutes) || minutes <= 0 || minutes > 10080)
|
||||
{
|
||||
return await req.BadRequestAsync("strandedSinceMinutes must be a positive integer up to 10080 (7 days).");
|
||||
}
|
||||
strandedSinceMinutes = minutes;
|
||||
return await req.BadRequestAsync(strandedError!);
|
||||
}
|
||||
if (strandedSinceMinutes.HasValue && state != SessionDeletionState.Queued)
|
||||
{
|
||||
return await req.BadRequestAsync("strandedSinceMinutes is only valid with state=Queued.");
|
||||
}
|
||||
|
||||
var cutoffUtc = strandedSinceMinutes.HasValue
|
||||
|
||||
+3
-13
@@ -65,19 +65,9 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
|
||||
internal static bool TryParseDays(string? raw, out int days, out string? error)
|
||||
{
|
||||
days = DefaultDays;
|
||||
error = null;
|
||||
if (string.IsNullOrEmpty(raw)) return true;
|
||||
|
||||
if (!int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
|
||||
|| parsed < 1 || parsed > MaxDays)
|
||||
{
|
||||
error = $"days must be a positive integer between 1 and {MaxDays}";
|
||||
return false;
|
||||
}
|
||||
|
||||
days = parsed;
|
||||
return true;
|
||||
var ok = QueryParams.TryInt(raw, "days", 1, MaxDays, out var parsed, out error);
|
||||
days = parsed ?? DefaultDays;
|
||||
return ok;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,10 +158,10 @@ public class SearchSessionsFunction
|
||||
if (bool.TryParse(query["isHybridJoin"], out var ihj)) filter.IsHybridJoin = ihj;
|
||||
if (bool.TryParse(query["isSelfDeployingProfile"], out var isdp)) filter.IsSelfDeployingProfile = isdp;
|
||||
if (bool.TryParse(query["isCloudPc"], out var icp)) filter.IsCloudPc = icp;
|
||||
if (DateTime.TryParse(query["startedAfter"], out var sa)) filter.StartedAfter = sa;
|
||||
if (DateTime.TryParse(query["startedBefore"], out var sb)) filter.StartedBefore = sb;
|
||||
if (int.TryParse(query["rebootCountMin"], out var rcMin)) filter.RebootCountMin = rcMin;
|
||||
if (int.TryParse(query["rebootCountMax"], out var rcMax)) filter.RebootCountMax = rcMax;
|
||||
filter.StartedAfter = QueryParams.UtcInstant(query["startedAfter"]);
|
||||
filter.StartedBefore = QueryParams.UtcInstant(query["startedBefore"]);
|
||||
filter.RebootCountMin = QueryParams.IntOrNull(query["rebootCountMin"]);
|
||||
filter.RebootCountMax = QueryParams.IntOrNull(query["rebootCountMax"]);
|
||||
|
||||
// Dynamic device property filters: any query param starting with "prop."
|
||||
var deviceProperties = new Dictionary<string, string>();
|
||||
|
||||
+2
-6
@@ -51,12 +51,8 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
var userIdentifier = TenantHelper.GetUserIdentifier(req);
|
||||
|
||||
var query = HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var skip = 0;
|
||||
var take = DefaultTake;
|
||||
if (int.TryParse(query["skip"], out var parsedSkip) && parsedSkip >= 0)
|
||||
skip = parsedSkip;
|
||||
if (int.TryParse(query["take"], out var parsedTake) && parsedTake >= 1)
|
||||
take = Math.Min(parsedTake, MaxTake);
|
||||
var skip = QueryParams.Int(query["skip"], @default: 0, min: 0, max: int.MaxValue);
|
||||
var take = QueryParams.Int(query["take"], DefaultTake, 1, MaxTake);
|
||||
|
||||
// Query all SoftwareInventory entries across tenants and filter for unmatched (no CpeUri).
|
||||
// Server-side filtering on CpeUri is NOT possible: rows re-created by the cascade-delete
|
||||
|
||||
+2
-2
@@ -66,8 +66,8 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
try
|
||||
{
|
||||
var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty);
|
||||
var days = int.TryParse(query["days"], out var d) ? Math.Clamp(d, 1, 365) : 30;
|
||||
var topN = int.TryParse(query["topN"], out var t) ? Math.Clamp(t, 1, 100) : 20;
|
||||
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
|
||||
var topN = QueryParams.Int(query["topN"], @default: 20, min: 1, max: 100);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Vulnerability summary requested (scope={Scope}, days={Days}, topN={TopN})",
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
using System.Collections.Specialized;
|
||||
using System.Globalization;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// The one parser for HTTP query parameters. Every function reads its numbers and instants
|
||||
/// through here (<c>QueryParamGuardTests</c> fails on a stray <c>int.TryParse</c> /
|
||||
/// <c>DateTime.TryParse</c> under <c>Functions/</c> or <c>Pagination/</c>); the default and
|
||||
/// the range stay arguments at the call site because they are per-endpoint wire behaviour
|
||||
/// (a 7-day stats window and a 90-day usage window are both right).
|
||||
/// <para>
|
||||
/// Two contracts, deliberately not one:
|
||||
/// <list type="bullet">
|
||||
/// <item><b>Lenient</b> (<see cref="Int(string?, int, int, int)"/>, <see cref="UtcInstant"/>,
|
||||
/// <see cref="IntOrNull"/>): a missing, unparseable or below-minimum value means "use the
|
||||
/// default", a value above the maximum is clamped. For windows and top-N knobs where a
|
||||
/// dashboard link with a stale or odd value should still render.</item>
|
||||
/// <item><b>Strict</b> (<see cref="TryInt"/>, <see cref="TryPageSize"/>,
|
||||
/// <see cref="TryUtcInstant"/>): a missing value is <c>null</c> (the caller applies its
|
||||
/// default), anything else that does not parse or falls outside the range is an error
|
||||
/// message for a 400. For pagination and admin batch sizes, where a silently altered
|
||||
/// value would misreport what was done.</item>
|
||||
/// </list>
|
||||
/// Numbers are parsed invariant (<see cref="NumberStyles.Integer"/>); instants are parsed
|
||||
/// invariant with <c>AssumeUniversal | AdjustToUniversal</c>, so <c>2026-04-20</c>,
|
||||
/// <c>2026-04-20T10:00:00</c> and <c>2026-04-20T12:00:00+02:00</c> all yield the same UTC
|
||||
/// value with <see cref="DateTimeKind.Utc"/>. Whitespace-only counts as absent.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class QueryParams
|
||||
{
|
||||
/// <summary>Upper bound of every <c>pageSize</c> parameter (continuation-token pagination).</summary>
|
||||
public const int MaxPageSize = 1000;
|
||||
|
||||
private const DateTimeStyles UtcStyles = DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal;
|
||||
|
||||
// ── Lenient ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Lenient integer: absent / unparseable / below <paramref name="min"/> ⇒ <paramref name="default"/>; above <paramref name="max"/> ⇒ <paramref name="max"/>.</summary>
|
||||
public static int Int(string? raw, int @default, int min, int max)
|
||||
{
|
||||
if (!TryParseInvariant(raw, out var value) || value < min) return @default;
|
||||
return value > max ? max : value;
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="Int(string?, int, int, int)"/>
|
||||
public static int Int(NameValueCollection? query, string name, int @default, int min, int max)
|
||||
=> Int(query?[name], @default, min, max);
|
||||
|
||||
/// <summary>Lenient optional integer without a range: absent or unparseable ⇒ <c>null</c>.</summary>
|
||||
public static int? IntOrNull(string? raw)
|
||||
=> TryParseInvariant(raw, out var value) ? value : (int?)null;
|
||||
|
||||
/// <summary>Lenient optional instant: absent or unparseable ⇒ <c>null</c>, otherwise UTC.</summary>
|
||||
public static DateTime? UtcInstant(string? raw)
|
||||
=> TryParseUtc(raw, out var value) ? value : (DateTime?)null;
|
||||
|
||||
// ── Strict ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Strict integer: absent ⇒ <c>null</c> + true; unparseable or outside
|
||||
/// [<paramref name="min"/>, <paramref name="max"/>] ⇒ false with an error message that names
|
||||
/// the parameter. Pass <c>int.MaxValue</c> as <paramref name="max"/> for "at least min".
|
||||
/// </summary>
|
||||
public static bool TryInt(string? raw, string name, int min, int max, out int? value, out string? error)
|
||||
{
|
||||
value = null;
|
||||
error = null;
|
||||
if (string.IsNullOrWhiteSpace(raw)) return true;
|
||||
|
||||
if (!TryParseInvariant(raw, out var parsed))
|
||||
{
|
||||
error = $"{name} must be an integer";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (parsed < min || parsed > max)
|
||||
{
|
||||
error = max == int.MaxValue
|
||||
? $"{name} must be an integer of at least {min}"
|
||||
: $"{name} must be between {min} and {max}";
|
||||
return false;
|
||||
}
|
||||
|
||||
value = parsed;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Strict <c>pageSize</c>: absent ⇒ <c>null</c> (the paginator applies its default), otherwise 1..<see cref="MaxPageSize"/>.</summary>
|
||||
public static bool TryPageSize(string? raw, out int? pageSize, out string? error)
|
||||
=> TryInt(raw, "pageSize", 1, MaxPageSize, out pageSize, out error);
|
||||
|
||||
/// <summary>Strict instant: absent ⇒ <c>null</c> + true; unparseable ⇒ false with an error message; otherwise UTC.</summary>
|
||||
public static bool TryUtcInstant(string? raw, string name, out DateTime? value, out string? error)
|
||||
{
|
||||
value = null;
|
||||
error = null;
|
||||
if (string.IsNullOrWhiteSpace(raw)) return true;
|
||||
|
||||
if (!TryParseUtc(raw, out var parsed))
|
||||
{
|
||||
error = $"{name} must be an ISO 8601 date or date-time";
|
||||
return false;
|
||||
}
|
||||
|
||||
value = parsed;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Core ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private static bool TryParseInvariant(string? raw, out int value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
value = 0;
|
||||
return false;
|
||||
}
|
||||
return int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out value);
|
||||
}
|
||||
|
||||
private static bool TryParseUtc(string? raw, out DateTime value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
if (!DateTime.TryParse(raw, CultureInfo.InvariantCulture, UtcStyles, out var parsed))
|
||||
{
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
// AdjustToUniversal yields Kind=Utc; SpecifyKind is belt and braces for the one
|
||||
// shape (a bare date) where some runtimes leave it Unspecified.
|
||||
value = parsed.Kind == DateTimeKind.Utc ? parsed : DateTime.SpecifyKind(parsed, DateTimeKind.Utc);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using AutopilotMonitor.Shared.Pagination;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Pagination
|
||||
{
|
||||
@@ -35,7 +36,6 @@ namespace AutopilotMonitor.Functions.Pagination
|
||||
public static class DateWindowPagination
|
||||
{
|
||||
public const int DefaultPageSize = 200;
|
||||
public const int MaxPageSize = 1000;
|
||||
public static readonly TimeSpan DefaultWindow = TimeSpan.FromDays(30);
|
||||
|
||||
/// <summary>
|
||||
@@ -67,19 +67,10 @@ namespace AutopilotMonitor.Functions.Pagination
|
||||
var pageSizeRaw = query?["pageSize"];
|
||||
var continuationRaw = query?["continuation"];
|
||||
|
||||
DateTime? dateFrom = null, dateTo = null;
|
||||
if (!string.IsNullOrEmpty(dateFromRaw))
|
||||
{
|
||||
if (!TryParseUtc(dateFromRaw!, out var parsed))
|
||||
return new Parsed { Error = "dateFrom must be ISO 8601" };
|
||||
dateFrom = parsed;
|
||||
}
|
||||
if (!string.IsNullOrEmpty(dateToRaw))
|
||||
{
|
||||
if (!TryParseUtc(dateToRaw!, out var parsed))
|
||||
return new Parsed { Error = "dateTo must be ISO 8601" };
|
||||
dateTo = parsed;
|
||||
}
|
||||
if (!QueryParams.TryUtcInstant(dateFromRaw, "dateFrom", out var dateFrom, out var dateError))
|
||||
return new Parsed { Error = dateError };
|
||||
if (!QueryParams.TryUtcInstant(dateToRaw, "dateTo", out var dateTo, out dateError))
|
||||
return new Parsed { Error = dateError };
|
||||
if (dateFrom.HasValue && dateTo.HasValue && dateFrom > dateTo)
|
||||
{
|
||||
return new Parsed { Error = "dateFrom must be <= dateTo" };
|
||||
@@ -97,15 +88,8 @@ namespace AutopilotMonitor.Functions.Pagination
|
||||
dateTo = anchor;
|
||||
}
|
||||
|
||||
int? pageSize = null;
|
||||
if (!string.IsNullOrEmpty(pageSizeRaw))
|
||||
{
|
||||
if (!int.TryParse(pageSizeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n))
|
||||
return new Parsed { Error = "pageSize must be an integer" };
|
||||
if (n < 1 || n > MaxPageSize)
|
||||
return new Parsed { Error = $"pageSize must be between 1 and {MaxPageSize}" };
|
||||
pageSize = n;
|
||||
}
|
||||
if (!QueryParams.TryPageSize(pageSizeRaw, out var pageSize, out var pageSizeError))
|
||||
return new Parsed { Error = pageSizeError };
|
||||
|
||||
// continuation is meaningless without pageSize — silently drop.
|
||||
var continuation = pageSize.HasValue && !string.IsNullOrEmpty(continuationRaw)
|
||||
@@ -196,23 +180,5 @@ namespace AutopilotMonitor.Functions.Pagination
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static bool TryParseUtc(string s, out DateTime result)
|
||||
{
|
||||
// Accept ISO 8601 in any of the standard forms; force UTC.
|
||||
// AdjustToUniversal + AssumeUniversal handles strings without a TZ
|
||||
// offset (treat as UTC) and converts strings with an offset to UTC;
|
||||
// RoundtripKind is mutually exclusive with these and not needed.
|
||||
if (DateTime.TryParse(
|
||||
s, CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal,
|
||||
out var parsed))
|
||||
{
|
||||
result = parsed.Kind == DateTimeKind.Utc ? parsed : DateTime.SpecifyKind(parsed, DateTimeKind.Utc);
|
||||
return true;
|
||||
}
|
||||
result = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Collections.Specialized;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using AutopilotMonitor.Shared.Pagination;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Pagination
|
||||
{
|
||||
@@ -32,7 +33,6 @@ namespace AutopilotMonitor.Functions.Pagination
|
||||
public static class QueryRawEventsPagination
|
||||
{
|
||||
public const int DefaultPageSize = 200;
|
||||
public const int MaxPageSize = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// Slack between an event's sanitized time and the write time of its EventTypeIndex
|
||||
@@ -48,22 +48,6 @@ namespace AutopilotMonitor.Functions.Pagination
|
||||
public static DateTime? IndexWrittenAfterHint(DateTime? startedAfterUtc)
|
||||
=> startedAfterUtc.HasValue ? startedAfterUtc.Value - IndexWriteTimeSlack : null;
|
||||
|
||||
/// <summary>
|
||||
/// Parses an ISO-8601 query value as a UTC instant. Returns false only for a non-empty
|
||||
/// value that does not parse — an absent/empty value yields <c>null</c> and true.
|
||||
/// A bare (offset-less) value is taken as UTC, never as server-local time.
|
||||
/// </summary>
|
||||
public static bool TryParseUtc(string? raw, out DateTime? utc)
|
||||
{
|
||||
utc = null;
|
||||
if (string.IsNullOrWhiteSpace(raw)) return true;
|
||||
if (!DateTime.TryParse(raw, CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed))
|
||||
return false;
|
||||
utc = parsed;
|
||||
return true;
|
||||
}
|
||||
|
||||
public static string Fingerprint(
|
||||
string scope,
|
||||
string callerTenantId,
|
||||
@@ -104,15 +88,9 @@ namespace AutopilotMonitor.Functions.Pagination
|
||||
var pageSizeRaw = query?["pageSize"];
|
||||
var continuationRaw = query?["continuation"];
|
||||
|
||||
int pageSize = DefaultPageSize;
|
||||
if (!string.IsNullOrEmpty(pageSizeRaw))
|
||||
{
|
||||
if (!int.TryParse(pageSizeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n))
|
||||
return new Parsed { PageSize = DefaultPageSize, Error = "pageSize must be an integer" };
|
||||
if (n < 1 || n > MaxPageSize)
|
||||
return new Parsed { PageSize = DefaultPageSize, Error = $"pageSize must be between 1 and {MaxPageSize}" };
|
||||
pageSize = n;
|
||||
}
|
||||
if (!QueryParams.TryPageSize(pageSizeRaw, out var pageSizeOrNull, out var pageSizeError))
|
||||
return new Parsed { PageSize = DefaultPageSize, Error = pageSizeError };
|
||||
var pageSize = pageSizeOrNull ?? DefaultPageSize;
|
||||
|
||||
return new Parsed
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Collections.Specialized;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using AutopilotMonitor.Shared.Pagination;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Pagination
|
||||
{
|
||||
@@ -17,7 +18,6 @@ namespace AutopilotMonitor.Functions.Pagination
|
||||
public static class RawTablePagination
|
||||
{
|
||||
public const int DefaultPageSize = 200;
|
||||
public const int MaxPageSize = 1000;
|
||||
|
||||
public static string Fingerprint(
|
||||
string callerTenantId,
|
||||
@@ -49,15 +49,9 @@ namespace AutopilotMonitor.Functions.Pagination
|
||||
var pageSizeRaw = query?["pageSize"];
|
||||
var continuationRaw = query?["continuation"];
|
||||
|
||||
int pageSize = DefaultPageSize;
|
||||
if (!string.IsNullOrEmpty(pageSizeRaw))
|
||||
{
|
||||
if (!int.TryParse(pageSizeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n))
|
||||
return new Parsed { PageSize = DefaultPageSize, Error = "pageSize must be an integer" };
|
||||
if (n < 1 || n > MaxPageSize)
|
||||
return new Parsed { PageSize = DefaultPageSize, Error = $"pageSize must be between 1 and {MaxPageSize}" };
|
||||
pageSize = n;
|
||||
}
|
||||
if (!QueryParams.TryPageSize(pageSizeRaw, out var pageSizeOrNull, out var pageSizeError))
|
||||
return new Parsed { PageSize = DefaultPageSize, Error = pageSizeError };
|
||||
var pageSize = pageSizeOrNull ?? DefaultPageSize;
|
||||
|
||||
return new Parsed
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Collections.Specialized;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using AutopilotMonitor.Shared.Pagination;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Pagination
|
||||
{
|
||||
@@ -17,7 +18,6 @@ namespace AutopilotMonitor.Functions.Pagination
|
||||
public static class SearchSessionsByCvePagination
|
||||
{
|
||||
public const int DefaultPageSize = 200;
|
||||
public const int MaxPageSize = 1000;
|
||||
|
||||
public static string Fingerprint(
|
||||
string scope,
|
||||
@@ -50,15 +50,9 @@ namespace AutopilotMonitor.Functions.Pagination
|
||||
var pageSizeRaw = query?["pageSize"];
|
||||
var continuationRaw = query?["continuation"];
|
||||
|
||||
int pageSize = DefaultPageSize;
|
||||
if (!string.IsNullOrEmpty(pageSizeRaw))
|
||||
{
|
||||
if (!int.TryParse(pageSizeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n))
|
||||
return new Parsed { PageSize = DefaultPageSize, Error = "pageSize must be an integer" };
|
||||
if (n < 1 || n > MaxPageSize)
|
||||
return new Parsed { PageSize = DefaultPageSize, Error = $"pageSize must be between 1 and {MaxPageSize}" };
|
||||
pageSize = n;
|
||||
}
|
||||
if (!QueryParams.TryPageSize(pageSizeRaw, out var pageSizeOrNull, out var pageSizeError))
|
||||
return new Parsed { PageSize = DefaultPageSize, Error = pageSizeError };
|
||||
var pageSize = pageSizeOrNull ?? DefaultPageSize;
|
||||
|
||||
return new Parsed
|
||||
{
|
||||
|
||||
+4
-10
@@ -4,6 +4,7 @@ using System.Collections.Specialized;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using AutopilotMonitor.Shared.Pagination;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Pagination
|
||||
{
|
||||
@@ -17,7 +18,6 @@ namespace AutopilotMonitor.Functions.Pagination
|
||||
public static class SearchSessionsByEventPagination
|
||||
{
|
||||
public const int DefaultPageSize = 50;
|
||||
public const int MaxPageSize = 1000;
|
||||
|
||||
public static string Fingerprint(
|
||||
string scope,
|
||||
@@ -46,15 +46,9 @@ namespace AutopilotMonitor.Functions.Pagination
|
||||
var pageSizeRaw = query?["pageSize"];
|
||||
var continuationRaw = query?["continuation"];
|
||||
|
||||
int pageSize = DefaultPageSize;
|
||||
if (!string.IsNullOrEmpty(pageSizeRaw))
|
||||
{
|
||||
if (!int.TryParse(pageSizeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n))
|
||||
return new Parsed { PageSize = DefaultPageSize, Error = "pageSize must be an integer" };
|
||||
if (n < 1 || n > MaxPageSize)
|
||||
return new Parsed { PageSize = DefaultPageSize, Error = $"pageSize must be between 1 and {MaxPageSize}" };
|
||||
pageSize = n;
|
||||
}
|
||||
if (!QueryParams.TryPageSize(pageSizeRaw, out var pageSizeOrNull, out var pageSizeError))
|
||||
return new Parsed { PageSize = DefaultPageSize, Error = pageSizeError };
|
||||
var pageSize = pageSizeOrNull ?? DefaultPageSize;
|
||||
|
||||
return new Parsed
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@ using System.Linq;
|
||||
using System.Text;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Pagination;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Pagination
|
||||
{
|
||||
@@ -20,7 +21,6 @@ namespace AutopilotMonitor.Functions.Pagination
|
||||
public static class SearchSessionsPagination
|
||||
{
|
||||
public const int DefaultPageSize = 50;
|
||||
public const int MaxPageSize = 1000;
|
||||
|
||||
public static string Fingerprint(string scope, string callerTenantId, string? filterTenantId, SessionSearchFilter filter)
|
||||
{
|
||||
@@ -79,15 +79,9 @@ namespace AutopilotMonitor.Functions.Pagination
|
||||
var pageSizeRaw = query?["pageSize"];
|
||||
var continuationRaw = query?["continuation"];
|
||||
|
||||
int pageSize = DefaultPageSize;
|
||||
if (!string.IsNullOrEmpty(pageSizeRaw))
|
||||
{
|
||||
if (!int.TryParse(pageSizeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n))
|
||||
return new Parsed { PageSize = DefaultPageSize, Error = "pageSize must be an integer" };
|
||||
if (n < 1 || n > MaxPageSize)
|
||||
return new Parsed { PageSize = DefaultPageSize, Error = $"pageSize must be between 1 and {MaxPageSize}" };
|
||||
pageSize = n;
|
||||
}
|
||||
if (!QueryParams.TryPageSize(pageSizeRaw, out var pageSizeOrNull, out var pageSizeError))
|
||||
return new Parsed { PageSize = DefaultPageSize, Error = pageSizeError };
|
||||
var pageSize = pageSizeOrNull ?? DefaultPageSize;
|
||||
return new Parsed
|
||||
{
|
||||
PageSize = pageSize,
|
||||
|
||||
@@ -6,6 +6,7 @@ using System.Linq;
|
||||
using System.Text;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Pagination;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Pagination
|
||||
{
|
||||
@@ -19,7 +20,6 @@ namespace AutopilotMonitor.Functions.Pagination
|
||||
public static class SessionAnnotationsPagination
|
||||
{
|
||||
public const int DefaultPageSize = 200;
|
||||
public const int MaxPageSize = 1000;
|
||||
/// <summary>
|
||||
/// Cap on the free-text note search (<c>?q=</c>). Notes themselves are capped at 4096
|
||||
/// characters; a search term longer than this is never a real query.
|
||||
@@ -62,16 +62,10 @@ namespace AutopilotMonitor.Functions.Pagination
|
||||
|
||||
public static Parsed ParseQuery(NameValueCollection? query)
|
||||
{
|
||||
var pageSize = DefaultPageSize;
|
||||
var pageSizeRaw = query?["pageSize"];
|
||||
if (!string.IsNullOrEmpty(pageSizeRaw))
|
||||
{
|
||||
if (!int.TryParse(pageSizeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n))
|
||||
return new Parsed { Error = "pageSize must be an integer" };
|
||||
if (n < 1 || n > MaxPageSize)
|
||||
return new Parsed { Error = $"pageSize must be between 1 and {MaxPageSize}" };
|
||||
pageSize = n;
|
||||
}
|
||||
if (!QueryParams.TryPageSize(pageSizeRaw, out var pageSizeOrNull, out var pageSizeError))
|
||||
return new Parsed { Error = pageSizeError };
|
||||
var pageSize = pageSizeOrNull ?? DefaultPageSize;
|
||||
|
||||
var lane = Normalize(query?["lane"]);
|
||||
if (lane != null && !AnnotationLanes.All.Contains(lane))
|
||||
@@ -81,10 +75,10 @@ namespace AutopilotMonitor.Functions.Pagination
|
||||
if (verdict != null && !AnnotationVerdicts.All.Contains(verdict))
|
||||
return new Parsed { Error = $"verdict must be one of: {string.Join(", ", AnnotationVerdicts.All)}" };
|
||||
|
||||
if (!TryParseDate(query?["dateFrom"], out var dateFrom))
|
||||
return new Parsed { Error = "dateFrom must be an ISO 8601 date" };
|
||||
if (!TryParseDate(query?["dateTo"], out var dateTo))
|
||||
return new Parsed { Error = "dateTo must be an ISO 8601 date" };
|
||||
if (!QueryParams.TryUtcInstant(query?["dateFrom"], "dateFrom", out var dateFrom, out var dateError))
|
||||
return new Parsed { Error = dateError };
|
||||
if (!QueryParams.TryUtcInstant(query?["dateTo"], "dateTo", out var dateTo, out dateError))
|
||||
return new Parsed { Error = dateError };
|
||||
|
||||
var search = NullIfEmpty(query?["q"]?.Trim());
|
||||
if (search != null && search.Length > MaxQueryLength)
|
||||
@@ -149,18 +143,6 @@ namespace AutopilotMonitor.Functions.Pagination
|
||||
sb.Append('&').Append(name).Append('=').Append(Uri.EscapeDataString(value!));
|
||||
}
|
||||
|
||||
private static bool TryParseDate(string? raw, out DateTime? value)
|
||||
{
|
||||
value = null;
|
||||
if (string.IsNullOrEmpty(raw)) return true;
|
||||
if (!DateTime.TryParse(
|
||||
raw, CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed))
|
||||
return false;
|
||||
value = parsed;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string? Normalize(string? raw) =>
|
||||
string.IsNullOrEmpty(raw) ? null : raw.ToLowerInvariant();
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Collections.Specialized;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using AutopilotMonitor.Shared.Pagination;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Pagination
|
||||
{
|
||||
@@ -14,7 +15,6 @@ namespace AutopilotMonitor.Functions.Pagination
|
||||
public static class SessionEventsPagination
|
||||
{
|
||||
public const int DefaultPageSize = 200;
|
||||
public const int MaxPageSize = 1000;
|
||||
|
||||
/// <summary>Fingerprint binding tokens to <c>(tenantId, sessionId)</c>.</summary>
|
||||
public static string Fingerprint(string tenantId, string sessionId) =>
|
||||
@@ -51,19 +51,8 @@ namespace AutopilotMonitor.Functions.Pagination
|
||||
var rawPageSize = query["pageSize"];
|
||||
var rawContinuation = query["continuation"];
|
||||
|
||||
int? pageSize = null;
|
||||
if (!string.IsNullOrEmpty(rawPageSize))
|
||||
{
|
||||
if (!int.TryParse(rawPageSize, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n))
|
||||
{
|
||||
return new Parsed { Error = "pageSize must be an integer" };
|
||||
}
|
||||
if (n < 1 || n > MaxPageSize)
|
||||
{
|
||||
return new Parsed { Error = $"pageSize must be between 1 and {MaxPageSize}" };
|
||||
}
|
||||
pageSize = n;
|
||||
}
|
||||
if (!QueryParams.TryPageSize(rawPageSize, out var pageSize, out var pageSizeError))
|
||||
return new Parsed { Error = pageSizeError };
|
||||
|
||||
// continuation without pageSize is meaningless — silently drop it
|
||||
// rather than 400, so a caller that toggles pageSize off does not get
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Collections.Specialized;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using AutopilotMonitor.Shared.Pagination;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Pagination
|
||||
{
|
||||
@@ -16,7 +17,8 @@ namespace AutopilotMonitor.Functions.Pagination
|
||||
public static class SessionListPagination
|
||||
{
|
||||
public const int DefaultPageSize = 100;
|
||||
public const int MaxPageSize = 1000;
|
||||
/// <summary>Scan-cost cap on <c>days</c>; a wider window is a 400, never a silent narrowing.</summary>
|
||||
public const int MaxDays = 365;
|
||||
|
||||
/// <summary>
|
||||
/// Fingerprint binding the token to <c>(scope, callerTenantId, days, filterTenantId)</c>.
|
||||
@@ -47,23 +49,12 @@ namespace AutopilotMonitor.Functions.Pagination
|
||||
var daysRaw = query?["days"];
|
||||
var filterTenantIdRaw = acceptFilterTenantId ? query?["tenantId"] : null;
|
||||
|
||||
int pageSize = DefaultPageSize;
|
||||
if (!string.IsNullOrEmpty(pageSizeRaw))
|
||||
{
|
||||
if (!int.TryParse(pageSizeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n))
|
||||
return new Parsed { PageSize = DefaultPageSize, Error = "pageSize must be an integer" };
|
||||
if (n < 1 || n > MaxPageSize)
|
||||
return new Parsed { PageSize = DefaultPageSize, Error = $"pageSize must be between 1 and {MaxPageSize}" };
|
||||
pageSize = n;
|
||||
}
|
||||
if (!QueryParams.TryPageSize(pageSizeRaw, out var pageSizeOrNull, out var pageSizeError))
|
||||
return new Parsed { PageSize = DefaultPageSize, Error = pageSizeError };
|
||||
var pageSize = pageSizeOrNull ?? DefaultPageSize;
|
||||
|
||||
int? days = null;
|
||||
if (!string.IsNullOrEmpty(daysRaw))
|
||||
{
|
||||
if (!int.TryParse(daysRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var d) || d < 1)
|
||||
return new Parsed { PageSize = pageSize, Error = "days must be a positive integer" };
|
||||
days = d;
|
||||
}
|
||||
if (!QueryParams.TryInt(daysRaw, "days", 1, MaxDays, out var days, out var daysError))
|
||||
return new Parsed { PageSize = pageSize, Error = daysError };
|
||||
|
||||
return new Parsed
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Collections.Specialized;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using AutopilotMonitor.Shared.Pagination;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Pagination
|
||||
{
|
||||
@@ -18,7 +19,6 @@ namespace AutopilotMonitor.Functions.Pagination
|
||||
public static class SessionReportsPagination
|
||||
{
|
||||
public const int DefaultPageSize = 200;
|
||||
public const int MaxPageSize = 1000;
|
||||
|
||||
public static string Fingerprint(string callerTenantId, string? filterTenantId) =>
|
||||
ContinuationToken.ComputeFingerprint(new[]
|
||||
@@ -42,15 +42,8 @@ namespace AutopilotMonitor.Functions.Pagination
|
||||
var pageSizeRaw = query?["pageSize"];
|
||||
var continuationRaw = query?["continuation"];
|
||||
|
||||
int? pageSize = null;
|
||||
if (!string.IsNullOrEmpty(pageSizeRaw))
|
||||
{
|
||||
if (!int.TryParse(pageSizeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n))
|
||||
return new Parsed { Error = "pageSize must be an integer" };
|
||||
if (n < 1 || n > MaxPageSize)
|
||||
return new Parsed { Error = $"pageSize must be between 1 and {MaxPageSize}" };
|
||||
pageSize = n;
|
||||
}
|
||||
if (!QueryParams.TryPageSize(pageSizeRaw, out var pageSize, out var pageSizeError))
|
||||
return new Parsed { Error = pageSizeError };
|
||||
|
||||
// continuation is meaningless without pageSize — silently drop.
|
||||
var continuation = pageSize.HasValue && !string.IsNullOrEmpty(continuationRaw)
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Collections.Specialized;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using AutopilotMonitor.Shared.Pagination;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Pagination
|
||||
{
|
||||
@@ -17,7 +18,6 @@ namespace AutopilotMonitor.Functions.Pagination
|
||||
public static class TenantConfigPagination
|
||||
{
|
||||
public const int DefaultPageSize = 200;
|
||||
public const int MaxPageSize = 1000;
|
||||
|
||||
public static string Fingerprint(string callerTenantId) =>
|
||||
ContinuationToken.ComputeFingerprint(new[]
|
||||
@@ -46,15 +46,8 @@ namespace AutopilotMonitor.Functions.Pagination
|
||||
var continuationRaw = query?["continuation"];
|
||||
var fieldsRaw = query?["fields"];
|
||||
|
||||
int? pageSize = null;
|
||||
if (!string.IsNullOrEmpty(pageSizeRaw))
|
||||
{
|
||||
if (!int.TryParse(pageSizeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n))
|
||||
return new Parsed { Error = "pageSize must be an integer" };
|
||||
if (n < 1 || n > MaxPageSize)
|
||||
return new Parsed { Error = $"pageSize must be between 1 and {MaxPageSize}" };
|
||||
pageSize = n;
|
||||
}
|
||||
if (!QueryParams.TryPageSize(pageSizeRaw, out var pageSize, out var pageSizeError))
|
||||
return new Parsed { Error = pageSizeError };
|
||||
|
||||
// continuation is meaningless without pageSize — silently drop.
|
||||
var continuation = pageSize.HasValue && !string.IsNullOrEmpty(continuationRaw)
|
||||
|
||||
@@ -55,9 +55,12 @@ namespace AutopilotMonitor.Functions.Services
|
||||
_cache.TryRemove(key, out _);
|
||||
}
|
||||
|
||||
/// <summary>Widest window the SLA aggregates serve; the HTTP layer caps <c>months</c> at this too.</summary>
|
||||
public const int MaxMonths = 6;
|
||||
|
||||
public async Task<SlaMetricsResponse> ComputeSlaMetricsAsync(string tenantId, int months = 3, bool fresh = false)
|
||||
{
|
||||
months = Math.Clamp(months, 1, 6);
|
||||
months = Math.Clamp(months, 1, MaxMonths);
|
||||
|
||||
var cacheKey = $"{tenantId}:{months}";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user