Skip to content

Commit fdf490d

Browse files
Merge pull request #256 from erikdarlingdata/dev
Release v1.7.2 — operator self-time fix + decimal benefit %
2 parents 341678f + 3a40ada commit fdf490d

25 files changed

Lines changed: 304 additions & 55 deletions

server/PlanShare/Program.cs

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,14 @@ created_at TEXT NOT NULL
4444
cmd.ExecuteNonQuery();
4545
}
4646

47+
// --- Rate limiters (in-memory) ---
48+
// Created before Build() so they can be DI-registered and swept by CleanupService.
49+
var rateLimiter = new RateLimiter(maxRequests: 10, windowSeconds: 60);
50+
var analyticsRateLimiter = new RateLimiter(maxRequests: 30, windowSeconds: 60);
51+
4752
// Register the cleanup background service
4853
builder.Services.AddSingleton(new PlanDbConfig(connectionString));
54+
builder.Services.AddSingleton(new RateLimiters(rateLimiter, analyticsRateLimiter));
4955
builder.Services.AddHostedService<CleanupService>();
5056

5157
// Request size limit (10 MB)
@@ -54,10 +60,6 @@ created_at TEXT NOT NULL
5460
var app = builder.Build();
5561
app.UseCors();
5662

57-
// --- Rate limiters (in-memory) ---
58-
var rateLimiter = new RateLimiter(maxRequests: 10, windowSeconds: 60);
59-
var analyticsRateLimiter = new RateLimiter(maxRequests: 30, windowSeconds: 60);
60-
6163
const int MaxTtlDays = 365;
6264

6365
// --- Endpoints ---
@@ -161,9 +163,15 @@ created_at TEXT NOT NULL
161163
return Results.BadRequest("Invalid JSON");
162164
}
163165

164-
// Strip referrer to domain only (no full URLs with query params)
165-
if (!string.IsNullOrEmpty(referrer) && Uri.TryCreate(referrer, UriKind.Absolute, out var refUri))
166-
referrer = refUri.Host;
166+
// Strip referrer to domain only (no full URLs with query params).
167+
// If it doesn't parse as an absolute URL, drop it — never persist raw
168+
// client-supplied strings, since the dashboard renders referrers in HTML.
169+
if (!string.IsNullOrEmpty(referrer))
170+
{
171+
referrer = Uri.TryCreate(referrer, UriKind.Absolute, out var refUri)
172+
? refUri.Host
173+
: null;
174+
}
167175

168176
// Visitor hash: SHA256(IP + User-Agent + date) — unique per day, no PII stored
169177
var ua = ctx.Request.Headers.UserAgent.FirstOrDefault() ?? "";
@@ -352,14 +360,18 @@ static string GenerateDeleteToken()
352360

353361
record PlanDbConfig(string ConnectionString);
354362

363+
record RateLimiters(RateLimiter Share, RateLimiter Analytics);
364+
355365
sealed class CleanupService : BackgroundService
356366
{
357367
private readonly PlanDbConfig _config;
368+
private readonly RateLimiters _rateLimiters;
358369
private readonly ILogger<CleanupService> _logger;
359370

360-
public CleanupService(PlanDbConfig config, ILogger<CleanupService> logger)
371+
public CleanupService(PlanDbConfig config, RateLimiters rateLimiters, ILogger<CleanupService> logger)
361372
{
362373
_config = config;
374+
_rateLimiters = rateLimiters;
363375
_logger = logger;
364376
}
365377

@@ -401,6 +413,14 @@ private void Cleanup()
401413
if (deleted > 0)
402414
_logger.LogInformation("Cleaned up {Count} old page views", deleted);
403415
}
416+
417+
// Evict stale rate-limiter keys so the dictionary doesn't grow forever.
418+
var shareEvicted = _rateLimiters.Share.Sweep();
419+
var analyticsEvicted = _rateLimiters.Analytics.Sweep();
420+
if (shareEvicted + analyticsEvicted > 0)
421+
_logger.LogInformation(
422+
"Evicted {Share} share + {Analytics} analytics rate-limit keys",
423+
shareEvicted, analyticsEvicted);
404424
}
405425
catch (Exception ex)
406426
{
@@ -441,4 +461,25 @@ public bool IsAllowed(string key)
441461
return true;
442462
}
443463
}
464+
465+
/// <summary>
466+
/// Evicts keys whose timestamp lists have gone empty. Call periodically
467+
/// so the dictionary doesn't grow forever across unique IPs.
468+
/// Returns the number of keys evicted.
469+
/// </summary>
470+
public int Sweep()
471+
{
472+
var cutoff = DateTime.UtcNow.AddSeconds(-_windowSeconds);
473+
var evicted = 0;
474+
foreach (var kvp in _requests)
475+
{
476+
lock (kvp.Value)
477+
{
478+
kvp.Value.RemoveAll(t => t < cutoff);
479+
if (kvp.Value.Count == 0 && _requests.TryRemove(kvp))
480+
evicted++;
481+
}
482+
}
483+
return evicted;
484+
}
444485
}

server/PlanShare/dashboard.html

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -275,13 +275,28 @@ <h3>Top Referrers (30 days)</h3>
275275
}
276276
function updateReferrers() {
277277
var tbody = document.querySelector("#referrersTable tbody");
278+
tbody.replaceChildren();
278279
if (!planStats || !planStats.traffic.top_referrers.length) {
279-
tbody.innerHTML = "<tr><td colspan='2' style='color:#484f58'>No referrer data yet</td></tr>";
280+
var tr = document.createElement("tr");
281+
var td = document.createElement("td");
282+
td.setAttribute("colspan", "2");
283+
td.style.color = "#484f58";
284+
td.textContent = "No referrer data yet";
285+
tr.appendChild(td);
286+
tbody.appendChild(tr);
280287
return;
281288
}
282-
tbody.innerHTML = planStats.traffic.top_referrers.map(function(r) {
283-
return "<tr><td>" + r.referrer + "</td><td class='num'>" + fmt(r.count) + "</td></tr>";
284-
}).join("");
289+
planStats.traffic.top_referrers.forEach(function(r) {
290+
var tr = document.createElement("tr");
291+
var tdRef = document.createElement("td");
292+
tdRef.textContent = r.referrer;
293+
var tdCount = document.createElement("td");
294+
tdCount.className = "num";
295+
tdCount.textContent = fmt(r.count);
296+
tr.appendChild(tdRef);
297+
tr.appendChild(tdCount);
298+
tbody.appendChild(tr);
299+
});
285300
}
286301
function updateCharts() {
287302
updateChart("starsChart", "line", {

src/PlanViewer.App/AboutWindow.axaml.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ private void SaveMcpSettings()
5656
}, new JsonSerializerOptions { WriteIndented = true });
5757

5858
Directory.CreateDirectory(settingsDir);
59-
File.WriteAllText(settingsFile, json);
59+
Services.AtomicFile.WriteAllText(settingsFile, json);
6060
}
6161

6262
private void GitHubLink_Click(object? sender, PointerPressedEventArgs e) => OpenUrl(GitHubUrl);

src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -801,6 +801,9 @@ private static string FormatBytes(double bytes)
801801
return $"{bytes / (1024L * 1024 * 1024):N1} GB";
802802
}
803803

804+
private static string FormatBenefitPercent(double pct) =>
805+
pct >= 100 ? $"{pct:N0}" : $"{pct:N1}";
806+
804807
#endregion
805808

806809
#region Node Selection & Properties Panel
@@ -1737,7 +1740,7 @@ private void ShowPropertiesPanel(PlanNode node)
17371740
: w.Severity == PlanWarningSeverity.Warning ? "#FFB347" : "#6BB5FF";
17381741
var warnPanel = new StackPanel { Margin = new Thickness(10, 2, 10, 2) };
17391742
var planWarnHeader = w.MaxBenefitPercent.HasValue
1740-
? $"\u26A0 {w.WarningType} \u2014 up to {w.MaxBenefitPercent:N0}% benefit"
1743+
? $"\u26A0 {w.WarningType} \u2014 up to {FormatBenefitPercent(w.MaxBenefitPercent.Value)}% benefit"
17411744
: $"\u26A0 {w.WarningType}";
17421745
warnPanel.Children.Add(new TextBlock
17431746
{
@@ -1819,7 +1822,7 @@ private void ShowPropertiesPanel(PlanNode node)
18191822
: w.Severity == PlanWarningSeverity.Warning ? "#FFB347" : "#6BB5FF";
18201823
var warnPanel = new StackPanel { Margin = new Thickness(10, 2, 10, 2) };
18211824
var nodeWarnHeader = w.MaxBenefitPercent.HasValue
1822-
? $"\u26A0 {w.WarningType} \u2014 up to {w.MaxBenefitPercent:N0}% benefit"
1825+
? $"\u26A0 {w.WarningType} \u2014 up to {FormatBenefitPercent(w.MaxBenefitPercent.Value)}% benefit"
18231826
: $"\u26A0 {w.WarningType}";
18241827
warnPanel.Children.Add(new TextBlock
18251828
{

src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,11 +78,15 @@ public QuerySessionControl(ICredentialService credentialService, ConnectionStore
7878
QueryEditor.TextArea.Focus();
7979
};
8080

81-
// Dispose TextMate when detached (e.g. tab switch) to release renderers/transformers
81+
// Dispose TextMate when detached (e.g. tab switch) to release renderers/transformers.
82+
// Also cancel any in-flight status-clear dispatch so it doesn't fire on a dead control.
8283
DetachedFromVisualTree += (_, _) =>
8384
{
8485
_textMateInstallation?.Dispose();
8586
_textMateInstallation = null;
87+
_statusClearCts?.Cancel();
88+
_statusClearCts?.Dispose();
89+
_statusClearCts = null;
8690
};
8791

8892
// Focus the editor when the Editor tab is selected; toggle plan-dependent buttons

src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,16 @@ public static string MapOrderByToMetricTag(string orderBy)
169169
: "AvgCpuMs";
170170
}
171171

172+
protected override void OnClosed(EventArgs e)
173+
{
174+
// Cancel any in-flight history fetch so the SqlConnection doesn't sit open on the
175+
// server after the dialog is dismissed.
176+
_fetchCts?.Cancel();
177+
_fetchCts?.Dispose();
178+
_fetchCts = null;
179+
base.OnClosed(e);
180+
}
181+
172182
private async System.Threading.Tasks.Task LoadHistoryAsync()
173183
{
174184
_fetchCts?.Cancel();

src/PlanViewer.App/PlanViewer.App.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
<ApplicationManifest>app.manifest</ApplicationManifest>
77
<ApplicationIcon>EDD.ico</ApplicationIcon>
88
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
9-
<Version>1.7.1</Version>
9+
<Version>1.7.2</Version>
1010
<Authors>Erik Darling</Authors>
1111
<Company>Darling Data LLC</Company>
1212
<Product>Performance Studio</Product>

src/PlanViewer.App/Services/AppSettingsService.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ public static void Save(AppSettings settings)
5959
{
6060
Directory.CreateDirectory(SettingsDir);
6161
var json = JsonSerializer.Serialize(settings, JsonOptions);
62-
File.WriteAllText(SettingsPath, json);
62+
AtomicFile.WriteAllText(SettingsPath, json);
6363
}
6464
catch
6565
{
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
using System.IO;
2+
3+
namespace PlanViewer.App.Services;
4+
5+
/// <summary>
6+
/// Helper for atomic text-file writes: write to a sibling .tmp and rename
7+
/// into place so a crash mid-write can't truncate the target file. Callers
8+
/// are responsible for creating the parent directory first.
9+
/// </summary>
10+
internal static class AtomicFile
11+
{
12+
/// <summary>
13+
/// Writes <paramref name="contents"/> to <paramref name="path"/> atomically
14+
/// with respect to process crashes. If the process dies before the rename,
15+
/// <paramref name="path"/> keeps its previous contents and a stray
16+
/// <c>.tmp</c> sibling is left behind (cleaned up on the next call).
17+
/// </summary>
18+
public static void WriteAllText(string path, string contents)
19+
{
20+
var tmp = path + ".tmp";
21+
File.WriteAllText(tmp, contents);
22+
// File.Move with overwrite:true maps to MoveFileEx(MOVEFILE_REPLACE_EXISTING)
23+
// on Windows and rename(2) on Unix — both atomic when source and destination
24+
// live on the same filesystem, which is always the case here.
25+
File.Move(tmp, path, overwrite: true);
26+
}
27+
}

src/PlanViewer.App/Services/ConnectionStore.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ public void Save(List<ServerConnection> connections)
3939
{
4040
Directory.CreateDirectory(ConfigDir);
4141
var json = JsonSerializer.Serialize(connections, JsonOptions);
42-
File.WriteAllText(ConfigFile, json);
42+
AtomicFile.WriteAllText(ConfigFile, json);
4343
}
4444

4545
public void AddOrUpdate(ServerConnection connection)

0 commit comments

Comments
 (0)