o
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
var instances = db.Instances.Where(i => i.CustomerId == customer.Id).Select(i => i.Id);
await db.RuleStates.Where(r => instances.Contains(r.InstanceId)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
+ await db.VersionChanges.Where(v => instances.Contains(v.InstanceId)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
await db.Submissions.Where(s => instances.Contains(s.InstanceId)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
await db.Findings.Where(f => instances.Contains(f.InstanceId)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
await db.Instances.Where(i => i.CustomerId == customer.Id).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
diff --git a/src/SQLInsight.Hub/Portal/Pages/Insights.razor b/src/SQLInsight.Hub/Portal/Pages/Insights.razor
new file mode 100644
index 0000000..eec693c
--- /dev/null
+++ b/src/SQLInsight.Hub/Portal/Pages/Insights.razor
@@ -0,0 +1,48 @@
+@page "/portal/indsigt"
+@attribute [Authorize]
+@using SQLInsight.Contracts
+@using SQLInsight.Hub.Insights
+@inject InsightCalculator Calculator
+@inject InsightOptions Options
+
+Opgraderinger · SQLInsight Hub
+Hvad forsvinder efter en opdatering?
+
+ For hver instans, der har skiftet build: hvilke fund forsvandt inden for @Options.ResolvedWithin.TotalDays.ToString("0", CultureInfo.InvariantCulture) dage,
+ og hvilke var der stadig efter @Options.PersistedAfter.TotalDays.ToString("0", CultureInfo.InvariantCulture) dage. Det er sammenhæng, ikke bevis.
+ Rækker med mindst @Options.MinCustomers kunder, hvor fundet oftest forsvandt, deles anonymt med kundernes agenter.
+
+
+ @if (_effects.Count == 0)
+ {
+ Ingen instanser har skiftet build endnu.
+ }
+ else
+ {
+
+ | Regel | Version | Opdateret til | Kunder | Forsvandt | Blev | Andel | Deles |
+
+ @foreach (var e in _effects)
+ {
+ var shared = e.Customers >= Options.MinCustomers && e.ResolvedShare > 0.5;
+
+ | @e.RuleId |
+ @e.Major |
+ @e.ToBuild @(e.ToLevel is { } level ? $"({level})" : "") |
+ @e.Customers |
+ @e.Resolved |
+ @e.Persisted |
+ @e.ResolvedShare.ToString("P0", CultureInfo.GetCultureInfo("da-DK")) |
+ @(shared ? "Ja" : "Nej") |
+
+ }
+
+
+ }
+
+
+@code {
+ private IReadOnlyList _effects = [];
+
+ protected override async Task OnInitializedAsync() => _effects = await Calculator.UpgradeEffectsAsync(CancellationToken.None);
+}
diff --git a/src/SQLInsight.Hub/Portal/Shared/PortalLayout.razor b/src/SQLInsight.Hub/Portal/Shared/PortalLayout.razor
index 48b0f36..ac61f8e 100644
--- a/src/SQLInsight.Hub/Portal/Shared/PortalLayout.razor
+++ b/src/SQLInsight.Hub/Portal/Shared/PortalLayout.razor
@@ -7,6 +7,7 @@
Kunder
Overblik
Alle fund
+ Opgraderinger
Supportsager
Licenser
diff --git a/src/SQLInsight.Hub/Program.cs b/src/SQLInsight.Hub/Program.cs
index 7a39067..5081ee9 100644
--- a/src/SQLInsight.Hub/Program.cs
+++ b/src/SQLInsight.Hub/Program.cs
@@ -61,6 +61,8 @@ public static class HubProgram
}
});
builder.Services.AddScoped();
+ builder.Services.AddSingleton(settings.Insights);
+ builder.Services.AddScoped();
builder.Services.AddHostedService();
builder.Services.AddScoped();
builder.Services.AddScoped();
@@ -124,6 +126,7 @@ public static class HubProgram
app.MapGet("/", () => Results.Redirect("/portal"));
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
FindingsEndpoint.Map(app);
+ SQLInsight.Hub.Insights.InsightsEndpoint.Map(app);
PortalEndpoints.Map(app);
app.MapRazorComponents();
return app;
diff --git a/src/SQLInsight.Repository/InsightRepository.Hub.cs b/src/SQLInsight.Repository/InsightRepository.Hub.cs
index 41fe2bf..23dcbeb 100644
--- a/src/SQLInsight.Repository/InsightRepository.Hub.cs
+++ b/src/SQLInsight.Repository/InsightRepository.Hub.cs
@@ -68,6 +68,23 @@ public sealed partial class InsightRepository
""", new { outboundId, error, now = Utc(now), next = nextAttemptAt is { } n ? Utc(n) : (DateTime?)null }).ConfigureAwait(false);
}
+ public async Task SaveHubInsightsAsync(string json, DateTimeOffset fetchedAt, CancellationToken cancellationToken = default)
+ {
+ await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
+ await connection.ExecuteAsync("""
+ MERGE insight.HubInsights AS t USING (SELECT 1 AS Id) AS s ON t.Id = s.Id
+ WHEN MATCHED THEN UPDATE SET FetchedAt = @at, Json = @json
+ WHEN NOT MATCHED THEN INSERT (Id, FetchedAt, Json) VALUES (1, @at, @json);
+ """, new { json, at = Utc(fetchedAt) }).ConfigureAwait(false);
+ }
+
+ /// The last insights fetched from the hub, as JSON (SQLInsight.Contracts.HubInsights), or null.
+ public async Task GetHubInsightsJsonAsync(CancellationToken cancellationToken = default)
+ {
+ await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
+ return await connection.QuerySingleOrDefaultAsync("SELECT Json FROM insight.HubInsights WHERE Id = 1;").ConfigureAwait(false);
+ }
+
public async Task SetHubErrorAsync(string error, DateTimeOffset now, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
diff --git a/src/SQLInsight.Repository/Migrations/0004_hub_insights.sql b/src/SQLInsight.Repository/Migrations/0004_hub_insights.sql
new file mode 100644
index 0000000..16af319
--- /dev/null
+++ b/src/SQLInsight.Repository/Migrations/0004_hub_insights.sql
@@ -0,0 +1,7 @@
+/* Aggregated, anonymous experience from SQLInsight Hub ("fixed elsewhere after updating to …"). One row. */
+CREATE TABLE insight.HubInsights
+(
+ Id tinyint NOT NULL CONSTRAINT PK_HubInsights PRIMARY KEY CONSTRAINT CK_HubInsights_Single CHECK (Id = 1),
+ FetchedAt datetime2(0) NOT NULL,
+ Json nvarchar(max) NOT NULL
+);
diff --git a/src/SQLInsight.Web/Components/Pages/FindingDetail.razor b/src/SQLInsight.Web/Components/Pages/FindingDetail.razor
index 13f1de3..9df83c2 100644
--- a/src/SQLInsight.Web/Components/Pages/FindingDetail.razor
+++ b/src/SQLInsight.Web/Components/Pages/FindingDetail.razor
@@ -54,6 +54,20 @@
}
+@if (_elsewhere.Count > 0 && _finding.ResolvedAt is null)
+{
+ var best = _elsewhere[0];
+
+ Erfaring fra SQLInsight-kunder
+
+ Hos @best.Customers kunder forsvandt dette fund efter opdatering til
+ @(best.ToLevel is { } level ? $"{level} ({best.ToBuild})" : best.ToBuild)
+ (@best.Resolved af @(best.Resolved + best.Persisted) instanser). Instansen kører @_version.
+
+ Tal fra SQLInsight Hub, samlet anonymt på tværs af mindst @_minCustomers kunder. Det er en sammenhæng, ikke en garanti: læs anbefalingen ovenfor, og test opdateringen først.
+
+}
+
@if (_finding.References.Count > 0)
{
@@ -71,6 +85,21 @@
[Parameter] public long Id { get; set; }
private StoredFinding? _finding;
+ private IReadOnlyList _elsewhere = [];
+ private string? _version;
+ private int _minCustomers;
- protected override async Task OnInitializedAsync() => _finding = await Repository.GetFindingAsync(Id);
+ protected override async Task OnInitializedAsync()
+ {
+ _finding = await Repository.GetFindingAsync(Id);
+ if (_finding is null || await Repository.GetHubInsightsJsonAsync() is not { } json)
+ {
+ return;
+ }
+
+ var insights = System.Text.Json.JsonSerializer.Deserialize(json, System.Text.Json.JsonSerializerOptions.Web);
+ _version = (await Repository.GetInstanceAsync(_finding.InstanceName))?.ProductVersion;
+ _minCustomers = insights?.MinCustomers ?? 0;
+ _elsewhere = SQLInsight.Contracts.HubInsightMatcher.For(insights, _finding.RuleId, _version);
+ }
}
diff --git a/src/SQLInsight.Web/SQLInsight.Web.csproj b/src/SQLInsight.Web/SQLInsight.Web.csproj
index 8d07a7c..67dde4b 100644
--- a/src/SQLInsight.Web/SQLInsight.Web.csproj
+++ b/src/SQLInsight.Web/SQLInsight.Web.csproj
@@ -6,6 +6,7 @@
+
diff --git a/src/SQLInsight.Web/wwwroot/app.css b/src/SQLInsight.Web/wwwroot/app.css
index c16a95b..1cb1b95 100644
--- a/src/SQLInsight.Web/wwwroot/app.css
+++ b/src/SQLInsight.Web/wwwroot/app.css
@@ -160,3 +160,4 @@ details .script pre { max-height: 32rem; }
.report-links { display: flex; gap: .6rem; flex-wrap: wrap; }
a.button { display: inline-block; background: var(--accent); color: #fff; padding: .5rem 1rem; border-radius: 8px; font-weight: 600; }
a.button:hover { filter: brightness(1.08); }
+.panel.insight { border-left: 4px solid var(--accent); }
diff --git a/tests/SQLInsight.IntegrationTests/HubEndToEndTests.cs b/tests/SQLInsight.IntegrationTests/HubEndToEndTests.cs
index f949a39..0509940 100644
--- a/tests/SQLInsight.IntegrationTests/HubEndToEndTests.cs
+++ b/tests/SQLInsight.IntegrationTests/HubEndToEndTests.cs
@@ -105,4 +105,46 @@ public class HubEndToEndTests(SqlServerFixture sql, HubFixture hub) : IClassFixt
Assert.Equal(0, calls);
Assert.Contains("https", (await _repository.GetHubStatusAsync())!.LastError, StringComparison.Ordinal);
}
+
+ [Fact]
+ public async Task Experience_from_other_customers_reaches_the_customers_web_ui()
+ {
+ for (var i = 0; i < 3; i++)
+ {
+ await HubInsightTests.UpgradeAsync(hub, $"Erfaring {i}", ["SQLI-CFG-005"], []);
+ }
+
+ SQLInsight.Hub.Insights.InsightsEndpoint.ClearCache();
+ var options = new HubOptions { Enabled = true, Url = new Uri("http://localhost/"), License = hub.License("Modtager A/S") };
+ options.TrustedKeys[HubFixture.KeyId] = hub.PublicKey;
+ var sync = new HubSyncService(Options.Create(options), _repository, new TestServerClients(() => hub.Factory.CreateClient()), TimeProvider.System, NullLogger.Instance);
+ Assert.True(await sync.FetchInsightsAsync(hub.Factory.CreateClient(), options, CancellationToken.None));
+
+ var instanceId = await _repository.UpsertInstanceAsync("prod-sql01", new SQLInsight.Core.Instances.InstanceInfo
+ {
+ ServerName = "SQL01", Version = SQLInsight.Core.Instances.SqlServerVersion.Parse(HubInsightTests.OldBuild), ProductLevel = "RTM", ProductUpdateLevel = "CU20",
+ Edition = "Enterprise Edition", EngineEdition = SQLInsight.Core.Instances.EngineEdition.Enterprise,
+ }, "SqlClient", false, DateTimeOffset.UtcNow);
+ await _repository.SaveRuleResultsAsync(instanceId,
+ [
+ new RuleResult
+ {
+ RuleId = "SQLI-CFG-005", RuleTitle = "Plan cache", Outcome = RuleOutcome.Findings,
+ Findings = [new SQLInsight.Core.Findings.Finding { RuleId = "SQLI-CFG-005", Severity = SQLInsight.Core.Findings.Severity.Medium, Title = "Plan cache er fyldt med engangsplaner", Explanation = "e", Recommendation = "r" }],
+ },
+ ], DateTimeOffset.UtcNow);
+ var findingId = (await _repository.GetActiveFindingsAsync(instanceId)).Single().FindingId;
+
+ await using var web = new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory().WithWebHostBuilder(b =>
+ {
+ Microsoft.AspNetCore.Hosting.HostingAbstractionsWebHostBuilderExtensions.UseEnvironment(b, "Development");
+ b.UseSetting("SQLInsight:Repository:ConnectionString", sql.DatabaseConnectionString(_database));
+ b.UseSetting("SQLInsight:Web:Authentication", "None");
+ });
+ var html = await web.CreateClient().GetStringAsync($"/fund/{findingId}");
+
+ Assert.Contains("Erfaring fra SQLInsight-kunder", html, StringComparison.Ordinal);
+ Assert.Contains("Hos 3 kunder", html, StringComparison.Ordinal);
+ Assert.Contains("CU21 (15.0.4415.2)", html, StringComparison.Ordinal);
+ }
}
diff --git a/tests/SQLInsight.IntegrationTests/HubInsightTests.cs b/tests/SQLInsight.IntegrationTests/HubInsightTests.cs
new file mode 100644
index 0000000..cebea44
--- /dev/null
+++ b/tests/SQLInsight.IntegrationTests/HubInsightTests.cs
@@ -0,0 +1,123 @@
+using System.Net;
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using SQLInsight.Contracts;
+using SQLInsight.Hub.Data;
+using SQLInsight.Hub.Insights;
+
+namespace SQLInsight.IntegrationTests;
+
+[Trait("Category", "Integration")]
+public class HubInsightTests(HubFixture hub) : IClassFixture
+{
+ public const string OldBuild = "15.0.4385.2";
+ public const string NewBuild = "15.0.4415.2";
+
+ internal static FindingsSubmission Submission(string build, string level, DateTimeOffset at, params string[] rules) => new()
+ {
+ SubmissionId = Guid.NewGuid(),
+ AgentVersion = "1.0.0",
+ CollectedAt = at,
+ Instance = new InstanceSnapshot { InstanceKey = "sql01", Release = "SQL Server 2019", ProductVersion = build, ProductUpdateLevel = level },
+ Rules = rules.Select(r => new RuleOutcomeDto { RuleId = r, Outcome = "Findings" }).ToList(),
+ Findings = rules.Select(r => new FindingDto
+ {
+ RuleId = r, Subject = "", Severity = "High", Title = r, Explanation = "e", Recommendation = "r", FirstSeenAt = at.AddHours(-1),
+ }).ToList(),
+ };
+
+ internal static async Task SubmitAsync(HttpClient client, string license, FindingsSubmission submission)
+ {
+ using var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/findings") { Content = JsonContent.Create(submission) };
+ request.Headers.Authorization = new AuthenticationHeaderValue("SQLInsight-License", license);
+ (await client.SendAsync(request)).EnsureSuccessStatusCode();
+ }
+
+ /// A customer whose instance had the rules' findings on the old build, then updated: went away.
+ internal static async Task UpgradeAsync(HubFixture hub, string customer, string[] before, string[] after)
+ {
+ var client = hub.Factory.CreateClient();
+ var license = hub.License(customer);
+ var t0 = DateTimeOffset.UtcNow.AddMinutes(-30);
+ await SubmitAsync(client, license, Submission(OldBuild, "CU20", t0, before));
+ await SubmitAsync(client, license, Submission(NewBuild, "CU21", t0.AddMinutes(15), after));
+ }
+
+ private async Task InsightsAsync(string license)
+ {
+ InsightsEndpoint.ClearCache();
+ using var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/insights");
+ request.Headers.Authorization = new AuthenticationHeaderValue("SQLInsight-License", license);
+ var response = await hub.Factory.CreateClient().SendAsync(request);
+ response.EnsureSuccessStatusCode();
+ return (await response.Content.ReadFromJsonAsync())!;
+ }
+
+ [Fact]
+ public async Task Findings_that_went_away_after_an_update_at_three_customers_are_shared()
+ {
+ var tag = Guid.NewGuid().ToString("N")[..6];
+ for (var i = 0; i < 3; i++)
+ {
+ await UpgradeAsync(hub, $"Kunde {tag} {i}", ["SQLI-CFG-005", "SQLI-VER-002", "SQLI-IDX-001"], ["SQLI-IDX-001"]);
+ }
+
+ await UpgradeAsync(hub, $"Kunde {tag} x", ["SQLI-CFG-003"], []);
+ await UpgradeAsync(hub, $"Kunde {tag} y", ["SQLI-CFG-003"], []);
+
+ var insights = await InsightsAsync(hub.License($"Kunde {tag} 0"));
+
+ var shared = Assert.Single(insights.Upgrades, u => u.RuleId == "SQLI-CFG-005");
+ Assert.Equal((15, NewBuild, "CU21", 3, 3, 0), (shared.Major, shared.ToBuild, shared.ToLevel, shared.Customers, shared.Resolved, shared.Persisted));
+ Assert.DoesNotContain(insights.Upgrades, u => u.RuleId == "SQLI-VER-002");
+ Assert.DoesNotContain(insights.Upgrades, u => u.RuleId == "SQLI-CFG-003"); // only two customers
+ Assert.DoesNotContain(insights.Upgrades, u => u.RuleId == "SQLI-IDX-001"); // still there
+ Assert.Equal(3, insights.MinCustomers);
+ }
+
+ [Fact]
+ public async Task Findings_still_there_a_week_after_the_update_count_against_it()
+ {
+ var tag = Guid.NewGuid().ToString("N")[..6];
+ for (var i = 0; i < 3; i++)
+ {
+ await UpgradeAsync(hub, $"Stadig {tag} {i}", ["SQLI-STAT-001"], ["SQLI-STAT-001"]);
+ }
+
+ await using var scope = hub.Factory.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var instances = db.Instances.Where(i => i.Customer!.Name.StartsWith($"Stadig {tag}")).Select(i => i.Id);
+ await db.VersionChanges.Where(v => instances.Contains(v.InstanceId)).ExecuteUpdateAsync(v => v.SetProperty(x => x.ChangedAt, x => x.ChangedAt.AddDays(-10)));
+ await db.Findings.Where(f => instances.Contains(f.InstanceId)).ExecuteUpdateAsync(f => f.SetProperty(x => x.FirstSeenAt, x => x.FirstSeenAt.AddDays(-11)));
+
+ var effects = await scope.ServiceProvider.GetRequiredService().UpgradeEffectsAsync(CancellationToken.None);
+
+ var stat = Assert.Single(effects, e => e.RuleId == "SQLI-STAT-001");
+ Assert.Equal((0, 3), (stat.Resolved, stat.Persisted));
+ Assert.DoesNotContain((await InsightsAsync(hub.License($"Stadig {tag} 0"))).Upgrades, u => u.RuleId == "SQLI-STAT-001");
+ }
+
+ [Fact]
+ public async Task Insights_require_a_license()
+ {
+ Assert.Equal(HttpStatusCode.Unauthorized, (await hub.Factory.CreateClient().GetAsync("/api/v1/insights")).StatusCode);
+ }
+
+ [Fact]
+ public void Matcher_picks_newer_builds_of_the_same_major_version()
+ {
+ var insights = new HubInsights(DateTimeOffset.UtcNow, 3,
+ [
+ new UpgradeInsight("SQLI-CFG-005", 15, "15.0.4415.2", "CU21", 3, 3, 0),
+ new UpgradeInsight("SQLI-CFG-005", 15, "15.0.4430.1", "CU24", 5, 5, 1),
+ new UpgradeInsight("SQLI-CFG-005", 16, "16.0.4175.1", "CU20", 9, 9, 0),
+ ]);
+
+ Assert.Equal(["CU24", "CU21"], HubInsightMatcher.For(insights, "SQLI-CFG-005", "15.0.4385.2").Select(i => i.ToLevel));
+ Assert.Equal(["CU24"], HubInsightMatcher.For(insights, "SQLI-CFG-005", "15.0.4420.2").Select(i => i.ToLevel));
+ Assert.Empty(HubInsightMatcher.For(insights, "SQLI-CFG-004", "15.0.4385.2"));
+ Assert.Empty(HubInsightMatcher.For(null, "SQLI-CFG-005", "15.0.4385.2"));
+ }
+}