Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions Darling/Darling.Tests/ViewerThemeColorOverridesTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/*
* Copyright (c) 2026 Erik Darling, Darling Data LLC
*
* This file is part of the SQL Server Performance Monitor.
*
* Licensed under the MIT License. See LICENSE file in the project root for full license information.
*/

using System;
using System.IO;
using System.Runtime.CompilerServices;
using System.Text.RegularExpressions;
using PerformanceMonitor.Ui;
using Xunit;

namespace Darling.Tests;

/// <summary>
/// #3577 (arm B) as it lands in the Darling viewer. The loader, the key list, the rewriter and the
/// regeneration are shared <c>PerformanceMonitor.Ui</c> code and are pinned once, in
/// <c>Lite.Tests/ThemeColorOverrideTests</c>, against the theme files of BOTH apps. What is the viewer's own
/// is where its file lives and where it does not: <c>theme-overrides.json</c> sits beside
/// <c>viewer-settings.json</c> under the viewer's per-user directory, and the preference is LOCAL to the
/// machine — it never goes to the store or the control plane, because a color is a preference of the
/// person at this screen and not a fact about the fleet. A viewer seat on another machine keeps its own.
/// </summary>
public sealed class ViewerThemeColorOverridesTests
{
private static readonly string ViewerDir = Path.Combine("Darling", "PerformanceMonitor.Darling.Viewer");

[Fact]
public void TheOverridesFile_SitsBesideViewerSettings_UnderTheViewersOwnDirectory()
{
var app = File.ReadAllText(Path.Combine(RepoRoot(), ViewerDir, "App.xaml.cs"));

Assert.Contains("Path.GetDirectoryName(ViewerAppSettingsStore.DefaultFilePath())", app, StringComparison.Ordinal);
Assert.Contains("ThemeColorOverrides.FileName", app, StringComparison.Ordinal);
Assert.Equal("theme-overrides.json", ThemeColorOverrides.FileName);
}

/// <summary>
/// The preference is not a settings-model property, not a store column and not a control-plane knob.
/// <c>ViewerAppSettings</c> keeps <c>ColorTheme</c> (which theme) and nothing about the colors inside
/// it; the service and storage projects never see the word.
/// </summary>
[Fact]
public void TheColorOverrides_AreNotPersistedInTheSettingsModel_TheStore_OrTheControlPlane()
{
var root = RepoRoot();

var settingsModel = File.ReadAllText(Path.Combine(root, ViewerDir, "ViewerAppSettings.cs"));
Assert.DoesNotContain("ThemeColorOverride", settingsModel, StringComparison.Ordinal);
Assert.DoesNotContain("theme-overrides", settingsModel, StringComparison.Ordinal);

foreach (var project in new[] { "PerformanceMonitor.Darling.Storage", "PerformanceMonitor.Darling.Service" })
{
var directory = Path.Combine(root, "Darling", project);
foreach (var file in Directory.EnumerateFiles(directory, "*.cs", SearchOption.AllDirectories))
{
if (file.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.Ordinal))
{
continue;
}

var text = File.ReadAllText(file);
Assert.False(text.Contains("ThemeColorOverride", StringComparison.Ordinal) || text.Contains("theme-overrides", StringComparison.Ordinal),
$"{Path.GetRelativePath(root, file)} mentions the viewer's color overrides; they are viewer-local and must stay out of the store and the service.");
}
}
}

/// <summary>
/// The viewer's Settings window hosts the same shared panel Lite does, beneath its theme combo, and the
/// panel is the ONLY colors surface — no viewer-only copy of the rows that could drift from Lite's.
/// </summary>
[Fact]
public void TheViewerSettingsWindow_HostsTheSharedPanel_AndNoPrivateCopy()
{
var xaml = File.ReadAllText(Path.Combine(RepoRoot(), ViewerDir, "SettingsWindow.xaml"));

Assert.Single(Regex.Matches(xaml, "<ui:ThemeColorsPanel"));
Assert.True(xaml.IndexOf("x:Name=\"ColorThemeCombo\"", StringComparison.Ordinal) < xaml.IndexOf("<ui:ThemeColorsPanel", StringComparison.Ordinal));
Assert.DoesNotContain("AccentForegroundColor", xaml, StringComparison.Ordinal); // no hand-rolled rows
}

/// <summary>The viewer's own three theme files are the ones the shared regeneration reads — embedded under the loader's names.</summary>
[Fact]
public void TheViewerProject_EmbedsItsThemeText()
{
var csproj = File.ReadAllText(Path.Combine(RepoRoot(), ViewerDir, "PerformanceMonitor.Darling.Viewer.csproj"));

Assert.Contains("<EmbeddedResource Include=\"Themes\\*.xaml\" LogicalName=\"Themes/%(Filename).xaml\" />", csproj, StringComparison.Ordinal);
foreach (var theme in ThemeColorOverrides.ThemeNames)
{
Assert.True(File.Exists(Path.Combine(RepoRoot(), ViewerDir, "Themes", theme + "Theme.xaml")),
$"{ThemeManager.ThemeTextResourceName(theme)} names a file the viewer does not ship.");
}
}

private static string RepoRoot([CallerFilePath] string thisFile = "")
=> Path.GetFullPath(Path.Combine(Path.GetDirectoryName(thisFile)!, "..", ".."));
}
14 changes: 14 additions & 0 deletions Darling/PerformanceMonitor.Darling.Viewer/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/

using System;
using System.IO;
using System.Reflection;
using System.Threading.Tasks;
using System.Windows;
Expand Down Expand Up @@ -67,6 +68,17 @@ diagnostic the one guaranteed to vanish. Nothing between here and its old positi
failure. */
ViewerLogger.Initialize();

/* #3577: the operator's per-theme color overrides live beside viewer-settings.json, in the viewer's
own per-user directory - LOCAL to this machine, never in the store or the control plane, because
a color is a preference of the person at this screen and not a fact about the fleet. The path
and the log hooks go in BEFORE the first Apply so the very first paint carries them (the same
no-flash reasoning as the theme itself); the watcher that re-applies an outside edit starts
right after. */
ThemeManager.OverridesFilePath = Path.Combine(
Path.GetDirectoryName(ViewerAppSettingsStore.DefaultFilePath())!, ThemeColorOverrides.FileName);
ThemeManager.LogWarning = message => ViewerLogger.Warn("Theme", message);
ThemeManager.LogInfo = message => ViewerLogger.Info("Theme", message);

/* Apply the saved color theme through ThemeManager (App.xaml merges Dark as the design-time
default) so ThemeManager owns the app-level merged dictionary at runtime, before StartupUri
creates MainWindow. Reads the viewer-local settings directly (cheap JSON read) so the very
Expand All @@ -81,6 +93,8 @@ on any error. Light / CoolBreeze are honored via the Settings window's theme sel
ThemeManager.Apply("Dark");
}

ThemeManager.WatchOverridesFile();

/* #1050 (companion to the ported tray's WindowResumeGuard): WPF's GPU render thread can zombie its
surface across sleep/wake or RDP, leaving a live-but-blank window — now reachable in the viewer
because minimize-to-tray can hide it. Software rendering removes the GPU dependency entirely; charts
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,12 @@
<ItemGroup>
<Resource Include="EDD.ico" />
</ItemGroup>

<ItemGroup>
<!-- #3577: the three theme dictionaries a SECOND time, as plain text, under Themes/{Name}Theme.xaml -
the same line Lite's project carries, for the same reason. The compiled BAML stays the load path
for a theme with no color overrides; a theme WITH overrides is regenerated from this text by the
shared ThemeManager (see ThemeXamlRewriter's header for why it must be the text). -->
<EmbeddedResource Include="Themes\*.xaml" LogicalName="Themes/%(Filename).xaml" />
</ItemGroup>
</Project>
8 changes: 8 additions & 0 deletions Darling/PerformanceMonitor.Darling.Viewer/SettingsWindow.xaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<Window x:Class="PerformanceMonitor.Darling.Viewer.SettingsWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="clr-namespace:PerformanceMonitor.Ui;assembly=PerformanceMonitor.Ui"
Title="Settings"
Height="750" Width="750"
MinHeight="500" MinWidth="600"
Expand Down Expand Up @@ -254,6 +255,13 @@
<ComboBoxItem Content="Cool Breeze" Tag="CoolBreeze"/>
</ComboBox>
</StackPanel>
<!-- #3577: the operator's own colors for the theme the combo shows - the same shared panel
Lite hosts (PerformanceMonitor.Ui.ThemeColorsPanel), reading and writing the viewer's
own theme-overrides.json beside viewer-settings.json. Viewer-LOCAL: this preference
never goes to the store or the control plane. Collapsed by default, as in Lite. -->
<Expander Header="Colors" IsExpanded="False" Margin="0,10,0,0">
<ui:ThemeColorsPanel Margin="0,8,0,0"/>
</Expander>
</StackPanel>
</Border>

Expand Down
2 changes: 2 additions & 0 deletions Darling/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1017,6 +1017,8 @@ The aggregate tabs — **Overview** and **Alerts** span every server; **Recommen

Only the visible tab loads (Lite's visible-only rule). The Alerts tab and the visible server tab's active inner tab refresh every 60 seconds; the Overview refreshes on its own faster 30-second timer (Lite's Overview cadence); and **Recommendations** refreshes on tab activation, its Refresh button, and its own server-selector change only, never on the timer — its findings change on the service's 30-minute analysis cadence, so a 60-second auto-refresh would be pointless churn (and would reset the incident expanders under the reader), matching Lite. The one finding that can appear between passes is **same-statement pileup** (evaluated on the collection cadence — see the `alerts` section), so a live pileup shows up here on the next activation or Refresh rather than the instant it fires; its notification does not wait for either.

The viewer has the same three color themes as Lite — Dark, Light and Cool Breeze, picked from **Settings → Color theme** — and the same **Colors** section beneath that drop-down (#3577): one row per adjustable palette color of the theme on screen, with a swatch, the hex, a picker and the measured contrast ratio against the surface it renders on (green ≥ 4.50:1, amber 3.00–4.49, red below — a warning, never a gate). **Apply** writes and applies live, **Reset to default** returns that one theme to its shipped palette, **Open file** hands you the JSON. The file is `%APPDATA%\PerformanceMonitorDarling\theme-overrides.json`, beside `viewer-settings.json`, and it is **local to this machine**: a color is a preference of the person at the screen, so it lives in neither the store nor the control plane, and a second viewer seat keeps its own. The keys, the JSON shape and the parse rules are the same as Lite's — see [Themes and colors in Lite's README](../Lite/README.md#themes-and-colors).

The viewer is read-only over collected data, but it does perform a small set of **user-initiated writes** — and those go straight to the PostgreSQL store, which is the coordination point (the service honors them on its next read; there is no viewer-to-service channel). From the Alerts tab, creating a mute rule from an alert (**Mute This Alert** / **Mute Similar**) or adding, editing, toggling, deleting, or purging one via **Manage Mute Rules** writes `config_mute_rules` (a rule scopes to a server by name, exactly as Lite's mute rules do); and **dismissing alerts** sets the `dismissed` flag on `config_alert_log` so they drop out of the Alert History view (a single atomic UPDATE — Darling has no parquet archive tier, so there is no dismissed-archive sidecar). The viewer never writes collector data.

### Restart Semantics
Expand Down
Loading
Loading