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
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,12 @@ jobs:
# the lock files were not regenerated, which is a chore rather than a defect -- and the
# error NuGet prints for it does not mention the fix.
- name: Restore
env:
REF_NAME: ${{ github.head_ref || github.ref_name }}
run: |
set -euo pipefail
if ! dotnet restore --locked-mode; then
echo "::error title=Lock files are out of step::dotnet restore --locked-mode failed. If this is a dependency bump, Dependabot cannot rewrite packages.lock.json under central package management. Run the 'Refresh lock files' workflow against ${{ github.head_ref || github.ref_name }}, then re-run these checks."
echo "::error title=Lock files are out of step::dotnet restore --locked-mode failed. If this is a dependency bump, Dependabot cannot rewrite packages.lock.json under central package management. Run the 'Refresh lock files' workflow against $REF_NAME, then re-run these checks."
exit 1
fi

Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,13 @@ jobs:

- name: Resolve version
id: version
env:
DISPATCH_VERSION: ${{ inputs.version }}
run: |
set -euo pipefail

if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
VERSION="${{ inputs.version }}"
VERSION="$DISPATCH_VERSION"
else
VERSION="${GITHUB_REF#refs/tags/v}"
fi
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/refresh-lock-files.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ jobs:
- run: dotnet build --no-restore -c Release

- name: Commit the refreshed lock files
env:
BRANCH: ${{ inputs.branch }}
run: |
set -euo pipefail
# Named paths, never `git add -A`: this job can write, so it says exactly what it writes.
Expand All @@ -77,7 +79,7 @@ jobs:
Regenerated with `dotnet restore --force-evaluate`, then verified with
`--locked-mode` and a Release build. Dependabot cannot write these under
central package management; see .github/workflows/refresh-lock-files.yml.'
git push origin HEAD:'${{ inputs.branch }}'
git push origin "HEAD:$BRANCH"
cat <<'NOTE'
Pushed.

Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/).

## [Unreleased]

## [4.1.4] - 2026-09-30

### Fixed

- Timer-scheduled checks now continue after an individual scheduling caller cancels, while host
shutdown still stops them; an older overlapping result can no longer overwrite newer checker state.
- Bulk dashboard state reads and AI diagnoses now avoid redundant state-provider calls.
- Workflow dispatch inputs are passed to shell steps as environment variables, preventing command
injection through a supplied version or branch name.
- Dashboard status text has stronger contrast, and checker details expose their meaning to assistive
technology.

## [4.1.3] - 2026-09-29

### Fixed
Expand Down
10 changes: 7 additions & 3 deletions src/Healthie.AI/PulseDiagnostician.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Healthie.Abstractions;
using Healthie.Abstractions.Models;
using Healthie.Abstractions.Scheduling;
using Microsoft.Extensions.AI;
Expand Down Expand Up @@ -44,14 +45,17 @@ public async Task<PulseDiagnosis> DiagnoseAsync(string name, CancellationToken c
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);

var states = await _pulsesScheduler.GetPulsesStatesAsync(cancellationToken).ConfigureAwait(false);
var checkers = await _pulsesScheduler.GetPulseCheckersAsync(cancellationToken).ConfigureAwait(false);

if (!states.TryGetValue(name, out var state))
if (!checkers.TryGetValue(name, out var checker))
{
throw new ArgumentException($"Pulse checker with name '{name}' not found.", nameof(name));
}

var history = await _pulsesScheduler.GetHistoryAsync(name, cancellationToken).ConfigureAwait(false);
var state = await checker.GetStateAsync(cancellationToken).ConfigureAwait(false);
var history = checker is PulseChecker
? state.History
: await checker.GetHistoryAsync(cancellationToken).ConfigureAwait(false);

if (history.Count == 0)
{
Expand Down
26 changes: 24 additions & 2 deletions src/Healthie.Abstractions/PulseChecker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ protected PulseChecker(
/// <summary>
/// Builds the state a checker starts from when the store holds nothing for it yet.
/// </summary>
private PulseCheckerState CreateInitialState() =>
internal PulseCheckerState CreateInitialState() =>
new(_initialInterval, _initialUnhealthyThreshold)
{
Schedule = _initialSchedule,
Expand Down Expand Up @@ -505,10 +505,32 @@ public async Task TriggerAsync(CancellationToken cancellationToken = default)
var result = await RunCheckAsync(cancellationToken).ConfigureAwait(false);
var elapsed = Stopwatch.GetElapsedTime(startedAt);

var staleResult = false;
var (oldState, state, changed) = await ApplyAsync(
current => RecordResult(current, result, executedAt),
current =>
{
// Another replica can finish a check that started later while this one is
// still running. Its result is newer by definition, so never replace it with
// this older execution or move the last-executed time backwards.
staleResult = current.LastExecutionDateTime is { } lastExecuted && lastExecuted > executedAt;
if (staleResult)
{
return;
}

RecordResult(current, result, executedAt);
},
cancellationToken).ConfigureAwait(false);

// A later-started execution already recorded its result. This check did run, but it is
// not the state currently being monitored, so it must not emit a state transition or
// count as a persisted health result. The flag is assigned on every CAS attempt, so a
// conflict retry cannot inherit the outcome of an earlier attempt.
if (staleResult)
{
return;
}

// Recorded after the write, so a check whose state could not be stored is not counted
// as one that ran -- the same reason a storage failure is not a health result.
RecordTelemetry(activity, state, oldState, elapsed);
Expand Down
21 changes: 15 additions & 6 deletions src/Healthie.Abstractions/Scheduling/PulsesScheduler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,9 @@ public Task<Dictionary<string, IPulseChecker>> GetPulseCheckersAsync(Cancellatio
/// <inheritdoc />
/// <remarks>
/// Reads the whole set in one call where the store can, which is what every dashboard load and
/// every list request does. A checker with nothing stored yet is still asked individually --
/// only it can build its own initial state, seeded from the tags and group it declares -- but
/// that is once per checker in its life, not once per page.
/// every list request does. A built-in checker with nothing stored yet creates its initial
/// state locally, preserving its declared tags and group without asking the store again. A
/// custom checker still supplies its own state.
/// </remarks>
public async Task<Dictionary<string, PulseCheckerState>> GetPulsesStatesAsync(CancellationToken cancellationToken = default)
{
Expand All @@ -82,9 +82,18 @@ public async Task<Dictionary<string, PulseCheckerState>> GetPulsesStatesAsync(Ca

foreach (var checker in checkers)
{
states[checker.Name] = stored.TryGetValue(checker.Name, out var state)
? state
: await checker.GetStateAsync(cancellationToken).ConfigureAwait(false);
if (stored.TryGetValue(checker.Name, out var state))
{
states[checker.Name] = state;
}
else if (_stateProvider is not null && checker is PulseChecker builtInChecker)
{
states[checker.Name] = builtInChecker.CreateInitialState();
}
else
{
states[checker.Name] = await checker.GetStateAsync(cancellationToken).ConfigureAwait(false);
}
}

return states;
Expand Down
12 changes: 7 additions & 5 deletions src/Healthie.Dashboard/Components/HealthieDashboard.razor
Original file line number Diff line number Diff line change
Expand Up @@ -267,14 +267,14 @@
var status = StatusOf(state);
var history = HistoryOf(name);
}
<div class="hpm-row @StatusClass(status) @(isSelected ? "hpm-row--selected" : "") @(state.IsActive ? "" : "hpm-row--paused")"
<div class="hpm-row @StatusClass(status) @(isSelected ? "hpm-row--selected" : "")"
@onclick="@(() => SelectAsync(name))">

<span class="hpm-led @(state.IsActive ? "" : "hpm-led--steady") @(status == Status.Critical ? "hpm-led--urgent" : "")"
aria-hidden="true"></span>

<button type="button" class="hpm-row-name hpm-row-select"
aria-label="View @DisplayNameOf(name) details"
aria-label="View @DisplayNameOf(name) details, status @StatusWord(state)"
aria-pressed="@(isSelected ? "true" : "false")"
@onclick="@(() => SelectAsync(name))" @onclick:stopPropagation="true">
<span class="hpm-row-title">
Expand All @@ -298,27 +298,29 @@
</button>

<div class="hpm-rate-cell">
<span class="hpm-sr-only">Rate: </span>
<span class="hpm-rate @(state.IsActive ? "" : "hpm-rate--paused") @(state.EffectiveSchedule.IsCron ? "hpm-rate--cron" : "")"
title="@(state.EffectiveSchedule.IsCron ? $"Runs on the cron expression {state.EffectiveSchedule.CronExpression}, evaluated in UTC" : null)">@RateLabel(state)</span>
<span class="hpm-rate-unit">@RateUnit(state)</span>
</div>

@if (history.Count == 0)
{
<span class="hpm-blips-empty">no runs yet</span>
<span class="hpm-blips-empty"><span class="hpm-sr-only">Pulse: </span>no runs yet</span>
}
else
{
<div class="hpm-blips" title="The last @history.Count runs, oldest on the left. Green healthy, amber suspicious, red failing.">
<span class="hpm-sr-only">Pulse: @history.Count recent @(history.Count == 1 ? "run" : "runs"), oldest first: @string.Join(", ", history.Select(entry => entry.Health)).</span>
@foreach (var entry in history)
{
<span class="hpm-blip @BlipClass(entry.Health) @StatusClass(StatusOf(entry.Health))"
title="@entry.Health at @entry.ExecutedAt.ToString("HH:mm:ss") UTC"></span>
title="@entry.Health at @entry.ExecutedAt.ToString("HH:mm:ss") UTC" aria-hidden="true"></span>
}
</div>
}

<span class="hpm-time">@Relative(state.LastExecutionDateTime)</span>
<span class="hpm-time"><span class="hpm-sr-only">Last run: </span>@Relative(state.LastExecutionDateTime)</span>

@if (Options.AllowMutations)
{
Expand Down
25 changes: 17 additions & 8 deletions src/Healthie.Dashboard/wwwroot/healthie.css
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,14 @@
--hpm-line2: #263340;
--hpm-text: #DFE9EE;
--hpm-muted: #7C8C97;
--hpm-faint: #4A5862;
--hpm-faint: #71838D;
--hpm-ok: #38E08F;
--hpm-ok-bg: rgba(56, 224, 143, 0.09);
--hpm-warn: #FFC24B;
--hpm-warn-bg: rgba(255, 194, 75, 0.10);
--hpm-crit: #FF5A64;
--hpm-crit-bg: rgba(255, 90, 100, 0.11);
--hpm-paused: #4A5862;
--hpm-paused: #7C8C97;
--hpm-paused-bg: rgba(74, 88, 98, 0.18);
--hpm-grid: rgba(56, 224, 143, 0.05);

Expand Down Expand Up @@ -65,6 +65,19 @@
display: block;
}

/* Labels that keep the compact dashboard grid readable to assistive technology. */
.healthie-dashboard .hpm-sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}

.healthie-dashboard[data-hpm="light"] {
--hpm-bg: #EFF3F2;
--hpm-panel: #FFFFFF;
Expand All @@ -73,14 +86,14 @@
--hpm-line2: #C2CDCA;
--hpm-text: #12181B;
--hpm-muted: #5A6A70;
--hpm-faint: #93A2A8;
--hpm-faint: #5C6C72;
--hpm-ok: #0B9E5E;
--hpm-ok-bg: rgba(11, 158, 94, 0.10);
--hpm-warn: #B57A0D;
--hpm-warn-bg: rgba(181, 122, 13, 0.12);
--hpm-crit: #D23440;
--hpm-crit-bg: rgba(210, 52, 64, 0.10);
--hpm-paused: #93A2A8;
--hpm-paused: #5A6A70;
--hpm-paused-bg: rgba(147, 162, 168, 0.16);
--hpm-grid: rgba(11, 158, 94, 0.06);
}
Expand Down Expand Up @@ -380,10 +393,6 @@
border-color: var(--hpm-line2);
}

.hpm-row--paused {
opacity: 0.55;
}

.hpm-row-name {
min-width: 0;
}
Expand Down
41 changes: 33 additions & 8 deletions src/Healthie.DependencyInjection/TimerPulseScheduler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using Healthie.Abstractions;
using Healthie.Abstractions.Enums;
using Healthie.Abstractions.Scheduling;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;

Expand All @@ -25,6 +26,8 @@ public sealed class TimerPulseScheduler : IPulseScheduler, IAsyncDisposable, IDi
private const double MaxTimerMilliseconds = uint.MaxValue;

private readonly ConcurrentDictionary<string, CancellationTokenSource> _timers = new();
private readonly CancellationTokenSource _shutdown = new();
private readonly CancellationTokenRegistration _applicationStoppingRegistration;

/// <summary>
/// Serialises scheduling, so a checker is never left running under a timer nobody holds.
Expand All @@ -50,6 +53,21 @@ public TimerPulseScheduler(ILogger<TimerPulseScheduler>? logger = null)
_logger = logger;
}

/// <summary>
/// Initializes a new instance of the <see cref="TimerPulseScheduler"/> class that stops its
/// timers when the host stops.
/// </summary>
/// <param name="logger">An optional logger for diagnostic output.</param>
/// <param name="applicationLifetime">The host lifetime that stops locally managed timers.</param>
public TimerPulseScheduler(
ILogger<TimerPulseScheduler>? logger,
IHostApplicationLifetime applicationLifetime)
: this(logger)
{
ArgumentNullException.ThrowIfNull(applicationLifetime);
_applicationStoppingRegistration = applicationLifetime.ApplicationStopping.Register(_shutdown.Cancel);
}

/// <inheritdoc />
public Task ScheduleAsync(
IPulseChecker checker,
Expand Down Expand Up @@ -86,25 +104,29 @@ public async Task ScheduleAsync(
// Stopping the old schedule and installing the new one is two steps, and two callers
// scheduling one checker at once -- an interval changed from the dashboard while the
// scheduler starts it, say -- could each install a timer. Only the last would be in the
// dictionary; the other kept running with nothing able to reach it, and its linked
// CancellationTokenSource was never disposed, so its registration on the parent token
// outlived it too.
// dictionary; the other kept running with nothing able to reach it, and its cancellation
// source was never disposed.
await _scheduling.WaitAsync(cancellationToken).ConfigureAwait(false);

try
{
// The caller can cancel while this request waits for another scheduling operation.
// Once a schedule is installed, its lifetime belongs to this scheduler instead: the
// host's ApplicationStopping token, unscheduling, and replacement stop it.
cancellationToken.ThrowIfCancellationRequested();

// A request can outlive the decision to shut down -- an interval changed from the API
// while the host stops. Installing a timer now would leave one running that Dispose has
// already been past, so there would be nothing left to stop it. Quietly rather than by
// throwing: the host is going away, and that is not the caller's mistake.
if (_disposed)
// while the host stops. Installing a timer now would leave one running that nothing
// would own, so quietly refuse it: the host is going away, and that is not the caller's
// mistake.
if (_disposed || _shutdown.IsCancellationRequested)
{
return;
}

StopExisting(checker.Name);

var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var cts = CancellationTokenSource.CreateLinkedTokenSource(_shutdown.Token);
_timers[checker.Name] = cts;

_ = Task.Run(
Expand Down Expand Up @@ -349,6 +371,9 @@ private static CronExpression ParseCron(string expression, string checkerName)
/// </remarks>
public void Dispose()
{
_applicationStoppingRegistration.Unregister();
_shutdown.Cancel();

// Shutdown races the last requests: an interval can be changed from the API while the host
// is stopping. Without the lock this is a third writer of _timers, and clearing the
// dictionary between an in-flight schedule's stop and its install drops a live timer without
Expand Down
Loading
Loading