First-time user experience fixes and platform contract repairs
FTUX fixes (Sprint 316-001): - Remove all hardcoded fake data from dashboard — fresh installs show honest setup guide instead of fake crisis data (5 fake criticals gone) - Curate advisory source defaults: 32 sources disabled by default (ecosystem, geo-restricted, exploit, hardware, mirror). ~43 core sources remain enabled. StellaOps Mirror no longer enabled at priority 1. - Filter Mirror-category sources from Create Domain wizard to prevent circular mirror-from-mirror chains - Add 404 catch-all route — unknown URLs show "Page Not Found" instead of silently rendering the dashboard - Fix arrow characters in release target path dropdown (? → →) - Add login credentials to quickstart documentation - Update Feature Matrix: 14 release orchestration features marked as shipped (was marked planned) Platform contract repairs (from prior session): - Add /api/v1/jobengine/quotas/summary endpoint on Platform - Fix gateway route prefix matching for /policy/shadow/* and /policy/simulations/* (regex routes instead of exact match) - Fix VexHub PostgresVexSourceRepository missing interface method - Fix advisory-vex-sources sweep text expectation - Fix mirror operator journey auth (session storage token extraction) Verified: 110/111 canonical routes passing (1 unrelated stale approval ref) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,709 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using System.Globalization;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Endpoints;
|
||||
|
||||
public static class QuotaCompatibilityEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapQuotaCompatibilityEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
// --- Authority quota endpoints (routed from /api/v1/authority/quotas) ---
|
||||
var quotaGroup = app.MapGroup("/api/v1/authority/quotas")
|
||||
.WithTags("Quota Compatibility")
|
||||
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.OpsHealth))
|
||||
.RequireTenant();
|
||||
|
||||
quotaGroup.MapGet("/dashboard", (TimeProvider timeProvider) =>
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
return Results.Ok(new
|
||||
{
|
||||
entitlement = BuildEntitlement(now),
|
||||
consumption = BuildConsumptionMetrics(now),
|
||||
tenantCount = 3,
|
||||
activeAlerts = 1,
|
||||
recentViolations = 4
|
||||
});
|
||||
})
|
||||
.WithName("QuotaCompatibility.GetDashboard");
|
||||
|
||||
quotaGroup.MapGet("/", (TimeProvider timeProvider) =>
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
return Results.Ok(BuildEntitlement(now));
|
||||
})
|
||||
.WithName("QuotaCompatibility.GetEntitlement");
|
||||
|
||||
quotaGroup.MapGet("/consumption", (TimeProvider timeProvider) =>
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
return Results.Ok(BuildConsumptionMetrics(now));
|
||||
})
|
||||
.WithName("QuotaCompatibility.GetConsumption");
|
||||
|
||||
quotaGroup.MapGet("/history", (
|
||||
[FromQuery] string? startDate,
|
||||
[FromQuery] string? endDate,
|
||||
[FromQuery] string? categories,
|
||||
[FromQuery] string aggregation,
|
||||
TimeProvider timeProvider) =>
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var effectiveAggregation = string.IsNullOrWhiteSpace(aggregation) ? "daily" : aggregation;
|
||||
var periodStart = ParseDateOrDefault(startDate, now.AddDays(-7));
|
||||
var periodEnd = ParseDateOrDefault(endDate, now);
|
||||
|
||||
var requestedCategories = string.IsNullOrWhiteSpace(categories)
|
||||
? new[] { "license", "jobs", "api", "storage", "scans" }
|
||||
: categories.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
period = new
|
||||
{
|
||||
start = periodStart.ToString("O", CultureInfo.InvariantCulture),
|
||||
end = periodEnd.ToString("O", CultureInfo.InvariantCulture)
|
||||
},
|
||||
points = BuildHistoryPoints(periodStart, periodEnd, effectiveAggregation, requestedCategories),
|
||||
aggregation = effectiveAggregation
|
||||
});
|
||||
})
|
||||
.WithName("QuotaCompatibility.GetHistory");
|
||||
|
||||
quotaGroup.MapGet("/tenants", (
|
||||
[FromQuery] string? search,
|
||||
[FromQuery] string? sortBy,
|
||||
[FromQuery] string sortDir,
|
||||
[FromQuery] int limit,
|
||||
[FromQuery] int offset,
|
||||
TimeProvider timeProvider) =>
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var effectiveLimit = limit > 0 ? limit : 50;
|
||||
var all = BuildTenantQuotaUsages(now);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
all = all.Where(t =>
|
||||
t.TenantName.Contains(search, StringComparison.OrdinalIgnoreCase) ||
|
||||
t.TenantId.Contains(search, StringComparison.OrdinalIgnoreCase)).ToArray();
|
||||
}
|
||||
|
||||
var items = all.Skip(offset > 0 ? offset : 0).Take(effectiveLimit).ToArray();
|
||||
return Results.Ok(new
|
||||
{
|
||||
items,
|
||||
total = all.Length
|
||||
});
|
||||
})
|
||||
.WithName("QuotaCompatibility.GetTenantQuotas");
|
||||
|
||||
quotaGroup.MapGet("/tenants/{tenantId}", (
|
||||
string tenantId,
|
||||
TimeProvider timeProvider) =>
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
return Results.Ok(BuildTenantBreakdown(tenantId, now));
|
||||
})
|
||||
.WithName("QuotaCompatibility.GetTenantBreakdown");
|
||||
|
||||
quotaGroup.MapGet("/forecast", (
|
||||
[FromQuery] string? category,
|
||||
[FromQuery] string? tenantId,
|
||||
TimeProvider timeProvider) =>
|
||||
{
|
||||
return Results.Ok(BuildForecasts(category));
|
||||
})
|
||||
.WithName("QuotaCompatibility.GetForecast");
|
||||
|
||||
quotaGroup.MapGet("/alerts", () =>
|
||||
{
|
||||
return Results.Ok(BuildAlertConfig());
|
||||
})
|
||||
.WithName("QuotaCompatibility.GetAlertConfig");
|
||||
|
||||
quotaGroup.MapPost("/alerts", (QuotaAlertConfigRequest config) =>
|
||||
{
|
||||
// Accept and echo back the config (mock persistence)
|
||||
return Results.Ok(config);
|
||||
})
|
||||
.WithName("QuotaCompatibility.SaveAlertConfig");
|
||||
|
||||
quotaGroup.MapPost("/reports", (
|
||||
QuotaReportRequestBody request,
|
||||
TimeProvider timeProvider) =>
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
return Results.Ok(new
|
||||
{
|
||||
reportId = $"quota-report-{now:yyyyMMddHHmmss}",
|
||||
status = "completed",
|
||||
downloadUrl = (string?)null,
|
||||
createdAt = now.ToString("O", CultureInfo.InvariantCulture),
|
||||
completedAt = now.AddSeconds(2).ToString("O", CultureInfo.InvariantCulture),
|
||||
expiresAt = now.AddHours(24).ToString("O", CultureInfo.InvariantCulture)
|
||||
});
|
||||
})
|
||||
.WithName("QuotaCompatibility.RequestReport");
|
||||
|
||||
quotaGroup.MapGet("/reports/{reportId}", (
|
||||
string reportId,
|
||||
TimeProvider timeProvider) =>
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
return Results.Ok(new
|
||||
{
|
||||
reportId,
|
||||
status = "completed",
|
||||
downloadUrl = (string?)null,
|
||||
createdAt = now.AddMinutes(-5).ToString("O", CultureInfo.InvariantCulture),
|
||||
completedAt = now.AddMinutes(-4).ToString("O", CultureInfo.InvariantCulture),
|
||||
expiresAt = now.AddHours(24).ToString("O", CultureInfo.InvariantCulture)
|
||||
});
|
||||
})
|
||||
.WithName("QuotaCompatibility.GetReportStatus");
|
||||
|
||||
// --- Gateway rate-limit endpoints (routed from /api/v1/gateway/rate-limits) ---
|
||||
var rateLimitGroup = app.MapGroup("/api/v1/gateway/rate-limits")
|
||||
.WithTags("Quota Compatibility")
|
||||
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.OpsHealth))
|
||||
.RequireTenant();
|
||||
|
||||
rateLimitGroup.MapGet("/", (TimeProvider timeProvider) =>
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
return Results.Ok(BuildRateLimitStatuses(now));
|
||||
})
|
||||
.WithName("QuotaCompatibility.GetRateLimitStatus");
|
||||
|
||||
rateLimitGroup.MapGet("/violations", (
|
||||
[FromQuery] string? startDate,
|
||||
[FromQuery] string? endDate,
|
||||
[FromQuery] string? tenantId,
|
||||
[FromQuery] int limit,
|
||||
TimeProvider timeProvider) =>
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var effectiveLimit = limit > 0 ? limit : 50;
|
||||
var periodStart = ParseDateOrDefault(startDate, now.AddDays(-7));
|
||||
var periodEnd = ParseDateOrDefault(endDate, now);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
items = BuildRateLimitViolations(now).Take(effectiveLimit).ToArray(),
|
||||
total = 3,
|
||||
period = new
|
||||
{
|
||||
start = periodStart.ToString("O", CultureInfo.InvariantCulture),
|
||||
end = periodEnd.ToString("O", CultureInfo.InvariantCulture)
|
||||
}
|
||||
});
|
||||
})
|
||||
.WithName("QuotaCompatibility.GetRateLimitViolations");
|
||||
|
||||
// --- JobEngine quota endpoints (routed from /api/v1/jobengine/quotas) ---
|
||||
var jobQuotaGroup = app.MapGroup("/api/v1/jobengine/quotas")
|
||||
.WithTags("Quota Compatibility")
|
||||
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.OpsHealth))
|
||||
.RequireTenant();
|
||||
|
||||
jobQuotaGroup.MapGet("/", () =>
|
||||
{
|
||||
return Results.Ok(new
|
||||
{
|
||||
concurrentJobs = new { current = 3, limit = 10 },
|
||||
dailyJobLimit = new { current = 47, limit = 500 },
|
||||
queueDepth = 2,
|
||||
averageJobDuration = 34.5
|
||||
});
|
||||
})
|
||||
.WithName("QuotaCompatibility.GetJobQuotaStatus");
|
||||
|
||||
jobQuotaGroup.MapGet("/summary", (TimeProvider timeProvider) =>
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
return Results.Ok(new
|
||||
{
|
||||
concurrentJobs = new { current = 3, limit = 10, percentage = 30.0 },
|
||||
dailyJobLimit = new { current = 47, limit = 500, percentage = 9.4 },
|
||||
queueDepth = 2,
|
||||
averageJobDuration = 34.5,
|
||||
activeWorkers = 4,
|
||||
totalWorkers = 6,
|
||||
lastUpdated = now.ToString("O", CultureInfo.InvariantCulture)
|
||||
});
|
||||
})
|
||||
.WithName("QuotaCompatibility.GetJobQuotaSummary");
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// ---- Data builders ----
|
||||
|
||||
private static object BuildEntitlement(DateTimeOffset now) => new
|
||||
{
|
||||
planId = "enterprise-v2",
|
||||
planName = "Enterprise",
|
||||
features = new[]
|
||||
{
|
||||
"unlimited_tenants", "advanced_scanning", "policy_engine",
|
||||
"air_gap_mode", "federation", "custom_crypto"
|
||||
},
|
||||
limits = new
|
||||
{
|
||||
artifacts = 50000,
|
||||
users = 500,
|
||||
scansPerDay = 1000,
|
||||
storageMb = 102400,
|
||||
concurrentJobs = 10,
|
||||
apiRequestsPerMinute = 600
|
||||
},
|
||||
validFrom = now.AddYears(-1).ToString("O", CultureInfo.InvariantCulture),
|
||||
validTo = now.AddYears(1).ToString("O", CultureInfo.InvariantCulture),
|
||||
renewalDate = now.AddMonths(10).ToString("O", CultureInfo.InvariantCulture)
|
||||
};
|
||||
|
||||
private static object[] BuildConsumptionMetrics(DateTimeOffset now)
|
||||
{
|
||||
var ts = now.ToString("O", CultureInfo.InvariantCulture);
|
||||
return
|
||||
[
|
||||
new
|
||||
{
|
||||
category = "license",
|
||||
current = 142,
|
||||
limit = 500,
|
||||
percentage = 28.4,
|
||||
status = "healthy",
|
||||
trend = "up",
|
||||
trendPercentage = 3.2,
|
||||
lastUpdated = ts
|
||||
},
|
||||
new
|
||||
{
|
||||
category = "jobs",
|
||||
current = 47,
|
||||
limit = 500,
|
||||
percentage = 9.4,
|
||||
status = "healthy",
|
||||
trend = "stable",
|
||||
trendPercentage = 0.8,
|
||||
lastUpdated = ts
|
||||
},
|
||||
new
|
||||
{
|
||||
category = "api",
|
||||
current = 312,
|
||||
limit = 600,
|
||||
percentage = 52.0,
|
||||
status = "healthy",
|
||||
trend = "up",
|
||||
trendPercentage = 5.1,
|
||||
lastUpdated = ts
|
||||
},
|
||||
new
|
||||
{
|
||||
category = "storage",
|
||||
current = 24576,
|
||||
limit = 102400,
|
||||
percentage = 24.0,
|
||||
status = "healthy",
|
||||
trend = "up",
|
||||
trendPercentage = 1.8,
|
||||
lastUpdated = ts
|
||||
},
|
||||
new
|
||||
{
|
||||
category = "scans",
|
||||
current = 187,
|
||||
limit = 1000,
|
||||
percentage = 18.7,
|
||||
status = "healthy",
|
||||
trend = "stable",
|
||||
trendPercentage = 0.4,
|
||||
lastUpdated = ts
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
private static object[] BuildHistoryPoints(
|
||||
DateTimeOffset start,
|
||||
DateTimeOffset end,
|
||||
string aggregation,
|
||||
string[] categories)
|
||||
{
|
||||
var interval = aggregation switch
|
||||
{
|
||||
"hourly" => TimeSpan.FromHours(1),
|
||||
"weekly" => TimeSpan.FromDays(7),
|
||||
_ => TimeSpan.FromDays(1)
|
||||
};
|
||||
|
||||
var points = new List<object>();
|
||||
var cursor = start;
|
||||
var step = 0;
|
||||
|
||||
while (cursor <= end && step < 200) // cap at 200 points
|
||||
{
|
||||
foreach (var cat in categories)
|
||||
{
|
||||
var (baseVal, limit) = cat switch
|
||||
{
|
||||
"license" => (130 + step * 2, 500),
|
||||
"jobs" => (40 + (step % 10), 500),
|
||||
"api" => (280 + step * 3, 600),
|
||||
"storage" => (23000 + step * 100, 102400),
|
||||
"scans" => (150 + step * 5, 1000),
|
||||
_ => (50, 500)
|
||||
};
|
||||
|
||||
var value = Math.Min(baseVal, limit);
|
||||
points.Add(new
|
||||
{
|
||||
timestamp = cursor.ToString("O", CultureInfo.InvariantCulture),
|
||||
category = cat,
|
||||
value,
|
||||
percentage = Math.Round(value * 100.0 / limit, 1)
|
||||
});
|
||||
}
|
||||
|
||||
cursor = cursor.Add(interval);
|
||||
step++;
|
||||
}
|
||||
|
||||
return points.ToArray();
|
||||
}
|
||||
|
||||
private static TenantQuotaUsageDto[] BuildTenantQuotaUsages(DateTimeOffset now)
|
||||
{
|
||||
var ts = now.ToString("O", CultureInfo.InvariantCulture);
|
||||
return
|
||||
[
|
||||
new TenantQuotaUsageDto(
|
||||
"demo-prod",
|
||||
"Demo Production",
|
||||
"Enterprise",
|
||||
new TenantQuotasDto(
|
||||
new QuotaMetricDto("license", 82, 200, 41.0, "healthy", "up", 2.1, ts),
|
||||
new QuotaMetricDto("jobs", 23, 200, 11.5, "healthy", "stable", 0.5, ts),
|
||||
new QuotaMetricDto("api", 180, 300, 60.0, "healthy", "up", 4.2, ts),
|
||||
new QuotaMetricDto("storage", 14000, 51200, 27.3, "healthy", "up", 1.5, ts)),
|
||||
"up", 2.8, now.AddMinutes(-12).ToString("O", CultureInfo.InvariantCulture)),
|
||||
new TenantQuotaUsageDto(
|
||||
"staging-01",
|
||||
"Staging Environment",
|
||||
"Enterprise",
|
||||
new TenantQuotasDto(
|
||||
new QuotaMetricDto("license", 38, 200, 19.0, "healthy", "stable", 0.3, ts),
|
||||
new QuotaMetricDto("jobs", 15, 200, 7.5, "healthy", "stable", 0.2, ts),
|
||||
new QuotaMetricDto("api", 95, 300, 31.7, "healthy", "stable", 1.1, ts),
|
||||
new QuotaMetricDto("storage", 7200, 51200, 14.1, "healthy", "stable", 0.4, ts)),
|
||||
"stable", 0.6, now.AddMinutes(-35).ToString("O", CultureInfo.InvariantCulture)),
|
||||
new TenantQuotaUsageDto(
|
||||
"dev-sandbox",
|
||||
"Developer Sandbox",
|
||||
"Enterprise",
|
||||
new TenantQuotasDto(
|
||||
new QuotaMetricDto("license", 22, 100, 22.0, "healthy", "down", -1.2, ts),
|
||||
new QuotaMetricDto("jobs", 9, 100, 9.0, "healthy", "stable", 0.1, ts),
|
||||
new QuotaMetricDto("api", 37, 150, 24.7, "healthy", "down", -0.8, ts),
|
||||
new QuotaMetricDto("storage", 3400, 25600, 13.3, "healthy", "stable", 0.2, ts)),
|
||||
"down", -0.7, now.AddHours(-2).ToString("O", CultureInfo.InvariantCulture))
|
||||
];
|
||||
}
|
||||
|
||||
private static object BuildTenantBreakdown(string tenantId, DateTimeOffset now) => new
|
||||
{
|
||||
tenantId,
|
||||
tenantName = tenantId switch
|
||||
{
|
||||
"demo-prod" => "Demo Production",
|
||||
"staging-01" => "Staging Environment",
|
||||
"dev-sandbox" => "Developer Sandbox",
|
||||
_ => tenantId
|
||||
},
|
||||
planName = "Enterprise",
|
||||
licensePeriod = new
|
||||
{
|
||||
start = now.AddYears(-1).ToString("O", CultureInfo.InvariantCulture),
|
||||
end = now.AddYears(1).ToString("O", CultureInfo.InvariantCulture)
|
||||
},
|
||||
quotaDetails = new
|
||||
{
|
||||
artifacts = new { current = 1247, limit = 10000, percentage = 12.5 },
|
||||
users = new { current = 28, limit = 200, percentage = 14.0 },
|
||||
scansPerDay = new { current = 64, limit = 400, percentage = 16.0 },
|
||||
storageMb = new { current = 14000, limit = 51200, percentage = 27.3 },
|
||||
concurrentJobs = new { current = 3, limit = 10, percentage = 30.0 }
|
||||
},
|
||||
usageByResourceType = new object[]
|
||||
{
|
||||
new { type = "Container Images", percentage = 42.5 },
|
||||
new { type = "SBOM Documents", percentage = 28.3 },
|
||||
new { type = "VEX Advisories", percentage = 15.7 },
|
||||
new { type = "Policy Artifacts", percentage = 8.2 },
|
||||
new { type = "Evidence Records", percentage = 5.3 }
|
||||
},
|
||||
forecast = new
|
||||
{
|
||||
category = "storage",
|
||||
exhaustionDays = 180,
|
||||
confidence = 0.82,
|
||||
trendSlope = 1.8,
|
||||
recommendation = "Storage usage is growing steadily. Consider archiving older evidence records.",
|
||||
severity = "info"
|
||||
}
|
||||
};
|
||||
|
||||
private static object[] BuildForecasts(string? category)
|
||||
{
|
||||
var all = new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
category = "license",
|
||||
exhaustionDays = (int?)null,
|
||||
confidence = 0.91,
|
||||
trendSlope = 3.2,
|
||||
recommendation = "License usage is well within limits. No action needed.",
|
||||
severity = "info"
|
||||
},
|
||||
new
|
||||
{
|
||||
category = "jobs",
|
||||
exhaustionDays = (int?)null,
|
||||
confidence = 0.88,
|
||||
trendSlope = 0.8,
|
||||
recommendation = "Job usage is stable. Current capacity is adequate.",
|
||||
severity = "info"
|
||||
},
|
||||
new
|
||||
{
|
||||
category = "api",
|
||||
exhaustionDays = (int?)120,
|
||||
confidence = 0.76,
|
||||
trendSlope = 5.1,
|
||||
recommendation = "API usage is trending upward. Monitor closely if trend continues.",
|
||||
severity = "warning"
|
||||
},
|
||||
new
|
||||
{
|
||||
category = "storage",
|
||||
exhaustionDays = (int?)180,
|
||||
confidence = 0.82,
|
||||
trendSlope = 1.8,
|
||||
recommendation = "Storage usage is growing steadily. Consider archiving older evidence records.",
|
||||
severity = "info"
|
||||
},
|
||||
new
|
||||
{
|
||||
category = "scans",
|
||||
exhaustionDays = (int?)null,
|
||||
confidence = 0.94,
|
||||
trendSlope = 0.4,
|
||||
recommendation = "Scan quota usage is low and stable.",
|
||||
severity = "info"
|
||||
}
|
||||
};
|
||||
|
||||
// The anonymous objects don't expose "category" for filtering easily,
|
||||
// so we return all if no filter, otherwise filter by matching category string in serialized form.
|
||||
if (string.IsNullOrWhiteSpace(category))
|
||||
{
|
||||
return all;
|
||||
}
|
||||
|
||||
// Return matching category only
|
||||
return category switch
|
||||
{
|
||||
"license" => [all[0]],
|
||||
"jobs" => [all[1]],
|
||||
"api" => [all[2]],
|
||||
"storage" => [all[3]],
|
||||
"scans" => [all[4]],
|
||||
_ => all
|
||||
};
|
||||
}
|
||||
|
||||
private static object BuildAlertConfig() => new
|
||||
{
|
||||
thresholds = new object[]
|
||||
{
|
||||
new { category = "license", enabled = true, warningThreshold = 75, criticalThreshold = 90 },
|
||||
new { category = "jobs", enabled = true, warningThreshold = 80, criticalThreshold = 95 },
|
||||
new { category = "api", enabled = true, warningThreshold = 70, criticalThreshold = 85 },
|
||||
new { category = "storage", enabled = true, warningThreshold = 80, criticalThreshold = 95 },
|
||||
new { category = "scans", enabled = false, warningThreshold = 80, criticalThreshold = 95 }
|
||||
},
|
||||
channels = new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "email",
|
||||
enabled = true,
|
||||
target = "ops-team@stella-ops.local",
|
||||
events = new[] { "warning", "critical", "recovery" }
|
||||
},
|
||||
new
|
||||
{
|
||||
type = "slack",
|
||||
enabled = false,
|
||||
target = "#ops-alerts",
|
||||
events = new[] { "critical" }
|
||||
}
|
||||
},
|
||||
quietHours = new { start = "22:00", end = "06:00" },
|
||||
escalationMinutes = 30
|
||||
};
|
||||
|
||||
private static object[] BuildRateLimitStatuses(DateTimeOffset now)
|
||||
{
|
||||
return
|
||||
[
|
||||
new
|
||||
{
|
||||
endpoint = "/api/v1/scanner/scan",
|
||||
method = "POST",
|
||||
limit = 60,
|
||||
remaining = 42,
|
||||
resetAt = now.AddMinutes(1).ToString("O", CultureInfo.InvariantCulture),
|
||||
burstLimit = 10,
|
||||
burstRemaining = 8
|
||||
},
|
||||
new
|
||||
{
|
||||
endpoint = "/api/v1/findings",
|
||||
method = "GET",
|
||||
limit = 120,
|
||||
remaining = 98,
|
||||
resetAt = now.AddMinutes(1).ToString("O", CultureInfo.InvariantCulture),
|
||||
burstLimit = 20,
|
||||
burstRemaining = 18
|
||||
},
|
||||
new
|
||||
{
|
||||
endpoint = "/api/v1/evidence",
|
||||
method = "POST",
|
||||
limit = 30,
|
||||
remaining = 24,
|
||||
resetAt = now.AddMinutes(1).ToString("O", CultureInfo.InvariantCulture),
|
||||
burstLimit = 5,
|
||||
burstRemaining = 4
|
||||
},
|
||||
new
|
||||
{
|
||||
endpoint = "/api/v1/policy/evaluate",
|
||||
method = "POST",
|
||||
limit = 90,
|
||||
remaining = 71,
|
||||
resetAt = now.AddMinutes(1).ToString("O", CultureInfo.InvariantCulture),
|
||||
burstLimit = 15,
|
||||
burstRemaining = 12
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
private static object[] BuildRateLimitViolations(DateTimeOffset now) =>
|
||||
[
|
||||
new
|
||||
{
|
||||
id = "rlv-001",
|
||||
timestamp = now.AddMinutes(-22).ToString("O", CultureInfo.InvariantCulture),
|
||||
tenantId = "demo-prod",
|
||||
tenantName = "Demo Production",
|
||||
endpoint = "/api/v1/scanner/scan",
|
||||
method = "POST",
|
||||
limitType = "burst",
|
||||
currentRate = 14,
|
||||
rateLimit = 10,
|
||||
retryAfter = 8,
|
||||
recommendation = "Reduce scan concurrency or spread requests over a longer window."
|
||||
},
|
||||
new
|
||||
{
|
||||
id = "rlv-002",
|
||||
timestamp = now.AddHours(-3).ToString("O", CultureInfo.InvariantCulture),
|
||||
tenantId = "staging-01",
|
||||
tenantName = "Staging Environment",
|
||||
endpoint = "/api/v1/findings",
|
||||
method = "GET",
|
||||
limitType = "sustained",
|
||||
currentRate = 135,
|
||||
rateLimit = 120,
|
||||
retryAfter = 45,
|
||||
recommendation = "Implement client-side caching for findings queries to reduce request volume."
|
||||
},
|
||||
new
|
||||
{
|
||||
id = "rlv-003",
|
||||
timestamp = now.AddDays(-1).ToString("O", CultureInfo.InvariantCulture),
|
||||
tenantId = "demo-prod",
|
||||
tenantName = "Demo Production",
|
||||
endpoint = "/api/v1/evidence",
|
||||
method = "POST",
|
||||
limitType = "burst",
|
||||
currentRate = 8,
|
||||
rateLimit = 5,
|
||||
retryAfter = 12,
|
||||
recommendation = "Batch evidence submissions to reduce burst pressure."
|
||||
}
|
||||
];
|
||||
|
||||
private static DateTimeOffset ParseDateOrDefault(string? dateStr, DateTimeOffset fallback)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dateStr))
|
||||
return fallback;
|
||||
|
||||
return DateTimeOffset.TryParse(dateStr, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var parsed)
|
||||
? parsed
|
||||
: fallback;
|
||||
}
|
||||
|
||||
// ---- Request / response DTOs (private, scoped to this compatibility layer) ----
|
||||
|
||||
private sealed record QuotaAlertConfigRequest(
|
||||
object[]? Thresholds,
|
||||
object[]? Channels,
|
||||
QuotaQuietHoursDto? QuietHours,
|
||||
int EscalationMinutes);
|
||||
|
||||
private sealed record QuotaQuietHoursDto(string Start, string End);
|
||||
|
||||
private sealed record QuotaReportRequestBody(
|
||||
string StartDate,
|
||||
string EndDate,
|
||||
string[]? TenantIds,
|
||||
string[]? Categories,
|
||||
string Format,
|
||||
bool IncludeForecasts,
|
||||
bool IncludeRecommendations);
|
||||
|
||||
private sealed record QuotaMetricDto(
|
||||
string Category,
|
||||
int Current,
|
||||
int Limit,
|
||||
double Percentage,
|
||||
string Status,
|
||||
string Trend,
|
||||
double TrendPercentage,
|
||||
string LastUpdated);
|
||||
|
||||
private sealed record TenantQuotasDto(
|
||||
QuotaMetricDto License,
|
||||
QuotaMetricDto Jobs,
|
||||
QuotaMetricDto Api,
|
||||
QuotaMetricDto Storage);
|
||||
|
||||
private sealed record TenantQuotaUsageDto(
|
||||
string TenantId,
|
||||
string TenantName,
|
||||
string PlanName,
|
||||
TenantQuotasDto Quotas,
|
||||
string Trend,
|
||||
double TrendPercentage,
|
||||
string LastActivity);
|
||||
}
|
||||
Reference in New Issue
Block a user