From 4eb78ab8f1ba20d110b3134aa8b68eae8d75375c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20Ko=CC=88ninger?= Date: Thu, 26 Mar 2026 13:45:04 +0100 Subject: [PATCH 01/20] feat(quartz): add Quartz job configuration and dependencies for scheduling --- .../spring-boot-admin-sample-servlet/pom.xml | 4 + .../admin/sample/QuartzJobsConfiguration.java | 130 ++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 spring-boot-admin-samples/spring-boot-admin-sample-servlet/src/main/java/de/codecentric/boot/admin/sample/QuartzJobsConfiguration.java diff --git a/spring-boot-admin-samples/spring-boot-admin-sample-servlet/pom.xml b/spring-boot-admin-samples/spring-boot-admin-sample-servlet/pom.xml index 56b4e1cd4c6..7baa1f04429 100644 --- a/spring-boot-admin-samples/spring-boot-admin-sample-servlet/pom.xml +++ b/spring-boot-admin-samples/spring-boot-admin-sample-servlet/pom.xml @@ -80,6 +80,10 @@ org.jolokia jolokia-support-springboot + + org.springframework.boot + spring-boot-starter-quartz + org.springframework.boot diff --git a/spring-boot-admin-samples/spring-boot-admin-sample-servlet/src/main/java/de/codecentric/boot/admin/sample/QuartzJobsConfiguration.java b/spring-boot-admin-samples/spring-boot-admin-sample-servlet/src/main/java/de/codecentric/boot/admin/sample/QuartzJobsConfiguration.java new file mode 100644 index 00000000000..bc103136749 --- /dev/null +++ b/spring-boot-admin-samples/spring-boot-admin-sample-servlet/src/main/java/de/codecentric/boot/admin/sample/QuartzJobsConfiguration.java @@ -0,0 +1,130 @@ +/* + * Copyright 2014-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.codecentric.boot.admin.sample; + +import java.util.TimeZone; + +import org.quartz.CronScheduleBuilder; +import org.quartz.JobBuilder; +import org.quartz.JobDetail; +import org.quartz.SimpleScheduleBuilder; +import org.quartz.Trigger; +import org.quartz.TriggerBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.quartz.QuartzJobBean; + +/** + * Configuration for sample Quartz jobs and triggers to demonstrate the Quartz actuator + * endpoint. This allows testing of job/trigger listing in Spring Boot Admin UI. + */ +@Configuration +public class QuartzJobsConfiguration { + + /** + * Creates job details for the sample job. + * @return job detail for the sample job + */ + @Bean + public JobDetail sampleJobDetail() { + return JobBuilder.newJob(SampleJob.class) + .withIdentity("sampleJob", "samples") + .withDescription("Sample job to demonstrate Quartz actuator endpoint") + .storeDurably() + .build(); + } + + /** + * Creates job details for the another sample job. + * @return job detail for the another sample job + */ + @Bean + public JobDetail anotherSampleJobDetail() { + return JobBuilder.newJob(AnotherSampleJob.class) + .withIdentity("anotherJob", "samples") + .withDescription("Another sample job for testing") + .storeDurably() + .build(); + } + + /** + * Creates a simple trigger that executes the sample job every 10 seconds. + * @return trigger for the sample job + */ + @Bean + public Trigger sampleJobTrigger() { + return TriggerBuilder.newTrigger() + .forJob(sampleJobDetail()) + .withIdentity("sampleTrigger", "samples") + .withDescription("Trigger that executes sample job every 10 seconds") + .withSchedule(SimpleScheduleBuilder.simpleSchedule().withIntervalInSeconds(10).repeatForever()) + .build(); + } + + /** + * Creates a cron trigger that executes another sample job every day at 3am. + * @return trigger for the another sample job + */ + @Bean + public Trigger anotherSampleJobTrigger() { + return TriggerBuilder.newTrigger() + .forJob(anotherSampleJobDetail()) + .withIdentity("dailyTrigger", "samples") + .withDescription("Daily trigger at 3am") + .withSchedule(CronScheduleBuilder.cronSchedule("0 0 3 * * ?").inTimeZone(TimeZone.getTimeZone("UTC"))) + .build(); + } + + /** + * Creates a simple trigger for testing purposes (every hour). + * @return trigger for hourly execution + */ + @Bean + public Trigger hourlyTestTrigger() { + return TriggerBuilder.newTrigger() + .forJob(sampleJobDetail()) + .withIdentity("hourlyTrigger", "DEFAULT") + .withDescription("Hourly trigger for testing") + .withSchedule(SimpleScheduleBuilder.simpleSchedule().withIntervalInHours(1).repeatForever()) + .build(); + } + + /** + * Sample job that logs at regular intervals. + */ + public static class SampleJob extends QuartzJobBean { + + @Override + protected void executeInternal(org.quartz.JobExecutionContext context) { + System.out.println("Sample Quartz Job executed at " + new java.util.Date()); + } + + } + + /** + * Another sample job for demonstration. + */ + public static class AnotherSampleJob extends QuartzJobBean { + + @Override + protected void executeInternal(org.quartz.JobExecutionContext context) { + System.out.println("Another Quartz Job executed at " + new java.util.Date()); + } + + } + +} From fb3cbb2da0b428f4ed193359f979545ecdb1b2fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20Ko=CC=88ninger?= Date: Thu, 26 Mar 2026 13:45:12 +0100 Subject: [PATCH 02/20] feat(quartz): implement Quartz Actuator service for job and trigger management --- .../src/main/frontend/services/instance.ts | 28 +- .../frontend/services/quartz-actuator.spec.ts | 693 ++++++++++++++++++ .../main/frontend/services/quartz-actuator.ts | 244 ++++++ .../frontend/views/instances/quartz/index.vue | 67 +- 4 files changed, 961 insertions(+), 71 deletions(-) create mode 100644 spring-boot-admin-server-ui/src/main/frontend/services/quartz-actuator.spec.ts create mode 100644 spring-boot-admin-server-ui/src/main/frontend/services/quartz-actuator.ts diff --git a/spring-boot-admin-server-ui/src/main/frontend/services/instance.ts b/spring-boot-admin-server-ui/src/main/frontend/services/instance.ts index 44de1753031..a2590b86be8 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/services/instance.ts +++ b/spring-boot-admin-server-ui/src/main/frontend/services/instance.ts @@ -1,5 +1,5 @@ /* - * Copyright 2014-2019 the original author or authors. + * Copyright 2014-2026 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -45,7 +45,7 @@ const isInstanceActuatorRequest = (url: string) => class Instance { public readonly id: string; - private readonly axios: AxiosInstance; + readonly axios: AxiosInstance; public registration: Registration; public endpoints: Endpoint[] = []; public availableMetrics: string[] = []; @@ -481,30 +481,6 @@ class Instance { return this.axios.get(uri`actuator/mappings`); } - async fetchQuartzJobs() { - return this.axios.get(uri`actuator/quartz/jobs`, { - headers: { Accept: 'application/json' }, - }); - } - - async fetchQuartzJob(group, name) { - return this.axios.get(uri`actuator/quartz/jobs/${group}/${name}`, { - headers: { Accept: 'application/json' }, - }); - } - - async fetchQuartzTriggers() { - return this.axios.get(uri`actuator/quartz/triggers`, { - headers: { Accept: 'application/json' }, - }); - } - - async fetchQuartzTrigger(group, name) { - return this.axios.get(uri`actuator/quartz/triggers/${group}/${name}`, { - headers: { Accept: 'application/json' }, - }); - } - async fetchSbomIds() { return this.axios.get(uri`actuator/sbom`, { headers: { Accept: 'application/json' }, diff --git a/spring-boot-admin-server-ui/src/main/frontend/services/quartz-actuator.spec.ts b/spring-boot-admin-server-ui/src/main/frontend/services/quartz-actuator.spec.ts new file mode 100644 index 00000000000..8f8ed726dbd --- /dev/null +++ b/spring-boot-admin-server-ui/src/main/frontend/services/quartz-actuator.spec.ts @@ -0,0 +1,693 @@ +/* + * Copyright 2014-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + JobDetail, + QuartzActuatorService, + TriggerDetail, +} from './quartz-actuator'; + +// Mock Instance type +interface MockInstance { + axios: { + get: ReturnType; + post?: ReturnType; + }; +} + +const createMockInstance = (): MockInstance => ({ + axios: { + get: vi.fn(), + }, +}); + +describe('QuartzActuatorService', () => { + let mockInstance: MockInstance; + + beforeEach(() => { + mockInstance = createMockInstance(); + }); + + describe('fetchJobGroups', () => { + it('should fetch job groups from correct endpoint', async () => { + const mockResponse = { + data: { + groups: { + samples: { jobs: ['job1', 'job2'] }, + tests: { jobs: ['job3'] }, + }, + }, + }; + + mockInstance.axios.get.mockResolvedValue(mockResponse); + + const result = await QuartzActuatorService.fetchJobGroups( + mockInstance as any, + ); + + expect(mockInstance.axios.get).toHaveBeenCalledWith( + expect.stringContaining('actuator/quartz/jobs'), + { headers: { Accept: 'application/json' } }, + ); + expect(result.data).toEqual(mockResponse.data); + }); + + it('should handle empty job groups', async () => { + const mockResponse = { data: { groups: {} } }; + mockInstance.axios.get.mockResolvedValue(mockResponse); + + const result = await QuartzActuatorService.fetchJobGroups( + mockInstance as any, + ); + + expect(result.data.groups).toEqual({}); + }); + + it('should propagate errors from axios', async () => { + const error = new Error('Network error'); + mockInstance.axios.get.mockRejectedValue(error); + + await expect( + QuartzActuatorService.fetchJobGroups(mockInstance as any), + ).rejects.toThrow('Network error'); + }); + }); + + describe('fetchJobDetail', () => { + it('should fetch job detail with correct path parameters', async () => { + const mockResponse = { + data: { + group: 'samples', + name: 'jobOne', + className: 'org.springframework.scheduling.quartz.DelegatingJob', + durable: false, + requestRecovery: false, + triggers: [], + } as JobDetail, + }; + + mockInstance.axios.get.mockResolvedValue(mockResponse); + + const result = await QuartzActuatorService.fetchJobDetail( + mockInstance as any, + 'samples', + 'jobOne', + ); + + expect(mockInstance.axios.get).toHaveBeenCalledWith( + expect.stringContaining('actuator/quartz/jobs/samples/jobOne'), + { headers: { Accept: 'application/json' } }, + ); + expect(result.data.name).toBe('jobOne'); + }); + + it('should include job description when present', async () => { + const mockResponse = { + data: { + group: 'samples', + name: 'jobOne', + description: 'A sample job', + className: 'org.springframework.scheduling.quartz.DelegatingJob', + durable: false, + requestRecovery: false, + triggers: [], + } as JobDetail, + }; + + mockInstance.axios.get.mockResolvedValue(mockResponse); + + const result = await QuartzActuatorService.fetchJobDetail( + mockInstance as any, + 'samples', + 'jobOne', + ); + + expect(result.data.description).toBe('A sample job'); + }); + + it('should include job data map when present', async () => { + const mockResponse = { + data: { + group: 'samples', + name: 'jobOne', + className: 'org.springframework.scheduling.quartz.DelegatingJob', + durable: false, + requestRecovery: false, + data: { username: 'admin', apiKey: 'secret' }, + triggers: [], + } as JobDetail, + }; + + mockInstance.axios.get.mockResolvedValue(mockResponse); + + const result = await QuartzActuatorService.fetchJobDetail( + mockInstance as any, + 'samples', + 'jobOne', + ); + + expect(result.data.data).toEqual({ + username: 'admin', + apiKey: 'secret', + }); + }); + + it('should include associated triggers', async () => { + const mockResponse = { + data: { + group: 'samples', + name: 'jobOne', + className: 'org.springframework.scheduling.quartz.DelegatingJob', + durable: false, + requestRecovery: false, + triggers: [ + { + group: 'samples', + name: 'every-day', + nextFireTime: '2020-12-04T12:00:00.000Z', + priority: 7, + }, + ], + } as JobDetail, + }; + + mockInstance.axios.get.mockResolvedValue(mockResponse); + + const result = await QuartzActuatorService.fetchJobDetail( + mockInstance as any, + 'samples', + 'jobOne', + ); + + expect(result.data.triggers).toHaveLength(1); + expect(result.data.triggers[0].name).toBe('every-day'); + }); + }); + + describe('fetchAllJobs', () => { + it('should fetch and aggregate all jobs from all groups', async () => { + const mockGroupsResponse = { + data: { + groups: { + samples: { jobs: ['job1', 'job2'] }, + }, + }, + }; + + const mockJob1 = { + data: { + name: 'job1', + group: 'samples', + className: 'Job1', + durable: false, + requestRecovery: false, + triggers: [], + } as JobDetail, + }; + + const mockJob2 = { + data: { + name: 'job2', + group: 'samples', + className: 'Job2', + durable: false, + requestRecovery: false, + triggers: [], + } as JobDetail, + }; + + mockInstance.axios.get + .mockResolvedValueOnce(mockGroupsResponse) + .mockResolvedValueOnce(mockJob1) + .mockResolvedValueOnce(mockJob2); + + const result = await QuartzActuatorService.fetchAllJobs( + mockInstance as any, + ); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual(mockJob1.data); + expect(result[1]).toEqual(mockJob2.data); + }); + + it('should handle jobs from multiple groups', async () => { + const mockGroupsResponse = { + data: { + groups: { + samples: { jobs: ['job1'] }, + tests: { jobs: ['job2', 'job3'] }, + }, + }, + }; + + mockInstance.axios.get + .mockResolvedValueOnce(mockGroupsResponse) + .mockResolvedValueOnce({ data: { name: 'job1', group: 'samples' } }) + .mockResolvedValueOnce({ data: { name: 'job2', group: 'tests' } }) + .mockResolvedValueOnce({ data: { name: 'job3', group: 'tests' } }); + + const result = await QuartzActuatorService.fetchAllJobs( + mockInstance as any, + ); + + expect(result).toHaveLength(3); + }); + + it('should handle partial failures gracefully', async () => { + const mockGroupsResponse = { + data: { + groups: { + samples: { jobs: ['job1', 'job2'] }, + }, + }, + }; + + mockInstance.axios.get + .mockResolvedValueOnce(mockGroupsResponse) + .mockResolvedValueOnce({ + data: { + name: 'job1', + group: 'samples', + className: 'Job1', + durable: false, + requestRecovery: false, + triggers: [], + }, + }) + .mockRejectedValueOnce(new Error('Failed to fetch job2')); + + const result = await QuartzActuatorService.fetchAllJobs( + mockInstance as any, + ); + + // Should return only successful job + expect(result).toHaveLength(1); + expect(result[0].name).toBe('job1'); + }); + + it('should return empty array when no jobs exist', async () => { + const mockGroupsResponse = { + data: { + groups: {}, + }, + }; + + mockInstance.axios.get.mockResolvedValueOnce(mockGroupsResponse); + + const result = await QuartzActuatorService.fetchAllJobs( + mockInstance as any, + ); + + expect(result).toHaveLength(0); + }); + + it('should make correct number of API calls', async () => { + const mockGroupsResponse = { + data: { + groups: { + samples: { jobs: ['job1', 'job2'] }, + tests: { jobs: ['job3'] }, + }, + }, + }; + + mockInstance.axios.get + .mockResolvedValueOnce(mockGroupsResponse) + .mockResolvedValue({ + data: { + className: 'Job', + durable: false, + requestRecovery: false, + triggers: [], + }, + }); + + await QuartzActuatorService.fetchAllJobs(mockInstance as any); + + // 1 call for groups + 3 calls for individual jobs = 4 total + expect(mockInstance.axios.get).toHaveBeenCalledTimes(4); + }); + }); + + describe('fetchTriggerGroups', () => { + it('should fetch trigger groups from correct endpoint', async () => { + const mockResponse = { + data: { + groups: { + samples: { paused: false, triggers: ['trigger1', 'trigger2'] }, + DEFAULT: { paused: false, triggers: ['trigger3'] }, + }, + }, + }; + + mockInstance.axios.get.mockResolvedValue(mockResponse); + + const result = await QuartzActuatorService.fetchTriggerGroups( + mockInstance as any, + ); + + expect(mockInstance.axios.get).toHaveBeenCalledWith( + expect.stringContaining('actuator/quartz/triggers'), + { headers: { Accept: 'application/json' } }, + ); + expect(result.data).toEqual(mockResponse.data); + }); + + it('should include paused status for each group', async () => { + const mockResponse = { + data: { + groups: { + paused_group: { paused: true, triggers: ['trigger1'] }, + active_group: { paused: false, triggers: ['trigger2'] }, + }, + }, + }; + + mockInstance.axios.get.mockResolvedValue(mockResponse); + + const result = await QuartzActuatorService.fetchTriggerGroups( + mockInstance as any, + ); + + expect(result.data.groups['paused_group'].paused).toBe(true); + expect(result.data.groups['active_group'].paused).toBe(false); + }); + }); + + describe('fetchTriggerDetail', () => { + it('should fetch trigger detail with correct path parameters', async () => { + const mockResponse = { + data: { + name: 'every-day', + group: 'samples', + type: 'simple' as const, + state: 'NORMAL', + priority: 7, + simple: { interval: 86400000, repeatCount: -1, timesTriggered: 0 }, + } as TriggerDetail, + }; + + mockInstance.axios.get.mockResolvedValue(mockResponse); + + const result = await QuartzActuatorService.fetchTriggerDetail( + mockInstance as any, + 'samples', + 'every-day', + ); + + expect(mockInstance.axios.get).toHaveBeenCalledWith( + expect.stringContaining('actuator/quartz/triggers/samples/every-day'), + { headers: { Accept: 'application/json' } }, + ); + expect(result.data.name).toBe('every-day'); + }); + + it('should handle cron triggers with expression and timezone', async () => { + const mockResponse = { + data: { + name: '3am-weekdays', + group: 'samples', + type: 'cron' as const, + state: 'NORMAL', + priority: 3, + cron: { + expression: '0 0 3 ? * 1,2,3,4,5', + timeZone: 'Europe/Paris', + }, + } as TriggerDetail, + }; + + mockInstance.axios.get.mockResolvedValue(mockResponse); + + const result = await QuartzActuatorService.fetchTriggerDetail( + mockInstance as any, + 'samples', + '3am-weekdays', + ); + + expect(result.data.cron?.expression).toBe('0 0 3 ? * 1,2,3,4,5'); + expect(result.data.cron?.timeZone).toBe('Europe/Paris'); + }); + + it('should handle daily time interval triggers with days and times', async () => { + const mockResponse = { + data: { + name: 'tue-thu', + group: 'samples', + type: 'dailyTimeInterval' as const, + state: 'NORMAL', + priority: 5, + dailyTimeInterval: { + interval: 3600000, + daysOfWeek: [3, 5], + startTimeOfDay: '09:00:00', + endTimeOfDay: '18:00:00', + repeatCount: -1, + timesTriggered: 0, + }, + } as TriggerDetail, + }; + + mockInstance.axios.get.mockResolvedValue(mockResponse); + + const result = await QuartzActuatorService.fetchTriggerDetail( + mockInstance as any, + 'samples', + 'tue-thu', + ); + + expect(result.data.dailyTimeInterval?.daysOfWeek).toEqual([3, 5]); + expect(result.data.dailyTimeInterval?.startTimeOfDay).toBe('09:00:00'); + }); + + it('should handle calendar interval triggers', async () => { + const mockResponse = { + data: { + name: 'once-a-week', + group: 'samples', + type: 'calendarInterval' as const, + state: 'NORMAL', + priority: 5, + calendarInterval: { + interval: 604800000, + timeZone: 'Etc/UTC', + timesTriggered: 0, + preserveHourOfDayAcrossDaylightSavings: false, + skipDayIfHourDoesNotExist: false, + }, + } as TriggerDetail, + }; + + mockInstance.axios.get.mockResolvedValue(mockResponse); + + const result = await QuartzActuatorService.fetchTriggerDetail( + mockInstance as any, + 'samples', + 'once-a-week', + ); + + expect(result.data.calendarInterval?.interval).toBe(604800000); + expect(result.data.calendarInterval?.timeZone).toBe('Etc/UTC'); + }); + + it('should handle custom triggers', async () => { + const mockResponse = { + data: { + name: 'custom-trigger', + group: 'samples', + type: 'custom' as const, + state: 'NORMAL', + priority: 10, + custom: { trigger: 'com.example.CustomTrigger@fdsfsd' }, + } as TriggerDetail, + }; + + mockInstance.axios.get.mockResolvedValue(mockResponse); + + const result = await QuartzActuatorService.fetchTriggerDetail( + mockInstance as any, + 'samples', + 'custom-trigger', + ); + + expect(result.data.custom?.trigger).toContain('CustomTrigger'); + }); + }); + + describe('fetchAllTriggers', () => { + it('should fetch and aggregate all triggers from all groups', async () => { + const mockGroupsResponse = { + data: { + groups: { + samples: { paused: false, triggers: ['trigger1', 'trigger2'] }, + }, + }, + }; + + const mockTrigger1 = { + data: { + name: 'trigger1', + group: 'samples', + type: 'simple' as const, + state: 'NORMAL', + priority: 7, + } as TriggerDetail, + }; + + const mockTrigger2 = { + data: { + name: 'trigger2', + group: 'samples', + type: 'cron' as const, + state: 'NORMAL', + priority: 5, + } as TriggerDetail, + }; + + mockInstance.axios.get + .mockResolvedValueOnce(mockGroupsResponse) + .mockResolvedValueOnce(mockTrigger1) + .mockResolvedValueOnce(mockTrigger2); + + const result = await QuartzActuatorService.fetchAllTriggers( + mockInstance as any, + ); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual(mockTrigger1.data); + expect(result[1]).toEqual(mockTrigger2.data); + }); + + it('should handle triggers from multiple groups', async () => { + const mockGroupsResponse = { + data: { + groups: { + samples: { paused: false, triggers: ['trigger1'] }, + tests: { paused: false, triggers: ['trigger2', 'trigger3'] }, + }, + }, + }; + + mockInstance.axios.get + .mockResolvedValueOnce(mockGroupsResponse) + .mockResolvedValue({ + data: { + type: 'simple', + state: 'NORMAL', + priority: 5, + } as TriggerDetail, + }); + + const result = await QuartzActuatorService.fetchAllTriggers( + mockInstance as any, + ); + + expect(result).toHaveLength(3); + }); + + it('should handle partial failures gracefully', async () => { + const mockGroupsResponse = { + data: { + groups: { + samples: { paused: false, triggers: ['trigger1', 'trigger2'] }, + }, + }, + }; + + mockInstance.axios.get + .mockResolvedValueOnce(mockGroupsResponse) + .mockResolvedValueOnce({ + data: { + name: 'trigger1', + group: 'samples', + type: 'simple', + state: 'NORMAL', + priority: 7, + }, + }) + .mockRejectedValueOnce(new Error('Failed to fetch trigger2')); + + const result = await QuartzActuatorService.fetchAllTriggers( + mockInstance as any, + ); + + // Should return only successful trigger + expect(result).toHaveLength(1); + expect(result[0].name).toBe('trigger1'); + }); + + it('should return empty array when no triggers exist', async () => { + const mockGroupsResponse = { + data: { + groups: {}, + }, + }; + + mockInstance.axios.get.mockResolvedValueOnce(mockGroupsResponse); + + const result = await QuartzActuatorService.fetchAllTriggers( + mockInstance as any, + ); + + expect(result).toHaveLength(0); + }); + + it('should handle groups with empty trigger arrays', async () => { + const mockGroupsResponse = { + data: { + groups: { + samples: { paused: false, triggers: [] }, + }, + }, + }; + + mockInstance.axios.get.mockResolvedValueOnce(mockGroupsResponse); + + const result = await QuartzActuatorService.fetchAllTriggers( + mockInstance as any, + ); + + expect(result).toHaveLength(0); + }); + + it('should make correct number of API calls', async () => { + const mockGroupsResponse = { + data: { + groups: { + samples: { paused: false, triggers: ['t1', 't2'] }, + tests: { paused: false, triggers: ['t3'] }, + }, + }, + }; + + mockInstance.axios.get + .mockResolvedValueOnce(mockGroupsResponse) + .mockResolvedValue({ + data: { + type: 'simple', + state: 'NORMAL', + priority: 5, + } as TriggerDetail, + }); + + await QuartzActuatorService.fetchAllTriggers(mockInstance as any); + + // 1 call for groups + 3 calls for individual triggers = 4 total + expect(mockInstance.axios.get).toHaveBeenCalledTimes(4); + }); + }); +}); diff --git a/spring-boot-admin-server-ui/src/main/frontend/services/quartz-actuator.ts b/spring-boot-admin-server-ui/src/main/frontend/services/quartz-actuator.ts new file mode 100644 index 00000000000..529d3284428 --- /dev/null +++ b/spring-boot-admin-server-ui/src/main/frontend/services/quartz-actuator.ts @@ -0,0 +1,244 @@ +/* + * Copyright 2014-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { AxiosResponse } from 'axios'; + +import uri from '../utils/uri'; +import Instance from './instance'; + +/** + * Quartz Actuator Service + * + * Provides static methods to interact with Spring Boot Quartz Actuator endpoint. + * All methods accept an Instance parameter and return promises with typed responses. + * + * This service acts as a utility layer for Quartz operations, abstracting + * the API communication and providing both low-level and high-level fetch methods. + */ +export class QuartzActuatorService { + /** + * Fetch all job groups with their job names + * + * @param instance The Instance to fetch from + * @returns Promise with job groups and job names + */ + static async fetchJobGroups( + instance: Instance, + ): Promise> { + return instance.axios.get(uri`actuator/quartz/jobs`, { + headers: { Accept: 'application/json' }, + }); + } + + /** + * Fetch details of a specific job + * + * @param instance The Instance to fetch from + * @param groupName The job group name + * @param jobName The job name + * @returns Promise with job details including triggers + */ + static async fetchJobDetail( + instance: Instance, + groupName: string, + jobName: string, + ): Promise> { + return instance.axios.get( + uri`actuator/quartz/jobs/${groupName}/${jobName}`, + { headers: { Accept: 'application/json' } }, + ); + } + + /** + * Fetch all jobs in all groups with full details + * + * Fetches the list of job groups, then fetches each job individually, + * and aggregates them into a single array. + * Handles partial failures gracefully - returns successful jobs even if some fail. + * + * @param instance The Instance to fetch from + * @returns Promise with array of all job details + */ + static async fetchAllJobs(instance: Instance): Promise { + const response = await this.fetchJobGroups(instance); + const jobList = response.data; + const promises: Promise>[] = []; + + for (const group in jobList.groups) { + for (const jobName of jobList.groups[group].jobs) { + promises.push(this.fetchJobDetail(instance, group, jobName)); + } + } + + const results = await Promise.allSettled(promises); + return results + .filter((r) => r.status === 'fulfilled') + .map( + (r) => + (r as PromiseFulfilledResult>).value.data, + ); + } + + /** + * Fetch all trigger groups with their trigger names and paused status + * + * @param instance The Instance to fetch from + * @returns Promise with trigger groups, names, and paused status + */ + static async fetchTriggerGroups( + instance: Instance, + ): Promise> { + return instance.axios.get(uri`actuator/quartz/triggers`, { + headers: { Accept: 'application/json' }, + }); + } + + /** + * Fetch details of a specific trigger + * + * @param instance The Instance to fetch from + * @param groupName The trigger group name + * @param triggerName The trigger name + * @returns Promise with trigger details including type-specific configuration + */ + static async fetchTriggerDetail( + instance: Instance, + groupName: string, + triggerName: string, + ): Promise> { + return instance.axios.get( + uri`actuator/quartz/triggers/${groupName}/${triggerName}`, + { headers: { Accept: 'application/json' } }, + ); + } + + /** + * Fetch all triggers in all groups with full details + * + * Fetches the list of trigger groups, then fetches each trigger individually, + * and aggregates them into a single array. + * Handles partial failures gracefully - returns successful triggers even if some fail. + * + * @param instance The Instance to fetch from + * @returns Promise with array of all trigger details + */ + static async fetchAllTriggers(instance: Instance): Promise { + const response = await this.fetchTriggerGroups(instance); + const groupList = response.data; + const promises: Promise>[] = []; + + for (const group in groupList.groups) { + for (const triggerName of groupList.groups[group].triggers) { + promises.push(this.fetchTriggerDetail(instance, group, triggerName)); + } + } + + const results = await Promise.allSettled(promises); + return results + .filter((r) => r.status === 'fulfilled') + .map( + (r) => + (r as PromiseFulfilledResult>).value + .data, + ); + } +} + +/** + * Response structure from GET /actuator/quartz/jobs + */ +export interface JobGroupsResponse { + groups: { [groupName: string]: { jobs: string[] } }; +} + +/** + * Response structure from GET /actuator/quartz/jobs/{groupName}/{jobName} + */ +export interface JobDetail { + group: string; + name: string; + className: string; + description?: string; + durable: boolean; + requestRecovery: boolean; + data?: { [key: string]: string }; + triggers: Array<{ + group: string; + name: string; + previousFireTime?: string; + nextFireTime?: string; + priority: number; + }>; +} + +/** + * Response structure from GET /actuator/quartz/triggers + */ +export interface TriggerGroupsResponse { + groups: { + [groupName: string]: { + paused: boolean; + triggers: string[]; + }; + }; +} + +/** + * Response structure from GET /actuator/quartz/triggers/{groupName}/{triggerName} + * Includes type-specific trigger details (cron, simple, dailyTimeInterval, calendarInterval, custom) + */ +export interface TriggerDetail { + group: string; + name: string; + description?: string; + state: string; + type: 'cron' | 'simple' | 'dailyTimeInterval' | 'calendarInterval' | 'custom'; + priority: number; + startTime?: string; + endTime?: string; + previousFireTime?: string; + nextFireTime?: string; + finalFireTime?: string; + calendarName?: string; + data?: { [key: string]: string }; + + cron?: { + expression: string; + timeZone?: string; + }; + simple?: { + interval: number; + repeatCount: number; + timesTriggered: number; + }; + dailyTimeInterval?: { + interval: number; + daysOfWeek: number[]; + startTimeOfDay?: string; + endTimeOfDay?: string; + repeatCount: number; + timesTriggered: number; + }; + calendarInterval?: { + interval: number; + timeZone?: string; + timesTriggered: number; + preserveHourOfDayAcrossDaylightSavings: boolean; + skipDayIfHourDoesNotExist: boolean; + }; + custom?: { + trigger: string; + }; +} diff --git a/spring-boot-admin-server-ui/src/main/frontend/views/instances/quartz/index.vue b/spring-boot-admin-server-ui/src/main/frontend/views/instances/quartz/index.vue index 5080f6014cc..6bcc02b9bbc 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/views/instances/quartz/index.vue +++ b/spring-boot-admin-server-ui/src/main/frontend/views/instances/quartz/index.vue @@ -75,6 +75,7 @@ diff --git a/spring-boot-admin-server-ui/src/main/frontend/views/instances/quartz/i18n.de.json b/spring-boot-admin-server-ui/src/main/frontend/views/instances/quartz/i18n.de.json index 6e87940f2a2..55d754b1309 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/views/instances/quartz/i18n.de.json +++ b/spring-boot-admin-server-ui/src/main/frontend/views/instances/quartz/i18n.de.json @@ -2,7 +2,21 @@ "instances": { "quartz": { "label": "Quartz", - "no_data": "Keine Quartz Informationen vorhanden" + "no_data": "Keine Quartz Informationen vorhanden", + "jobs": "Aufträge", + "triggers": "Auslöser", + "active_triggers": "Aktive Auslöser", + "paused_triggers": "Unterbrochene Auslöser", + "total": "insgesamt", + "name": "Name", + "description": "Beschreibung", + "group": "Gruppe", + "durable": "Persistent", + "recovery": "Wiederherstellung", + "state": "Status", + "type": "Typ", + "priority": "Priorität", + "next_fire_time": "Nächste Ausführungszeit" } } } diff --git a/spring-boot-admin-server-ui/src/main/frontend/views/instances/quartz/i18n.en.json b/spring-boot-admin-server-ui/src/main/frontend/views/instances/quartz/i18n.en.json index 1f2233c3859..c63a702f10e 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/views/instances/quartz/i18n.en.json +++ b/spring-boot-admin-server-ui/src/main/frontend/views/instances/quartz/i18n.en.json @@ -2,7 +2,21 @@ "instances": { "quartz": { "label": "Quartz", - "no_data": "No Quartz information available" + "no_data": "No Quartz information available", + "jobs": "Jobs", + "triggers": "Triggers", + "active_triggers": "Active Triggers", + "paused_triggers": "Paused Triggers", + "total": "total", + "name": "Name", + "description": "Description", + "group": "Group", + "durable": "Durable", + "recovery": "Recovery", + "state": "State", + "type": "Type", + "priority": "Priority", + "next_fire_time": "Next Fire Time" } } } diff --git a/spring-boot-admin-server-ui/src/main/frontend/views/instances/quartz/index.vue b/spring-boot-admin-server-ui/src/main/frontend/views/instances/quartz/index.vue index 6bcc02b9bbc..bb313bd7ca3 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/views/instances/quartz/index.vue +++ b/spring-boot-admin-server-ui/src/main/frontend/views/instances/quartz/index.vue @@ -1,150 +1,142 @@ @@ -66,6 +68,8 @@ import { computed, ref } from 'vue'; import JobRow from './job-row.vue'; +import Instance from '@/services/instance'; + interface Job { group: string; name: string; @@ -74,6 +78,7 @@ interface Job { interface Props { jobs: Job[]; + instance: Instance; } const props = defineProps(); @@ -88,4 +93,11 @@ const toggleJobExpanded = (job: Job): void => { const key = getJobKey(job); selectedJobKey.value = selectedJobKey.value === key ? null : key; }; + +const handleAction = (action: string, success: boolean): void => { + // Handle action feedback (can be connected to toast notifications later) + if (!success) { + console.warn(`Job action '${action}' failed`); + } +}; From 0e8411be8511004163e05ec3839a355521b08641 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20Ko=CC=88ninger?= Date: Thu, 26 Mar 2026 18:07:19 +0100 Subject: [PATCH 06/20] feat(quartz): update trigger row styling for improved visibility and interaction --- .../src/main/frontend/views/instances/quartz/trigger-row.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spring-boot-admin-server-ui/src/main/frontend/views/instances/quartz/trigger-row.vue b/spring-boot-admin-server-ui/src/main/frontend/views/instances/quartz/trigger-row.vue index 6b7efd61276..5b1ae2ed544 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/views/instances/quartz/trigger-row.vue +++ b/spring-boot-admin-server-ui/src/main/frontend/views/instances/quartz/trigger-row.vue @@ -2,7 +2,7 @@ From 00c6994e611f2d32640e58eefe6ad4e5747cbde2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20Ko=CC=88ninger?= Date: Thu, 26 Mar 2026 18:07:42 +0100 Subject: [PATCH 07/20] feat(styles): standardize CSS import syntax and improve variable formatting --- .../src/main/frontend/login.css | 2 +- .../src/main/frontend/theme.css | 22 +++++++++---------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/spring-boot-admin-server-ui/src/main/frontend/login.css b/spring-boot-admin-server-ui/src/main/frontend/login.css index f6659092dec..6d8c3018452 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/login.css +++ b/spring-boot-admin-server-ui/src/main/frontend/login.css @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -@import "./theme.css"; +@import './theme.css'; :root { --bg-color-start: #71e69c; diff --git a/spring-boot-admin-server-ui/src/main/frontend/theme.css b/spring-boot-admin-server-ui/src/main/frontend/theme.css index de81de43640..23f6e454003 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/theme.css +++ b/spring-boot-admin-server-ui/src/main/frontend/theme.css @@ -14,20 +14,20 @@ * limitations under the License. */ -@import "tailwindcss"; +@import 'tailwindcss'; @custom-variant dark (&:where(.dark, .dark *)); @plugin "@tailwindcss/forms"; @plugin "@tailwindcss/typography"; @theme { - --color-sba-50: var(--main-50); - --color-sba-100: var(--main-100); - --color-sba-200: var(--main-200); - --color-sba-300: var(--main-300); - --color-sba-400: var(--main-400); - --color-sba-500: var(--main-500); - --color-sba-600: var(--main-600); - --color-sba-700: var(--main-700); - --color-sba-800: var(--main-800); - --color-sba-900: var(--main-900); + --color-sba-50: var(--main-50); + --color-sba-100: var(--main-100); + --color-sba-200: var(--main-200); + --color-sba-300: var(--main-300); + --color-sba-400: var(--main-400); + --color-sba-500: var(--main-500); + --color-sba-600: var(--main-600); + --color-sba-700: var(--main-700); + --color-sba-800: var(--main-800); + --color-sba-900: var(--main-900); } From 5353777e8084fd1f615839783b145d79644558e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20Ko=CC=88ninger?= Date: Fri, 27 Mar 2026 07:44:41 +0100 Subject: [PATCH 08/20] feat(quartz): update UI styles for improved consistency and readability --- .../views/instances/quartz/error-alert.vue | 11 +-- .../views/instances/quartz/job-row.vue | 25 +++--- .../views/instances/quartz/jobs-section.vue | 7 +- .../views/instances/quartz/no-data-alert.vue | 10 +-- .../instances/quartz/quartz-summary-card.vue | 78 ++++++------------- .../views/instances/quartz/quartz-summary.vue | 4 - .../views/instances/quartz/trigger-row.vue | 41 ++++------ .../calendar-interval-details.vue | 10 --- .../instances/quartz/triggers-section.vue | 7 +- 9 files changed, 54 insertions(+), 139 deletions(-) diff --git a/spring-boot-admin-server-ui/src/main/frontend/views/instances/quartz/error-alert.vue b/spring-boot-admin-server-ui/src/main/frontend/views/instances/quartz/error-alert.vue index 7adf52b5127..fd80730669b 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/views/instances/quartz/error-alert.vue +++ b/spring-boot-admin-server-ui/src/main/frontend/views/instances/quartz/error-alert.vue @@ -1,15 +1,12 @@