diff --git a/src/main/java/org/phoebus/channelfinder/ChannelRepository.java b/src/main/java/org/phoebus/channelfinder/ChannelRepository.java index 7869f4c8..ac671e3c 100644 --- a/src/main/java/org/phoebus/channelfinder/ChannelRepository.java +++ b/src/main/java/org/phoebus/channelfinder/ChannelRepository.java @@ -465,70 +465,26 @@ private BuiltQuery getBuiltQuery(MultiValueMap searchParameters) } switch (key) { case "~name": - for (String value : parameter.getValue()) { - DisMaxQuery.Builder nameQuery = new DisMaxQuery.Builder(); - for (String pattern : value.split(valueSplitPattern)) { - nameQuery.queries(getSingleValueQuery("name", pattern.trim())); - } - boolQuery.must(nameQuery.build()._toQuery()); - } + addNameQuery(parameter, valueSplitPattern, boolQuery); break; case "~tag": - for (String value : parameter.getValue()) { - DisMaxQuery.Builder tagQuery = new DisMaxQuery.Builder(); - for (String pattern : value.split(valueSplitPattern)) { - tagQuery.queries( - NestedQuery.of(n -> n.path("tags").query( - getSingleValueQuery("tags.name", pattern.trim())))._toQuery()); - } - if (isNot) { - boolQuery.mustNot(tagQuery.build()._toQuery()); - } else { - boolQuery.must(tagQuery.build()._toQuery()); - } - - } + addTagsQuery(parameter, valueSplitPattern, isNot, boolQuery); break; case "~size": - Optional maxSize = parameter.getValue().stream().max(Comparator.comparing(Integer::valueOf)); - if (maxSize.isPresent()) { - size = Integer.parseInt(maxSize.get()); - } + size = parseCountParameter(parameter, size); break; case "~from": - Optional maxFrom = parameter.getValue().stream().max(Comparator.comparing(Integer::valueOf)); - if (maxFrom.isPresent()) { - from = Integer.parseInt(maxFrom.get()); - } + from = parseCountParameter(parameter, from); break; case "~search_after": searchAfter = parameter.getValue().stream().findFirst(); break; case "~track_total_hits": - Optional firstTrackTotalHits = parameter.getValue().stream().findFirst(); - if (firstTrackTotalHits.isPresent()) { - trackTotalHits = Boolean.parseBoolean(firstTrackTotalHits.get()); - } + trackTotalHits = isTrackTotalHits(parameter, trackTotalHits); break; default: - DisMaxQuery.Builder propertyQuery = new DisMaxQuery.Builder(); - for (String value : parameter.getValue()) { - for (String pattern : value.split(valueSplitPattern)) { - String finalKey = key; - BoolQuery bq; - if (isNot) { - bq = BoolQuery.of(p -> p.must(getSingleValueQuery("properties.name", finalKey)) - .mustNot(getSingleValueQuery("properties.value", pattern.trim()))); - } else { - bq = BoolQuery.of(p -> p.must(getSingleValueQuery("properties.name", finalKey)) - .must(getSingleValueQuery("properties.value", pattern.trim()))); - } - propertyQuery.queries( - NestedQuery.of(n -> n.path("properties").query(bq._toQuery()))._toQuery() - ); - } - } + DisMaxQuery.Builder propertyQuery = calculatePropertiesQuery(parameter, valueSplitPattern, key, isNot); boolQuery.must(propertyQuery.build()._toQuery()); break; } @@ -536,24 +492,99 @@ private BuiltQuery getBuiltQuery(MultiValueMap searchParameters) return new BuiltQuery(boolQuery, size, from, searchAfter, trackTotalHits); } + private static DisMaxQuery.Builder calculatePropertiesQuery(Map.Entry> parameter, String valueSplitPattern, String key, boolean isNot) { + DisMaxQuery.Builder propertyQuery = new DisMaxQuery.Builder(); + for (String value : parameter.getValue()) { + for (String pattern : value.split(valueSplitPattern)) { + BoolQuery bq; + bq = calculatePropertyQuery(key, isNot, pattern); + addPropertyQuery(isNot, pattern, propertyQuery, bq); + } + } + return propertyQuery; + } + + private static void addPropertyQuery(boolean isNot, String pattern, DisMaxQuery.Builder propertyQuery, BoolQuery bq) { + if (isNot && pattern.trim().equals("*")) { + + propertyQuery.queries( + BoolQuery.of( p -> p.mustNot( + NestedQuery.of(n -> n.path("properties").query(bq._toQuery()))._toQuery() + ))._toQuery() + ); + } else { + + propertyQuery.queries( + NestedQuery.of(n -> n.path("properties").query(bq._toQuery()))._toQuery() + ); + } + } + + private static BoolQuery calculatePropertyQuery(String key, boolean isNot, String pattern) { + BoolQuery bq; + if (isNot) { + if (pattern.trim().equals("*")) { + bq = BoolQuery.of(p -> p.must(getSingleValueQuery("properties.name", key))); + } else { + bq = BoolQuery.of(p -> p.must(getSingleValueQuery("properties.name", key)) + .mustNot(getSingleValueQuery("properties.value", pattern.trim()))); + } + } else { + bq = BoolQuery.of(p -> p.must(getSingleValueQuery("properties.name", key)) + .must(getSingleValueQuery("properties.value", pattern.trim()))); + } + return bq; + } + + private static boolean isTrackTotalHits(Map.Entry> parameter, boolean trackTotalHits) { + Optional firstTrackTotalHits = parameter.getValue().stream().findFirst(); + if (firstTrackTotalHits.isPresent()) { + trackTotalHits = Boolean.parseBoolean(firstTrackTotalHits.get()); + } + return trackTotalHits; + } + + private static int parseCountParameter(Map.Entry> parameter, int size) { + Optional maxSize = parameter.getValue().stream().max(Comparator.comparing(Integer::valueOf)); + if (maxSize.isPresent()) { + size = Integer.parseInt(maxSize.get()); + } + return size; + } + + private static void addTagsQuery(Map.Entry> parameter, String valueSplitPattern, boolean isNot, BoolQuery.Builder boolQuery) { + for (String value : parameter.getValue()) { + DisMaxQuery.Builder tagQuery = new DisMaxQuery.Builder(); + for (String pattern : value.split(valueSplitPattern)) { + tagQuery.queries( + NestedQuery.of(n -> n.path("tags").query( + getSingleValueQuery("tags.name", pattern.trim())))._toQuery()); + } + if (isNot) { + boolQuery.mustNot(tagQuery.build()._toQuery()); + } else { + boolQuery.must(tagQuery.build()._toQuery()); + } + + } + } + + private static void addNameQuery(Map.Entry> parameter, String valueSplitPattern, BoolQuery.Builder boolQuery) { + for (String value : parameter.getValue()) { + DisMaxQuery.Builder nameQuery = new DisMaxQuery.Builder(); + for (String pattern : value.split(valueSplitPattern)) { + nameQuery.queries(getSingleValueQuery("name", pattern.trim())); + } + boolQuery.must(nameQuery.build()._toQuery()); + } + } + private static Query getSingleValueQuery(String name, String pattern) { return WildcardQuery.of(w -> w.field(name).caseInsensitive(true).value(pattern))._toQuery(); } - private static class BuiltQuery { - public final BoolQuery.Builder boolQuery; - public final Integer size; - public final Integer from; - public final Optional searchAfter; - public final boolean trackTotalHits; - - public BuiltQuery(BoolQuery.Builder boolQuery, Integer size, Integer from, Optional searchAfter, boolean trackTotalHits) { - this.boolQuery = boolQuery; - this.size = size; - this.from = from; - this.searchAfter = searchAfter; - this.trackTotalHits = trackTotalHits; - } + private record BuiltQuery(BoolQuery.Builder boolQuery, Integer size, Integer from, Optional searchAfter, + boolean trackTotalHits) { } /** diff --git a/src/main/java/org/phoebus/channelfinder/MetricsService.java b/src/main/java/org/phoebus/channelfinder/MetricsService.java index 82f82f00..ed988bfb 100644 --- a/src/main/java/org/phoebus/channelfinder/MetricsService.java +++ b/src/main/java/org/phoebus/channelfinder/MetricsService.java @@ -1,102 +1,167 @@ package org.phoebus.channelfinder; import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.ImmutableTag; import io.micrometer.core.instrument.MeterRegistry; -import io.micrometer.core.instrument.MultiGauge; -import io.micrometer.core.instrument.Tags; +import io.micrometer.core.instrument.Tag; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.PropertySource; -import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import javax.annotation.PostConstruct; import java.util.ArrayList; import java.util.Arrays; -import java.util.logging.Level; -import java.util.logging.Logger; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.Map.Entry; @Service @PropertySource(value = "classpath:application.properties") public class MetricsService { - private static final Logger logger = Logger.getLogger(MetricsService.class.getName()); public static final String CF_TOTAL_CHANNEL_COUNT = "cf.total.channel.count"; public static final String CF_PROPERTY_COUNT = "cf.property.count"; public static final String CF_TAG_COUNT = "cf.tag.count"; public static final String CF_CHANNEL_COUNT = "cf.channel.count"; + public static final String CF_TAG_ON_CHANNELS_COUNT = "cf.tag_on_channels.count"; private static final String METRIC_DESCRIPTION_TOTAL_CHANNEL_COUNT = "Count of all ChannelFinder channels"; private static final String METRIC_DESCRIPTION_PROPERTY_COUNT = "Count of all ChannelFinder properties"; private static final String METRIC_DESCRIPTION_TAG_COUNT = "Count of all ChannelFinder tags"; - private static final String METRIC_DESCRIPTION_CHANNEL_COUNT = - "Count of channels with specific property with and specific value"; + private static final String METRIC_DESCRIPTION_CHANNEL_COUNT = "Count of all ChannelFinder channels with set properties"; + private static final String BASE_UNIT = "channels"; + private static final String NEGATE = "!"; + public static final String NOT_SET = "-"; + private final ChannelRepository channelRepository; private final PropertyRepository propertyRepository; private final TagRepository tagRepository; private final MeterRegistry meterRegistry; - MultiGauge channelCounts; - @Value("${metrics.tags}") private String[] tags; - @Value("#{${metrics.properties:{{'pvStatus', 'Active'}, {'pvStatus', 'Inactive'}}}}") - private String[][] properties; + @Value("${metrics.properties}") + private String metricProperties; + + Map> parseProperties() { + if (metricProperties == null || metricProperties.isEmpty()) { + return new LinkedMultiValueMap<>(); + } + return Arrays.stream(metricProperties.split(";")).map(s -> + { + String[] split = s.split(":"); + String k = split[0].trim(); + List v = Arrays.stream(split[1].split(",")).map(String::trim).toList(); + return Map.entry(k, v); + }).collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + } @Autowired public MetricsService( - final ChannelRepository channelRepository, - final PropertyRepository propertyRepository, - final TagRepository tagRepository, - final MeterRegistry meterRegistry) { + final ChannelRepository channelRepository, + final PropertyRepository propertyRepository, + final TagRepository tagRepository, + final MeterRegistry meterRegistry) { this.channelRepository = channelRepository; this.propertyRepository = propertyRepository; this.tagRepository = tagRepository; this.meterRegistry = meterRegistry; - registerGaugeMetrics(); } + @PostConstruct private void registerGaugeMetrics() { Gauge.builder(CF_TOTAL_CHANNEL_COUNT, () -> channelRepository.count(new LinkedMultiValueMap<>())) - .description(METRIC_DESCRIPTION_TOTAL_CHANNEL_COUNT) - .register(meterRegistry); + .description(METRIC_DESCRIPTION_TOTAL_CHANNEL_COUNT) + .register(meterRegistry); Gauge.builder(CF_PROPERTY_COUNT, propertyRepository::count) - .description(METRIC_DESCRIPTION_PROPERTY_COUNT) - .register(meterRegistry); + .description(METRIC_DESCRIPTION_PROPERTY_COUNT) + .register(meterRegistry); Gauge.builder(CF_TAG_COUNT, tagRepository::count) - .description(METRIC_DESCRIPTION_TAG_COUNT) - .register(meterRegistry); - channelCounts = MultiGauge.builder(CF_CHANNEL_COUNT) - .description(METRIC_DESCRIPTION_CHANNEL_COUNT) - .baseUnit("channels") + .description(METRIC_DESCRIPTION_TAG_COUNT) + .register(meterRegistry); + registerTagMetrics(); + registerPropertyMetrics(); + } + + private void registerTagMetrics() { + // Add tags + for (String tag : tags) { + Gauge.builder(CF_TAG_ON_CHANNELS_COUNT, () -> channelRepository.countByTag(tag)) + .description("Number of channels with tag") + .tag("tag", tag) + .baseUnit(BASE_UNIT) .register(meterRegistry); + } } - @Scheduled(fixedRate = 5000) - public void updateMetrics() { - logger.log( - Level.FINER, - () -> "Updating metrics for properties " + Arrays.deepToString(properties) + " and tags " + Arrays.toString(tags)); - ArrayList> rows = new ArrayList<>(); + public static List> generateAllMultiValueMaps(Map> properties) { + List> allMultiValueMaps = new ArrayList<>(); - // Add tags - for (String tag: tags) { - long count = channelRepository.countByTag(tag); - rows.add(MultiGauge.Row.of(Tags.of("tag", tag), count )); - logger.log( - Level.FINER, - () -> "Updating metrics for tag " + tag + " to " + count); + if (properties.isEmpty()) { + allMultiValueMaps.add(new LinkedMultiValueMap<>()); // Add an empty map for the case where all are null + return allMultiValueMaps; + } + + List>> entries = new ArrayList<>(properties.entrySet()); + generateCombinations(entries, 0, new LinkedMultiValueMap<>(), allMultiValueMaps); + + return allMultiValueMaps; + } + + private static void generateCombinations( + List>> entries, + int index, + MultiValueMap currentMap, + List> allMultiValueMaps) { + + if (index == entries.size()) { + allMultiValueMaps.add(new LinkedMultiValueMap<>(currentMap)); + return; + } + + Entry> currentEntry = entries.get(index); + String key = currentEntry.getKey(); + List values = currentEntry.getValue(); + + // Add the other options + for (String value : values) { + LinkedMultiValueMap nextMapWithValue = new LinkedMultiValueMap<>(currentMap); + if (value.startsWith(NEGATE)) { + nextMapWithValue.add(key + NEGATE, value.substring(1)); + } else { + nextMapWithValue.add(key, value); + } + generateCombinations(entries, index + 1, nextMapWithValue, allMultiValueMaps); } + } - // Add properties - for (String[] propertyValue: properties) { - long count = channelRepository.countByProperty(propertyValue[0], propertyValue[1]); - rows.add(MultiGauge.Row.of(Tags.of(propertyValue[0], propertyValue[1]), count)); - logger.log( - Level.FINER, - () -> "Updating metrics for property " + propertyValue[0] + ":" + propertyValue[1] + " to " + count); + private List metricTagsFromMultiValueMap(MultiValueMap multiValueMap) { + List metricTags = new ArrayList<>(); + for (Map.Entry entry : multiValueMap.toSingleValueMap().entrySet()) { + if (entry.getKey().endsWith(NEGATE)) { + if (entry.getValue().equals("*")) { + metricTags.add(new ImmutableTag(entry.getKey().substring(0, entry.getKey().length() - 1), NOT_SET)); + } else { + metricTags.add(new ImmutableTag(entry.getKey().substring(0, entry.getKey().length() - 1), NEGATE + entry.getValue())); + } + } else { + metricTags.add(new ImmutableTag(entry.getKey(), entry.getValue())); + } } + return metricTags; + } + private void registerPropertyMetrics() { + Map> properties = parseProperties(); - channelCounts.register(rows, true); + List> combinations = generateAllMultiValueMaps(properties); + combinations.forEach(map -> Gauge.builder(CF_CHANNEL_COUNT, () -> channelRepository.count(map)) + .description(METRIC_DESCRIPTION_CHANNEL_COUNT) + .tags(metricTagsFromMultiValueMap(map)) + .register(meterRegistry) + ); } } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 6b891fb1..2ac4361e 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -137,4 +137,4 @@ aa.auto_pause= #actuator management.endpoints.web.exposure.include=prometheus, metrics, health, info metrics.tags= -metrics.properties={{'pvStatus', 'Active'}, {'pvStatus', 'Inactive'}} +metrics.properties=pvStatus:Active, Inactive diff --git a/src/site/sphinx/aa_processor.rst b/src/site/sphinx/aa_processor.rst new file mode 100644 index 00000000..c8edff04 --- /dev/null +++ b/src/site/sphinx/aa_processor.rst @@ -0,0 +1,58 @@ +.. _aa_processor: +Archiver Appliance Processor +============================ + +.. _aa_processor_config: +Configuration +------------- +To enable the archiver appliance configuration processor, set the property :ref:`aa.enabled` to **true**. + +A list of archiver appliance URLs and aliases. :: + + aa.urls={'default': 'http://archiver-01.example.com:17665', 'neutron-controls': 'http://archiver-02.example.com:17665'} + +To set the choice of default archiver appliance, set the property :ref:`aa.default_alias` to the alias of the default archiver appliance. This setting can also be a comma-separated list if you want multiple default archivers. + +To pass the PV as "pva://PVNAME" to the archiver appliance, set the property :ref:`aa.pva` to **true**. + +The properties checked for setting a PV to be archived are :: + + aa.archive_property_name=archive + aa.archiver_property_name=archiver + +To set the auto pause behaviour, configure the parameter :ref:`aa.auto_pause`. Set to pvStatus to pause on pvStatus=Inactive, +and resume on pvStatus=Active. Set to archive to pause on archive_property_name not existing. Set to both to pause on pvStatus=Inactive and archive_property_name:: + + aa.auto_pause=pvStatus,archive + +AA Plugin Example +----------------- + +A common use case for the archiver appliance processor is for sites that use the Recsync project to populate Channel Finder. +With the reccaster module, info tags in the IOC database specify the archiving parameters and these properties will be pushed to Channel Finder by the recceiver service. + +In the example database below, the AA plugin will make requests to archive each PV. +The plugin will request MyPV to be archived with the SCAN method and sampling rate of 10 seconds to the "aa_appliance0" instance specified in aa.urls property. +MyPV2 will use the MONITOR method and a sampling rate of 0.1 seconds, and the request will be sent to the URL mapped to the the "aa_appliance1: key. +MyPolicyPV shows an example that uses an archiver appliance "Named Policy" string and also uses the URL specified in the aa.default_alias property since the "archiver" tag is missing. + +For named policy PVs, the AA plugin will first check that the named policy exists in the appliance using the getPolicyList BPL endpoint. + +.. code-block:: + + record(ao, "MyPV") { + info(archive, "scan@10") + info(archiver, "aa_appliance0") + } + record(ao, "MyPV2") { + info(archive, "monitor@0.1") + info(archiver, "aa_appliance1") + } + record(ao, "MyPVWithMultipleArchivers") { + info(archive, "monitor@0.1") + info(archiver, "aa_appliance0,aa_appliance1") + } + record(ao, "MyPolicyPV") { + info(archive, "AAPolicyName") + # no archiver tag so PV sent to archiver in aa.default_alias + } diff --git a/src/site/sphinx/config.rst b/src/site/sphinx/config.rst index 861184c0..6381f8f5 100644 --- a/src/site/sphinx/config.rst +++ b/src/site/sphinx/config.rst @@ -91,59 +91,15 @@ SSL Config server.ssl.key-store - Path to SSL keystore file -Archiver Appliance Configuration Processor +Archiver Appliance Processor Configuration ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -To enable the archiver appliance configuration processor, set the property :ref:`aa.enabled` to **true**. -A list of archiver appliance URLs and aliases. :: +See :ref:`_aa_processor_config`. - aa.urls={'default': 'http://archiver-01.example.com:17665', 'neutron-controls': 'http://archiver-02.example.com:17665'} +Metrics +^^^^^^^ -To set the choice of default archiver appliance, set the property :ref:`aa.default_alias` to the alias of the default archiver appliance. This setting can also be a comma-separated list if you want multiple default archivers. - -To pass the PV as "pva://PVNAME" to the archiver appliance, set the property :ref:`aa.pva` to **true**. - -The properties checked for setting a PV to be archived are :: - - aa.archive_property_name=archive - aa.archiver_property_name=archiver - -To set the auto pause behaviour, configure the parameter :ref:`aa.auto_pause`. Set to pvStatus to pause on pvStatus=Inactive, -and resume on pvStatus=Active. Set to archive to pause on archive_property_name not existing. Set to both to pause on pvStatus=Inactive and archive_property_name:: - - aa.auto_pause=pvStatus,archive - -AA Plugin Example -""""""""""""""""" - -A common use case for the archiver appliance processor is for sites that use the Recsync project to populate Channel Finder. -With the reccaster module, info tags in the IOC database specify the archiving parameters and these properties will be pushed to Channel Finder by the recceiver service. - -In the example database below, the AA plugin will make requests to archive each PV. -The plugin will request MyPV to be archived with the SCAN method and sampling rate of 10 seconds to the "aa_appliance0" instance specified in aa.urls property. -MyPV2 will use the MONITOR method and a sampling rate of 0.1 seconds, and the request will be sent to the URL mapped to the the "aa_appliance1: key. -MyPolicyPV shows an example that uses an archiver appliance "Named Policy" string and also uses the URL specified in the aa.default_alias property since the "archiver" tag is missing. - -For named policy PVs, the AA plugin will first check that the named policy exists in the appliance using the getPolicyList BPL endpoint. - -.. code-block:: - - record(ao, "MyPV") { - info(archive, "scan@10") - info(archiver, "aa_appliance0") - } - record(ao, "MyPV2") { - info(archive, "monitor@0.1") - info(archiver, "aa_appliance1") - } - record(ao, "MyPVWithMultipleArchivers") { - info(archive, "monitor@0.1") - info(archiver, "aa_appliance0,aa_appliance1") - } - record(ao, "MyPolicyPV") { - info(archive, "AAPolicyName") - # no archiver tag so PV sent to archiver in aa.default_alias - } +See :ref:`_metrics`. EPICS PV Access Server @@ -162,4 +118,3 @@ IPv4 you can set the environment variable Or to not have the EPICS PV Access Server listen, then: EPICS_PVAS_INTF_ADDR_LIST="0.0.0.0" - diff --git a/src/site/sphinx/index.rst b/src/site/sphinx/index.rst index fa3c1629..e3a71dbd 100644 --- a/src/site/sphinx/index.rst +++ b/src/site/sphinx/index.rst @@ -22,3 +22,5 @@ Related projects may be found in the `ChannelFinder testChannels = Arrays.asList(testChannel, testChannel1, testChannel2, testChannel3); + SearchResult foundChannelsResponse = null; + + List createdChannels = channelRepository.indexAll(testChannels); + SearchResult createdSearchResult = new SearchResult(createdChannels, 4); + cleanupTestChannels = testChannels; + + try { + MultiValueMap searchParameters = new LinkedMultiValueMap<>(); + searchParameters.set(testProperties.get(0).getName().toLowerCase() + "!", "*"); + foundChannelsResponse = channelRepository.search(searchParameters); + Assertions.assertEquals(new SearchResult(List.of(createdChannels.get(2)), 1), foundChannelsResponse); + + } catch (ResponseStatusException e) { + Assertions.fail(e); + } + } + /** * find channels using case insensitive names searches */ diff --git a/src/test/java/org/phoebus/channelfinder/MetricsServiceIT.java b/src/test/java/org/phoebus/channelfinder/MetricsServiceIT.java index af8d48aa..b28e4f30 100644 --- a/src/test/java/org/phoebus/channelfinder/MetricsServiceIT.java +++ b/src/test/java/org/phoebus/channelfinder/MetricsServiceIT.java @@ -12,6 +12,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.core.parameters.P; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.TestPropertySource; import org.springframework.test.web.servlet.MockMvc; @@ -19,39 +20,32 @@ import org.springframework.util.MultiValueMap; import java.io.IOException; -import java.util.Arrays; import java.util.List; -import java.util.stream.Collectors; +import java.util.stream.IntStream; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import static org.testcontainers.shaded.org.awaitility.Awaitility.await; @TestInstance(TestInstance.Lifecycle.PER_CLASS) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK, classes = Application.class) @AutoConfigureMockMvc @ActiveProfiles("metrics") @TestPropertySource( - locations = "classpath:application_test.properties", - properties = { - "metrics.tags=testTag0, testTag1", - "metrics.properties={{'testProperty0', 'testProperty0Value'}, {'testProperty1', 'testProperty1Value'}}" - }) + locations = "classpath:application_test.properties", + properties = { + "metrics.tags=testTag0, testTag1", + "metrics.properties=testProperty0: value0, value1; testProperty1: value0, !*" + }) class MetricsServiceIT { public static final String METRICS_ENDPOINT = "/actuator/metrics/"; - public static final String METRICS_TAG_LABEL = "tag"; - public static final String PROPERTY_0_LABEL = "testProperty0:testProperty0Value"; - public static final String PROPERTY_1_LABEL = "testProperty1:testProperty1Value"; - public static final String TAG_0_LABEL = "tag:testTag0"; - public static final String TAG_1_LABEL = "tag:testTag1"; - private final List testTags = - Arrays.asList(new Tag("testTag0", "testTagOwner0"), new Tag("testTag1", "testTagOwner1")); - private final List testProperties = Arrays.asList( - new Property("testProperty0", "testPropertyOwner0"), - new Property("testProperty1", "testPropertyOwner1"), - new Property("testProperty2", "testPropertyOwner2")); + public static final String PROPERTY_NAME = "testProperty"; + public static final String OWNER = "testOwner"; + public static final String TAG_NAME = "testTag"; + public static final String METRICS_PARAM_KEY = "tag"; + public static final String PROPERTY_VALUE = "value"; + @Autowired ChannelRepository channelRepository; @@ -92,81 +86,115 @@ public void cleanup() { tagRepository.findAll().forEach(t -> tagRepository.deleteById(t.getName())); propertyRepository.findAll().forEach(p -> propertyRepository.deleteById(p.getName())); } + + private void getAndExpectMetric(String paramValue, String endpoint, int expectedValue) throws Exception { + mockMvc.perform(get(METRICS_ENDPOINT + endpoint) + .param(METRICS_PARAM_KEY, paramValue)) + .andExpect(jsonPath("$.measurements[0].value").value(expectedValue)); + } + + private void getAndExpectMetricParent(String endpoint, int expectedValue) throws Exception { + mockMvc.perform(get(METRICS_ENDPOINT + endpoint)) + .andExpect(jsonPath("$.measurements[0].value").value(expectedValue)); + } + @Test void testGaugeMetrics() throws Exception { mockMvc.perform(get(METRICS_ENDPOINT)).andExpect(status().is(200)); - mockMvc.perform(get(METRICS_ENDPOINT + MetricsService.CF_TOTAL_CHANNEL_COUNT)) - .andExpect(jsonPath("$.measurements[0].value").value(0)); - mockMvc.perform(get(METRICS_ENDPOINT + MetricsService.CF_PROPERTY_COUNT)) - .andExpect(jsonPath("$.measurements[0].value").value(0)); - mockMvc.perform(get(METRICS_ENDPOINT + MetricsService.CF_TAG_COUNT)) - .andExpect(jsonPath("$.measurements[0].value").value(0)); + getAndExpectMetricParent(MetricsService.CF_TOTAL_CHANNEL_COUNT, 0); + getAndExpectMetricParent(MetricsService.CF_PROPERTY_COUNT, 0); + getAndExpectMetricParent(MetricsService.CF_TAG_COUNT, 0); Channel testChannel = new Channel("testChannel", "testOwner"); channelRepository.save(testChannel); - propertyRepository.saveAll(testProperties); - tagRepository.saveAll(testTags); + propertyRepository.saveAll(propertyList(3)); + tagRepository.saveAll(tagList(2)); + + getAndExpectMetricParent(MetricsService.CF_TOTAL_CHANNEL_COUNT, 1); + getAndExpectMetricParent(MetricsService.CF_PROPERTY_COUNT, 3); + getAndExpectMetricParent(MetricsService.CF_TAG_COUNT, 2); + } - mockMvc.perform(get(METRICS_ENDPOINT + MetricsService.CF_TOTAL_CHANNEL_COUNT)) - .andExpect(jsonPath("$.measurements[0].value").value(1)); - mockMvc.perform(get(METRICS_ENDPOINT + MetricsService.CF_PROPERTY_COUNT)) - .andExpect(jsonPath("$.measurements[0].value").value(3)); - mockMvc.perform(get(METRICS_ENDPOINT + MetricsService.CF_TAG_COUNT)) - .andExpect(jsonPath("$.measurements[0].value").value(2)); + private List tagList(int count) { + return IntStream.range(0, count).mapToObj(i -> new Tag(TAG_NAME + i, OWNER)).toList(); + } + + private String tagParamValue(Tag tag) { + return String.format("tag:%s", tag.getName()); + } + + + private void getAndExpectTagMetric(Tag tag, int expectedValue) throws Exception { + getAndExpectMetric(tagParamValue(tag), MetricsService.CF_TAG_ON_CHANNELS_COUNT, expectedValue); } @Test void testTagMultiGaugeMetrics() throws Exception { - mockMvc.perform(get(METRICS_ENDPOINT + MetricsService.CF_CHANNEL_COUNT) - .param(METRICS_TAG_LABEL, TAG_0_LABEL)) - .andExpect(jsonPath("$.measurements[0].value").value(0)); - mockMvc.perform(get(METRICS_ENDPOINT + MetricsService.CF_CHANNEL_COUNT) - .param(METRICS_TAG_LABEL, TAG_1_LABEL)) - .andExpect(jsonPath("$.measurements[0].value").value(0)); + List testTags = tagList(3); + getAndExpectMetricParent(MetricsService.CF_TAG_ON_CHANNELS_COUNT, 0); + getAndExpectTagMetric(testTags.get(0), 0); + getAndExpectTagMetric(testTags.get(1), 0); tagRepository.saveAll(testTags); Channel testChannel = new Channel("testChannelTag", "testOwner", List.of(), testTags); channelRepository.save(testChannel); + Channel testChannel1 = new Channel("testChannelTag1", "testOwner", List.of(), List.of(testTags.get(0))); + channelRepository.save(testChannel1); + + getAndExpectTagMetric(testTags.get(0), 2); + getAndExpectTagMetric(testTags.get(1), 1); + getAndExpectMetricParent(MetricsService.CF_TAG_ON_CHANNELS_COUNT, 3); + } + + private List propertyList(int count) { + return IntStream.range(0, count).mapToObj(i -> new Property(PROPERTY_NAME + i, OWNER)).toList(); + } - await().untilAsserted(() -> { - mockMvc.perform(get(METRICS_ENDPOINT + MetricsService.CF_CHANNEL_COUNT) - .param(METRICS_TAG_LABEL, TAG_0_LABEL)) - .andExpect(jsonPath("$.measurements[0].value").value(1)); - mockMvc.perform(get(METRICS_ENDPOINT + MetricsService.CF_CHANNEL_COUNT) - .param(METRICS_TAG_LABEL, TAG_1_LABEL)) - .andExpect(jsonPath("$.measurements[0].value").value(1)); - }); + private String propertyParamValue(Property property) { + return String.format("%s:%s", property.getName(), property.getValue()); + } + + private void getAndExpectPropertyMetric(Property property, int expectedValue) throws Exception { + getAndExpectMetric(propertyParamValue(property), MetricsService.CF_CHANNEL_COUNT, expectedValue); } @Test void testPropertyMultiGaugeMetrics() throws Exception { - mockMvc.perform(get(METRICS_ENDPOINT + MetricsService.CF_CHANNEL_COUNT) - .param(METRICS_TAG_LABEL, PROPERTY_0_LABEL)) - .andExpect(jsonPath("$.measurements[0].value").value(0)); - mockMvc.perform(get(METRICS_ENDPOINT + MetricsService.CF_CHANNEL_COUNT) - .param(METRICS_TAG_LABEL, PROPERTY_1_LABEL)) - .andExpect(jsonPath("$.measurements[0].value").value(0)); + List testProperties = propertyList(2); + Channel testChannel = new Channel( + "testChannelProp", + "testOwner", + List.of( + new Property(testProperties.get(0).getName(), OWNER, PROPERTY_VALUE + 0), + new Property(testProperties.get(1).getName(), OWNER, PROPERTY_VALUE + 0)), + List.of()); + + Channel testChannel1 = new Channel( + "testChannelProp1", + "testOwner", + List.of(new Property(testProperties.get(0).getName(), OWNER, PROPERTY_VALUE + 1)), + List.of()); + + getAndExpectMetricParent(MetricsService.CF_CHANNEL_COUNT, 0); + getAndExpectPropertyMetric(testChannel.getProperties().get(0), 0); + getAndExpectPropertyMetric(testChannel1.getProperties().get(0), 0); + + getAndExpectPropertyMetric(testChannel.getProperties().get(1), 0); + getAndExpectPropertyMetric(new Property(testProperties.get(1).getName(), OWNER, MetricsService.NOT_SET), 0); propertyRepository.saveAll(testProperties); - Channel testChannel = new Channel( - "testChannelProp", - "testOwner", - testProperties.stream() - .map(p -> new Property(p.getName(), p.getOwner(), p.getName() + "Value")) - .collect(Collectors.toList()), - testTags); channelRepository.save(testChannel); + channelRepository.save(testChannel1); + + getAndExpectMetricParent(MetricsService.CF_CHANNEL_COUNT, 2); + getAndExpectPropertyMetric(testChannel.getProperties().get(0), 1); + getAndExpectPropertyMetric(testChannel1.getProperties().get(0), 1); + + getAndExpectPropertyMetric(testChannel.getProperties().get(1), 1); + getAndExpectPropertyMetric(new Property(testProperties.get(1).getName(), OWNER, MetricsService.NOT_SET), 1); - await().untilAsserted(() -> { - mockMvc.perform(get(METRICS_ENDPOINT + MetricsService.CF_CHANNEL_COUNT) - .param(METRICS_TAG_LABEL, PROPERTY_0_LABEL)) - .andExpect(jsonPath("$.measurements[0].value").value(1)); - mockMvc.perform(get(METRICS_ENDPOINT + MetricsService.CF_CHANNEL_COUNT) - .param(METRICS_TAG_LABEL, PROPERTY_1_LABEL)) - .andExpect(jsonPath("$.measurements[0].value").value(1)); - }); } } diff --git a/src/test/java/org/phoebus/channelfinder/MetricsServiceTest.java b/src/test/java/org/phoebus/channelfinder/MetricsServiceTest.java new file mode 100644 index 00000000..23a2db41 --- /dev/null +++ b/src/test/java/org/phoebus/channelfinder/MetricsServiceTest.java @@ -0,0 +1,91 @@ +package org.phoebus.channelfinder; + +import org.junit.jupiter.api.Test; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class MetricsServiceTest { + + + @Test + void testGenerateAllMultiValueMaps_example1() { + Map> properties = Map.of( + "a", List.of("b", "c"), + "d", List.of("e", "!*") + ); + List> allMaps = MetricsService.generateAllMultiValueMaps(properties); + + assertEquals(4, allMaps.size()); + + assertTrue(allMaps.contains(createMap("a", "b", "d", "e"))); + assertTrue(allMaps.contains(createMap("a", "c", "d", "e"))); + assertTrue(allMaps.contains(createMap("a", "b", "d!", "*"))); + assertTrue(allMaps.contains(createMap("a", "c", "d!", "*"))); + } + + @Test + void testGenerateAllMultiValueMaps_example2() { + Map> properties = Map.of( + "x", List.of("y", "z"), + "p", List.of("q"), + "r", List.of("s", "t") + ); + List> allMaps = MetricsService.generateAllMultiValueMaps(properties); + + assertEquals(2 * 2, allMaps.size()); // 2 options (value or null) for each of 2 keys + + // Just a basic check, exhaustive check would be large + boolean foundExpectedCombination = false; + for (MultiValueMap map : allMaps) { + if (map.get("x").contains("y") && map.get("p").contains("q") && map.get("r").contains("s")) { + foundExpectedCombination = true; + break; + } + } + assertTrue(foundExpectedCombination); + } + + @Test + void testGenerateAllMultiValueMaps_emptyList() { + Map> properties = Map.of( + "m", List.of() + ); + List> allMaps = MetricsService.generateAllMultiValueMaps(properties); + + assertEquals(0, allMaps.size()); // One with m=null, one with m implicitly null (not present) + } + + @Test + void testGenerateAllMultiValueMaps_emptyMap() { + Map> properties = Map.of(); + List> allMaps = MetricsService.generateAllMultiValueMaps(properties); + + assertEquals(1, allMaps.size()); + } + + // Helper method to create a MultiValueMap for easier assertion + private MultiValueMap createMap(String key1, String value1, String key2, String value2) { + LinkedMultiValueMap map = new LinkedMultiValueMap<>(); + if (key1 != null) { + map.add(key1, value1); + } + if (key2 != null) { + map.add(key2, value2); + } + return map; + } + + private MultiValueMap createMap(String key, String value) { + LinkedMultiValueMap map = new LinkedMultiValueMap<>(); + if (key != null) { + map.add(key, value); + } + return map; + } +} diff --git a/src/test/resources/application_test.properties b/src/test/resources/application_test.properties index 24ee820d..38409a8c 100644 --- a/src/test/resources/application_test.properties +++ b/src/test/resources/application_test.properties @@ -107,4 +107,4 @@ aa.auto_pause=pvStatus,archive #actuator management.endpoints.web.exposure.include=prometheus, metrics, health, info metrics.tags=group4_10 -metrics.properties={{'group4', '10'}, {'group5', '10'}} \ No newline at end of file +metrics.properties=group4: 10; group5: 10 \ No newline at end of file