Skip to content

Commit ac35495

Browse files
committed
feat: implement snooze functionality for administrative monitors with cleanup and validation
1 parent 587f636 commit ac35495

File tree

8 files changed

+307
-20
lines changed

8 files changed

+307
-20
lines changed

core/src/main/java/hudson/model/AbstractCIBase.java

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,27 @@ public void setDisabledAdministrativeMonitors(Set<String> disabledAdministrative
119119
}
120120
}
121121

122+
private final Map<String, Long> snoozedAdministrativeMonitors = new HashMap<>();
123+
124+
/**
125+
* @since 2.549
126+
*/
127+
public Map<String, Long> getSnoozedAdministrativeMonitors() {
128+
synchronized (this.snoozedAdministrativeMonitors) {
129+
return new HashMap<>(snoozedAdministrativeMonitors);
130+
}
131+
}
132+
133+
/**
134+
* @since 2.549
135+
*/
136+
public void setSnoozedAdministrativeMonitors(Map<String, Long> snoozedAdministrativeMonitors) {
137+
synchronized (this.snoozedAdministrativeMonitors) {
138+
this.snoozedAdministrativeMonitors.clear();
139+
this.snoozedAdministrativeMonitors.putAll(snoozedAdministrativeMonitors);
140+
}
141+
}
142+
122143
/* =================================================================================================================
123144
* Implementation provided
124145
* ============================================================================================================== */

core/src/main/java/hudson/model/AdministrativeMonitor.java

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,10 @@
3131
import hudson.triggers.SCMTrigger;
3232
import hudson.triggers.TimerTrigger;
3333
import java.io.IOException;
34+
import java.util.Map;
3435
import java.util.Set;
36+
import java.util.logging.Level;
37+
import java.util.logging.Logger;
3538
import jenkins.model.Jenkins;
3639
import org.kohsuke.accmod.Restricted;
3740
import org.kohsuke.accmod.restrictions.NoExternalUse;
@@ -88,6 +91,8 @@
8891
* @see Jenkins#administrativeMonitors
8992
*/
9093
public abstract class AdministrativeMonitor extends AbstractModelObject implements ExtensionPoint, StaplerProxy {
94+
private static final Logger LOGGER = Logger.getLogger(AdministrativeMonitor.class.getName());
95+
9196
/**
9297
* Human-readable ID of this monitor, which needs to be unique within the system.
9398
*
@@ -145,9 +150,57 @@ public void disable(boolean value) throws IOException {
145150
* he wants to ignore.
146151
*/
147152
public boolean isEnabled() {
153+
if (isSnoozed()) {
154+
return false;
155+
}
148156
return !Jenkins.get().getDisabledAdministrativeMonitors().contains(id);
149157
}
150158

159+
/**
160+
* @since 2.549
161+
*/
162+
public boolean isSnoozed() {
163+
Map<String, Long> snoozed = Jenkins.get().getSnoozedAdministrativeMonitors();
164+
Long expiry = snoozed.get(id);
165+
if (expiry == null) {
166+
return false;
167+
}
168+
long now = System.currentTimeMillis();
169+
if (now >= expiry) {
170+
// Cleanup expired entry to prevent memory leak
171+
try {
172+
AbstractCIBase jenkins = Jenkins.get();
173+
Map<String, Long> map = jenkins.getSnoozedAdministrativeMonitors();
174+
if (map.remove(id) != null) {
175+
jenkins.setSnoozedAdministrativeMonitors(map);
176+
jenkins.save();
177+
}
178+
} catch (IOException e) {
179+
LOGGER.log(Level.WARNING, "Failed to cleanup expired snooze for " + id, e);
180+
}
181+
return false;
182+
}
183+
return true;
184+
}
185+
186+
/**
187+
* @since 2.549
188+
*/
189+
public void snooze(long durationMs) throws IOException {
190+
if (durationMs <= 0) {
191+
throw new IllegalArgumentException("Duration must be positive");
192+
}
193+
if (durationMs > 365L * 24 * 60 * 60 * 1000) {
194+
throw new IllegalArgumentException("Duration exceeds maximum (1 year)");
195+
}
196+
long expiryTime = System.currentTimeMillis() + durationMs;
197+
AbstractCIBase jenkins = Jenkins.get();
198+
Map<String, Long> map = jenkins.getSnoozedAdministrativeMonitors();
199+
map.put(id, expiryTime);
200+
jenkins.setSnoozedAdministrativeMonitors(map);
201+
jenkins.save();
202+
}
203+
151204
/**
152205
* Returns true if this monitor is activated and
153206
* wants to produce a warning message.
@@ -184,6 +237,42 @@ public void doDisable(StaplerRequest2 req, StaplerResponse2 rsp) throws IOExcept
184237
rsp.sendRedirect2(req.getContextPath() + "/manage");
185238
}
186239

240+
/**
241+
* @since 2.549
242+
*/
243+
@RequirePOST
244+
public void doSnooze(StaplerRequest2 req, StaplerResponse2 rsp) throws IOException {
245+
Jenkins.get().checkPermission(Jenkins.ADMINISTER);
246+
String duration = req.getParameter("duration");
247+
long durationMs;
248+
try {
249+
if ("custom".equals(duration)) {
250+
String minutesStr = req.getParameter("customMinutes");
251+
if (minutesStr == null) {
252+
throw new IllegalArgumentException("No customMinutes parameter");
253+
}
254+
long minutes = Long.parseLong(minutesStr);
255+
if (minutes <= 0) {
256+
throw new IllegalArgumentException("Custom minutes must be positive");
257+
}
258+
durationMs = minutes * 60 * 1000;
259+
} else {
260+
if (duration == null) {
261+
throw new IllegalArgumentException("No duration parameter");
262+
}
263+
durationMs = Long.parseLong(duration);
264+
if (durationMs <= 0) {
265+
throw new IllegalArgumentException("Duration must be positive");
266+
}
267+
}
268+
} catch (IllegalArgumentException e) {
269+
rsp.sendError(StaplerResponse2.SC_BAD_REQUEST, e.getMessage());
270+
return;
271+
}
272+
snooze(durationMs);
273+
rsp.sendRedirect2(req.getContextPath() + "/manage");
274+
}
275+
187276
/**
188277
* Required permission to view this admin monitor.
189278
* By default {@link Jenkins#ADMINISTER}, but {@link Jenkins#SYSTEM_READ} or {@link Jenkins#MANAGE} are also supported.

core/src/main/resources/hudson/model/Messages.properties

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,3 +427,8 @@ ManagementLink.Category.UNCATEGORIZED=Uncategorized
427427
FileParameterValue.IndexTitle=File Parameters
428428

429429
UserPreferencesProperty.DisplayName=Preferences
430+
431+
snooze=Snooze
432+
snooze.duration.label=Snooze duration in minutes
433+
snooze.duration.placeholder=Minutes
434+
snooze.duration.title=Enter duration in minutes (max 525600 = 1 year)

core/src/main/resources/jenkins/diagnostics/ControllerExecutorsAgents/message.jelly

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,24 @@ THE SOFTWARE.
2424

2525
<?jelly escape-by-default='true'?>
2626
<j:jelly xmlns:j="jelly:core" xmlns:st="jelly:stapler" xmlns:d="jelly:define" xmlns:l="/lib/layout" xmlns:t="/lib/hudson" xmlns:f="/lib/form">
27-
<div class="jenkins-alert jenkins-alert-warning">
28-
<form method="post" action="${rootURL}/${it.url}/act" name="${it.id}">
29-
<f:submit name="yes" value="${%Manage}"/>
30-
<f:submit name="no" value="${%Dismiss}"/>
31-
</form>
32-
33-
${%ExecutorsWarning}
27+
<div class="jenkins-alert jenkins-alert-warning" style="display: flex; justify-content: space-between; align-items: center; gap: 1rem;">
28+
<div>
29+
${%ExecutorsWarning}
30+
</div>
31+
<div style="display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center;">
32+
<form method="post" action="${rootURL}/${it.url}/act" name="${it.id}" style="display: inline-flex; gap: 0.5rem;">
33+
<f:submit name="yes" value="${%Manage}"/>
34+
<f:submit name="no" value="${%Dismiss}"/>
35+
</form>
36+
<form method="post" action="${rootURL}/${it.url}/snooze" style="display: inline-flex; gap: 0.5rem; align-items: center;">
37+
<j:if test="${h.isCrumbEncoded()}">
38+
<input type="hidden" name="${h.crumbRequestField}" value="${h.getCrumb(request)}"/>
39+
</j:if>
40+
<input type="hidden" name="duration" value="custom"/>
41+
<label for="snooze-minutes-${it.id}" class="jenkins-visually-hidden">${%snooze.duration.label}</label>
42+
<input type="number" id="snooze-minutes-${it.id}" name="customMinutes" placeholder="${%snooze.duration.placeholder}" min="1" max="525600" step="1" style="width: 80px;" class="jenkins-input" required="required" aria-label="${%snooze.duration.label}" title="${%snooze.duration.title}"/>
43+
<f:submit value="${%snooze}"/>
44+
</form>
45+
</div>
3446
</div>
3547
</j:jelly>

core/src/main/resources/jenkins/diagnostics/ControllerExecutorsNoAgents/message.jelly

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,25 @@ THE SOFTWARE.
2424

2525
<?jelly escape-by-default='true'?>
2626
<j:jelly xmlns:j="jelly:core" xmlns:st="jelly:stapler" xmlns:d="jelly:define" xmlns:l="/lib/layout" xmlns:t="/lib/hudson" xmlns:f="/lib/form">
27-
<div class="jenkins-alert jenkins-alert-warning">
28-
<form method="post" action="${rootURL}/${it.url}/act" name="${it.id}">
29-
<f:submit name="agent" value="${%Set up agent}"/>
30-
<f:submit name="cloud" value="${%Set up cloud}"/>
31-
<f:submit name="no" value="${%Dismiss}"/>
32-
</form>
33-
${%ExecutorsWarning}
27+
<div class="jenkins-alert jenkins-alert-warning" style="display: flex; justify-content: space-between; align-items: center; gap: 1rem;">
28+
<div>
29+
${%ExecutorsWarning}
30+
</div>
31+
<div style="display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center;">
32+
<form method="post" action="${rootURL}/${it.url}/act" name="${it.id}" style="display: inline-flex; gap: 0.5rem;">
33+
<f:submit name="agent" value="${%Set up agent}"/>
34+
<f:submit name="cloud" value="${%Set up cloud}"/>
35+
<f:submit name="no" value="${%Dismiss}"/>
36+
</form>
37+
<form method="post" action="${rootURL}/${it.url}/snooze" style="display: inline-flex; gap: 0.5rem; align-items: center;">
38+
<j:if test="${h.isCrumbEncoded()}">
39+
<input type="hidden" name="${h.crumbRequestField}" value="${h.getCrumb(request)}"/>
40+
</j:if>
41+
<input type="hidden" name="duration" value="custom"/>
42+
<label for="snooze-minutes-${it.id}" class="jenkins-visually-hidden">${%snooze.duration.label}</label>
43+
<input type="number" id="snooze-minutes-${it.id}" name="customMinutes" placeholder="${%snooze.duration.placeholder}" min="1" max="525600" step="1" style="width: 80px;" class="jenkins-input" required="required" aria-label="${%snooze.duration.label}" title="${%snooze.duration.title}"/>
44+
<f:submit value="${%snooze}"/>
45+
</form>
46+
</div>
3447
</div>
3548
</j:jelly>
Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,23 @@
11
<?jelly escape-by-default='true'?>
22
<j:jelly xmlns:j="jelly:core" xmlns:st="jelly:stapler" xmlns:d="jelly:define" xmlns:l="/lib/layout" xmlns:t="/lib/hudson" xmlns:f="/lib/form">
3-
<div class="jenkins-alert jenkins-alert-warning">
4-
<form method="post" action="${rootURL}/${it.url}/act" name="${it.id}">
5-
<f:submit name="yes" value="${%Apply Migration}"/>
6-
<f:submit name="no" value="${%Dismiss}"/>
7-
</form>
8-
${%blurb}
3+
<div class="jenkins-alert jenkins-alert-warning" style="display: flex; justify-content: space-between; align-items: center; gap: 1rem;">
4+
<div>
5+
${%blurb}
6+
</div>
7+
<div style="display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center;">
8+
<form method="post" action="${rootURL}/${it.url}/act" name="${it.id}" style="display: inline-flex; gap: 0.5rem;">
9+
<f:submit name="yes" value="${%Apply Migration}"/>
10+
<f:submit name="no" value="${%Dismiss}"/>
11+
</form>
12+
<form method="post" action="${rootURL}/${it.url}/snooze" style="display: inline-flex; gap: 0.5rem; align-items: center;">
13+
<j:if test="${h.isCrumbEncoded()}">
14+
<input type="hidden" name="${h.crumbRequestField}" value="${h.getCrumb(request)}"/>
15+
</j:if>
16+
<input type="hidden" name="duration" value="custom"/>
17+
<label for="snooze-minutes-${it.id}" class="jenkins-visually-hidden">${%snooze.duration.label}</label>
18+
<input type="number" id="snooze-minutes-${it.id}" name="customMinutes" placeholder="${%snooze.duration.placeholder}" min="1" max="525600" step="1" style="width: 80px;" class="jenkins-input" required="required" aria-label="${%snooze.duration.label}" title="${%snooze.duration.title}"/>
19+
<f:submit value="${%snooze}"/>
20+
</form>
21+
</div>
922
</div>
1023
</j:jelly>

core/src/main/resources/jenkins/model/Messages.properties

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ CLI.enable-job.shortDescription=Enables a job.
7777
GlobalCloudConfiguration.DisplayName=Clouds
7878

7979
BuiltInNodeMigration.DisplayName=Built-In Node Name and Label Migration
80+
snooze=Snooze
8081

8182
SimpleGlobalBuildDiscarderStrategy.displayName=Specific Build Discarder
8283
JobGlobalBuildDiscarderStrategy.displayName=Project Build Discarder
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
package hudson.model;
2+
3+
import static org.junit.jupiter.api.Assertions.assertFalse;
4+
import static org.junit.jupiter.api.Assertions.assertThrows;
5+
import static org.junit.jupiter.api.Assertions.assertTrue;
6+
import static org.mockito.Mockito.mock;
7+
import static org.mockito.Mockito.mockStatic;
8+
import static org.mockito.Mockito.verify;
9+
import static org.mockito.Mockito.when;
10+
11+
import java.io.IOException;
12+
import java.util.HashMap;
13+
import java.util.HashSet;
14+
import java.util.Map;
15+
import java.util.Set;
16+
import jenkins.model.Jenkins;
17+
import org.junit.jupiter.api.AfterEach;
18+
import org.junit.jupiter.api.BeforeEach;
19+
import org.junit.jupiter.api.Test;
20+
import org.mockito.MockedStatic;
21+
22+
class AdministrativeMonitorTest {
23+
24+
private MockedStatic<Jenkins> mockedJenkins;
25+
private Jenkins jenkins;
26+
private Map<String, Long> snoozedMonitors;
27+
private Set<String> disabledMonitors;
28+
29+
@BeforeEach
30+
void setUp() {
31+
mockedJenkins = mockStatic(Jenkins.class);
32+
jenkins = mock(Jenkins.class);
33+
mockedJenkins.when(Jenkins::get).thenReturn(jenkins);
34+
35+
snoozedMonitors = new HashMap<>();
36+
disabledMonitors = new HashSet<>();
37+
38+
when(jenkins.getSnoozedAdministrativeMonitors()).thenReturn(snoozedMonitors);
39+
when(jenkins.getDisabledAdministrativeMonitors()).thenReturn(disabledMonitors);
40+
}
41+
42+
@AfterEach
43+
void tearDown() {
44+
mockedJenkins.close();
45+
}
46+
47+
private static class TestMonitor extends AdministrativeMonitor {
48+
TestMonitor(String id) {
49+
super(id);
50+
}
51+
52+
@Override
53+
public boolean isActivated() {
54+
return true;
55+
}
56+
}
57+
58+
@Test
59+
void testSnoozeExpiry() throws IOException, InterruptedException {
60+
TestMonitor monitor = new TestMonitor("test-monitor");
61+
long duration = 100L; // 100ms
62+
monitor.snooze(duration);
63+
64+
assertTrue(monitor.isSnoozed(), "Monitor should be snoozed immediately");
65+
assertFalse(monitor.isEnabled(), "Monitor should not be enabled while snoozed");
66+
67+
Thread.sleep(150); // Wait for expiry
68+
69+
assertFalse(monitor.isSnoozed(), "Monitor should not be snoozed after expiry");
70+
}
71+
72+
@Test
73+
void testCleanupRemovesOnlyThisMonitor() throws IOException {
74+
TestMonitor monitor1 = new TestMonitor("monitor-1");
75+
TestMonitor monitor2 = new TestMonitor("monitor-2");
76+
77+
// Snooze both, expire monitor1
78+
snoozedMonitors.put("monitor-1", System.currentTimeMillis() - 1000);
79+
snoozedMonitors.put("monitor-2", System.currentTimeMillis() + 10000);
80+
81+
monitor1.isSnoozed(); // Should trigger cleanup for monitor1
82+
83+
assertFalse(snoozedMonitors.containsKey("monitor-1"), "Expired monitor should be removed");
84+
assertTrue(snoozedMonitors.containsKey("monitor-2"), "Active monitor should remain");
85+
}
86+
87+
@Test
88+
void testSnoozePersistence() throws IOException {
89+
TestMonitor monitor = new TestMonitor("persist-monitor");
90+
monitor.snooze(10000);
91+
92+
assertTrue(snoozedMonitors.containsKey("persist-monitor"), "Snooze map should contain the monitor ID");
93+
verify(jenkins).save(); // Verify persistence was called
94+
}
95+
96+
@Test
97+
void testMultipleMonitorsIndependent() throws IOException {
98+
TestMonitor monitor1 = new TestMonitor("m1");
99+
TestMonitor monitor2 = new TestMonitor("m2");
100+
101+
monitor1.snooze(10000);
102+
103+
assertTrue(monitor1.isSnoozed());
104+
assertFalse(monitor2.isSnoozed());
105+
}
106+
107+
@Test
108+
void testNegativeDuration() {
109+
TestMonitor monitor = new TestMonitor("negative");
110+
assertThrows(IllegalArgumentException.class, () -> monitor.snooze(-1));
111+
}
112+
113+
@Test
114+
void testZeroDuration() {
115+
TestMonitor monitor = new TestMonitor("zero");
116+
assertThrows(IllegalArgumentException.class, () -> monitor.snooze(0));
117+
}
118+
119+
@Test
120+
void testExcessiveDuration() {
121+
TestMonitor monitor = new TestMonitor("excessive");
122+
long tooLong = 365L * 24 * 60 * 60 * 1000 + 1;
123+
assertThrows(IllegalArgumentException.class, () -> monitor.snooze(tooLong));
124+
}
125+
126+
@Test
127+
void testMaxDurationAllowed() throws IOException {
128+
TestMonitor monitor = new TestMonitor("max");
129+
long maxDuration = 365L * 24 * 60 * 60 * 1000;
130+
monitor.snooze(maxDuration);
131+
assertTrue(monitor.isSnoozed());
132+
}
133+
}

0 commit comments

Comments
 (0)