Skip to content

Commit fac19e5

Browse files
authored
Merge pull request #182 from ucdavis/VPR-54-hangfire
VPR-54 feat(scheduler): Hangfire-backed scheduler
2 parents 7495619 + 64f6ca5 commit fac19e5

33 files changed

Lines changed: 1585 additions & 61 deletions

.env.local.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@
1515
# Default: C:\Tools\mailpit
1616
#MAILPIT_INSTALL_DIR=C:\Tools\mailpit
1717

18+
# Hangfire Scheduler (Enabled by Default)
19+
# Master toggle for the background scheduler. Defaults to true; the app
20+
# registers Hangfire and starts the background server. Set to false to
21+
# disable the scheduler in this environment. Hangfire's tables live in
22+
# the VIPER database under the HangFire schema.
23+
#Hangfire__Enabled=false
24+
1825
# Jenkins Build Trigger (pre-push hook)
1926
# Get your API token from Jenkins: User menu > Configure > API Token
2027
#JENKINS_USER=your-username

VueApp/src/layouts/LeftNav.vue

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,8 @@
6060
clickable
6161
v-ripple
6262
:href="menuItem.menuItemUrl"
63-
target="_blank"
64-
rel="noopener noreferrer"
63+
:target="menuItem.isExternalSite ? '_blank' : undefined"
64+
:rel="menuItem.isExternalSite ? 'noopener noreferrer' : undefined"
6565
:class="menuItem.displayClass"
6666
>
6767
<q-item-section>
@@ -162,6 +162,22 @@ function isItemActive(routeTo: string | null): boolean {
162162
return score > 0 && score === bestMatchScore.value
163163
}
164164
165+
// True when the URL resolves to a real SPA route. Vue Router's catch-all
166+
// (path: "/:catchAll(.*)*" etc.) matches anything not otherwise registered,
167+
// so a successful resolve isn't enough — we also reject paths that match
168+
// only via a regex catch-all segment.
169+
function isInSpaRoute(url: string): boolean {
170+
try {
171+
const matched = router.resolve(url).matched
172+
if (matched.length === 0) {
173+
return false
174+
}
175+
return matched.some((r) => !/\(\.\*\)/.test(r.path))
176+
} catch {
177+
return false
178+
}
179+
}
180+
165181
type OverflowTitleElement = HTMLElement & {
166182
_overflowTitleObserver?: ResizeObserver
167183
}
@@ -252,17 +268,29 @@ async function getLeftNav() {
252268
}
253269
}
254270
255-
let routeToUrl = null
271+
// Resolve to either an in-SPA route (RouterLink, client-side nav)
272+
// or a same-tab anchor (full page load). URLs that don't match
273+
// any registered SPA route fall through to the catch-all 404 if
274+
// RouterLink-handled, so render those as plain anchors instead.
275+
let routeToUrl: string | null = null
276+
let internalAnchorUrl: string | undefined = undefined
256277
if (!isExternalUrl && r.menuItemURL.length > 0) {
257-
if (isRelativeUrl && props.navarea && props.nav) {
258-
routeToUrl = `/${props.nav.toUpperCase()}/${r.menuItemURL}`
278+
const candidate =
279+
isRelativeUrl && props.navarea && props.nav
280+
? `/${props.nav.toUpperCase()}/${r.menuItemURL}`
281+
: r.menuItemURL
282+
if (isInSpaRoute(candidate)) {
283+
routeToUrl = candidate
259284
} else {
260-
routeToUrl = r.menuItemURL
285+
// Plain-anchor hrefs need the SPA's base path prepended
286+
// (e.g. `/2/`) since the browser won't apply Vue Router's
287+
// base for direct hrefs. router.resolve handles this.
288+
internalAnchorUrl = router.resolve(candidate).href
261289
}
262290
}
263291
264292
return {
265-
menuItemUrl: isExternalUrl ? r.menuItemURL : undefined,
293+
menuItemUrl: isExternalUrl ? r.menuItemURL : internalAnchorUrl,
266294
routeTo: routeToUrl,
267295
menuItemText: r.menuItemText,
268296
clickable: r.menuItemURL.length > 0,

package-lock.json

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
using Microsoft.Extensions.Diagnostics.HealthChecks;
2+
using NSubstitute;
3+
using Viper.Classes.HealthChecks;
4+
5+
namespace Viper.test.HealthChecks
6+
{
7+
public class HangfireHealthCheckTests
8+
{
9+
private static HealthCheckContext CreateContext(HangfireHealthCheck sut)
10+
{
11+
return new HealthCheckContext
12+
{
13+
Registration = new HealthCheckRegistration("hangfire", sut, null, null)
14+
};
15+
}
16+
17+
private static (Hangfire.JobStorage storage, Hangfire.Storage.IMonitoringApi monitoring) CreateStorage()
18+
{
19+
var monitoring = Substitute.For<Hangfire.Storage.IMonitoringApi>();
20+
var storage = Substitute.For<Hangfire.JobStorage>();
21+
storage.GetMonitoringApi().Returns(monitoring);
22+
return (storage, monitoring);
23+
}
24+
25+
private static Hangfire.Storage.Monitoring.StatisticsDto SampleStats(long servers = 1) => new()
26+
{
27+
Servers = servers,
28+
Enqueued = 2,
29+
Scheduled = 3,
30+
Processing = 4,
31+
Failed = 5,
32+
Recurring = 6
33+
};
34+
35+
[Fact]
36+
public async Task CheckHealthAsync_HealthyWhenServersHaveRecentHeartbeats()
37+
{
38+
var (storage, monitoring) = CreateStorage();
39+
monitoring.GetStatistics().Returns(SampleStats());
40+
monitoring.Servers().Returns(new List<Hangfire.Storage.Monitoring.ServerDto>
41+
{
42+
new() { Name = "srv-1", Heartbeat = DateTime.UtcNow }
43+
});
44+
45+
var sut = new HangfireHealthCheck(storage);
46+
var result = await sut.CheckHealthAsync(CreateContext(sut), TestContext.Current.CancellationToken);
47+
48+
Assert.Equal(HealthStatus.Healthy, result.Status);
49+
Assert.Contains("Hangfire OK", result.Description);
50+
Assert.Equal(1L, result.Data["servers"]);
51+
}
52+
53+
[Fact]
54+
public async Task CheckHealthAsync_DegradedWhenNoServersRegistered()
55+
{
56+
var (storage, monitoring) = CreateStorage();
57+
monitoring.GetStatistics().Returns(SampleStats(0));
58+
monitoring.Servers().Returns(new List<Hangfire.Storage.Monitoring.ServerDto>());
59+
60+
var sut = new HangfireHealthCheck(storage);
61+
var result = await sut.CheckHealthAsync(CreateContext(sut), TestContext.Current.CancellationToken);
62+
63+
Assert.Equal(HealthStatus.Degraded, result.Status);
64+
Assert.Contains("no servers registered", result.Description);
65+
}
66+
67+
[Fact]
68+
public async Task CheckHealthAsync_UnhealthyWhenAllHeartbeatsStale()
69+
{
70+
var (storage, monitoring) = CreateStorage();
71+
monitoring.GetStatistics().Returns(SampleStats());
72+
monitoring.Servers().Returns(new List<Hangfire.Storage.Monitoring.ServerDto>
73+
{
74+
new() { Name = "srv-stale", Heartbeat = DateTime.UtcNow.AddMinutes(-10) }
75+
});
76+
77+
var sut = new HangfireHealthCheck(storage);
78+
var result = await sut.CheckHealthAsync(CreateContext(sut), TestContext.Current.CancellationToken);
79+
80+
Assert.Equal(HealthStatus.Unhealthy, result.Status);
81+
Assert.Contains("stale", result.Description);
82+
}
83+
84+
[Fact]
85+
public async Task CheckHealthAsync_UnhealthyWhenServerHasNullHeartbeat()
86+
{
87+
var (storage, monitoring) = CreateStorage();
88+
monitoring.GetStatistics().Returns(SampleStats());
89+
monitoring.Servers().Returns(new List<Hangfire.Storage.Monitoring.ServerDto>
90+
{
91+
new() { Name = "srv-never", Heartbeat = null }
92+
});
93+
94+
var sut = new HangfireHealthCheck(storage);
95+
var result = await sut.CheckHealthAsync(CreateContext(sut), TestContext.Current.CancellationToken);
96+
97+
Assert.Equal(HealthStatus.Unhealthy, result.Status);
98+
Assert.Contains("never", result.Description);
99+
}
100+
101+
[Fact]
102+
public async Task CheckHealthAsync_HealthyWhenAtLeastOneHeartbeatIsRecent()
103+
{
104+
var (storage, monitoring) = CreateStorage();
105+
monitoring.GetStatistics().Returns(SampleStats(2));
106+
monitoring.Servers().Returns(new List<Hangfire.Storage.Monitoring.ServerDto>
107+
{
108+
new() { Name = "srv-stale", Heartbeat = DateTime.UtcNow.AddMinutes(-10) },
109+
new() { Name = "srv-fresh", Heartbeat = DateTime.UtcNow }
110+
});
111+
112+
var sut = new HangfireHealthCheck(storage);
113+
var result = await sut.CheckHealthAsync(CreateContext(sut), TestContext.Current.CancellationToken);
114+
115+
Assert.Equal(HealthStatus.Healthy, result.Status);
116+
Assert.Contains("Hangfire OK", result.Description);
117+
}
118+
119+
[Fact]
120+
public async Task CheckHealthAsync_UnhealthyWhenStorageThrows()
121+
{
122+
var storage = Substitute.For<Hangfire.JobStorage>();
123+
var boom = new InvalidOperationException("boom");
124+
storage.GetMonitoringApi().Returns(_ => throw boom);
125+
126+
var sut = new HangfireHealthCheck(storage);
127+
var result = await sut.CheckHealthAsync(CreateContext(sut), TestContext.Current.CancellationToken);
128+
129+
Assert.Equal(HealthStatus.Unhealthy, result.Status);
130+
Assert.Contains("unreachable", result.Description);
131+
Assert.Same(boom, result.Exception);
132+
}
133+
134+
[Fact]
135+
public async Task CheckHealthAsync_DataDictionaryContainsAllStatsKeys()
136+
{
137+
var (storage, monitoring) = CreateStorage();
138+
monitoring.GetStatistics().Returns(new Hangfire.Storage.Monitoring.StatisticsDto
139+
{
140+
Servers = 1,
141+
Enqueued = 7,
142+
Scheduled = 8,
143+
Processing = 9,
144+
Failed = 10,
145+
Recurring = 11
146+
});
147+
monitoring.Servers().Returns(new List<Hangfire.Storage.Monitoring.ServerDto>
148+
{
149+
new() { Name = "srv-1", Heartbeat = DateTime.UtcNow }
150+
});
151+
152+
var sut = new HangfireHealthCheck(storage);
153+
var result = await sut.CheckHealthAsync(CreateContext(sut), TestContext.Current.CancellationToken);
154+
155+
Assert.Contains("servers", result.Data.Keys);
156+
Assert.Contains("enqueued", result.Data.Keys);
157+
Assert.Contains("scheduled", result.Data.Keys);
158+
Assert.Contains("processing", result.Data.Keys);
159+
Assert.Contains("failed", result.Data.Keys);
160+
Assert.Contains("recurring", result.Data.Keys);
161+
Assert.Equal(1L, result.Data["servers"]);
162+
Assert.Equal(7L, result.Data["enqueued"]);
163+
Assert.Equal(11L, result.Data["recurring"]);
164+
}
165+
}
166+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
using System.Reflection;
2+
using Viper.Areas.RAPS.Jobs;
3+
using Viper.Areas.Scheduler.Services;
4+
5+
namespace Viper.test.RAPS
6+
{
7+
public sealed class RapsRoleRefreshScheduledJobTests
8+
{
9+
[Fact]
10+
public void Class_IsDecoratedWithScheduledJob()
11+
{
12+
var attr = typeof(RapsRoleRefreshScheduledJob).GetCustomAttribute<ScheduledJobAttribute>();
13+
14+
Assert.NotNull(attr);
15+
Assert.Equal("raps:role-refresh", attr.Id);
16+
Assert.Equal("0 0 * * *", attr.Cron);
17+
Assert.Equal("Pacific Standard Time", attr.TimeZoneId);
18+
}
19+
}
20+
}

0 commit comments

Comments
 (0)