diff --git a/.mvn/extensions.xml b/.mvn/extensions.xml index b40caf03..76d53e48 100644 --- a/.mvn/extensions.xml +++ b/.mvn/extensions.xml @@ -1,7 +1,7 @@ - io.quarkus.platform + io.quarkus quarkus-maven-plugin ${quarkus.platform.version} diff --git a/.mvn/maven.config b/.mvn/maven.config index e0610cdc..c111962c 100644 --- a/.mvn/maven.config +++ b/.mvn/maven.config @@ -1 +1 @@ --Dquarkus.platform.version=3.35.1 +-Dquarkus.platform.version=3.38.0 diff --git a/pom.xml b/pom.xml index 2303db1f..67ca5198 100644 --- a/pom.xml +++ b/pom.xml @@ -53,8 +53,6 @@ 5 true - 3.5 - 3.41.3 0.1.0 @@ -82,7 +80,7 @@ quay.io/quarkus/ubi-quarkus-graalvmce-builder-image:jdk-25 true quarkus-bom - io.quarkus.platform + io.quarkus true @@ -142,6 +140,10 @@ quarkus-quinoa ${quarkiverse.quinoa} + + io.quarkus + quarkus-aesh + io.quarkus quarkus-agroal @@ -182,10 +184,6 @@ io.quarkus quarkus-oidc - - io.quarkus - quarkus-picocli - io.quarkus quarkus-qute @@ -206,11 +204,6 @@ io.quarkus quarkus-smallrye-openapi - - org.aesh - aesh - ${lib.aesh} - org.apache.commons commons-math3 @@ -258,6 +251,11 @@ quarkus-junit test + + io.quarkus + quarkus-test-aesh + test + io.rest-assured rest-assured @@ -342,6 +340,10 @@ -parameters + + org.aesh + aesh-processor + org.openjdk.jmh jmh-generator-annprocess diff --git a/src/main/java/io/hyperfoil/tools/h5m/api/svc/NodeServiceInterface.java b/src/main/java/io/hyperfoil/tools/h5m/api/svc/NodeServiceInterface.java index 1c353f8f..82f34193 100644 --- a/src/main/java/io/hyperfoil/tools/h5m/api/svc/NodeServiceInterface.java +++ b/src/main/java/io/hyperfoil/tools/h5m/api/svc/NodeServiceInterface.java @@ -2,6 +2,7 @@ import io.hyperfoil.tools.h5m.api.Node; import io.hyperfoil.tools.h5m.api.NodeType; +import io.hyperfoil.tools.h5m.entity.NodeEntity; import java.util.List; @@ -34,6 +35,15 @@ public interface NodeServiceInterface { */ Long createConfigured(String name, Long groupId, NodeType type, List sources, Object configuration); + /** + * Updates a node's name and/or operation. Auto-triggers selective + * recalculation when the operation changes. + * + * @param node The node entity with updated fields. + * @return The node ID. + */ + long update(NodeEntity node); + /** * Deletes a node by its ID. * diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/AddCmd.java b/src/main/java/io/hyperfoil/tools/h5m/cli/AddCmd.java deleted file mode 100644 index ec3e6e09..00000000 --- a/src/main/java/io/hyperfoil/tools/h5m/cli/AddCmd.java +++ /dev/null @@ -1,31 +0,0 @@ -package io.hyperfoil.tools.h5m.cli; - -import picocli.CommandLine; - -import java.util.concurrent.Callable; - -@CommandLine.Command( - name="add", - description = "add entity", - mixinStandardHelpOptions = true, - subcommands = { - AddFolder.class, - AddJq.class, - AddJs.class, - AddJsonata.class, - AddSplit.class, - AddRelativeDifference.class, - AddFixedThreshold.class, - AddStdDevAnomaly.class, - AddEDivisive.class, - AddNotification.class, - } -) -public class AddCmd implements Callable { - @Override - public Integer call() throws Exception { - CommandLine cmd = new CommandLine(this); - cmd.usage(System.out); - return 0; - } -} diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/AddEDivisive.java b/src/main/java/io/hyperfoil/tools/h5m/cli/AddEDivisive.java index 118ba628..6ece95f8 100644 --- a/src/main/java/io/hyperfoil/tools/h5m/cli/AddEDivisive.java +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/AddEDivisive.java @@ -8,52 +8,56 @@ import io.hyperfoil.tools.h5m.api.svc.NodeServiceInterface; import io.hyperfoil.tools.h5m.entity.node.EDivisive; import jakarta.inject.Inject; -import picocli.CommandLine; + +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; +import org.aesh.command.option.Argument; +import org.aesh.command.option.Option; +import org.aesh.command.option.OptionList; import java.util.ArrayList; -import java.util.Arrays; import java.util.List; -import java.util.concurrent.Callable; import java.util.stream.Collectors; -@CommandLine.Command(name = "edivisive", separator = " ", description = "add an e-divisive (Hunter) change detection node", mixinStandardHelpOptions = true) -public class AddEDivisive implements Callable { +@CommandDefinition(name = "edivisive", description = "add an e-divisive (Hunter) change detection node", generateHelp = true) +public class AddEDivisive implements Command { - @CommandLine.Option(names = {"to"}, description = "target group / test") + @Option(name = "to", acceptNameWithoutDashes = true, description = "target group / test") String groupName; - @CommandLine.Option(names = {"range"}, arity = "1", description = "node that produces the value to inspect") + @Option(name = "range", acceptNameWithoutDashes = true, description = "node that produces the value to inspect") String rangeName; - @CommandLine.Option(names = {"domain"}, arity = "1", required = true, description = "node used to sort the range values (required for e-divisive)") + @Option(name = "domain", acceptNameWithoutDashes = true, required = true, description = "node used to sort the range values (required for e-divisive)") String domainName; - @CommandLine.Option(names = {"windowLen"}, arity = "0..1", description = "sliding window size for the split phase (min 3)", + @Option(name = "windowLen", acceptNameWithoutDashes = true, description = "sliding window size for the split phase (min 3)", defaultValue = "" + EDivisive.DEFAULT_WINDOW_LEN) int windowLen; - @CommandLine.Option(names = {"maxPvalue"}, arity = "0..1", description = "significance threshold for change points", + @Option(name = "maxPvalue", acceptNameWithoutDashes = true, description = "significance threshold for change points", defaultValue = "" + EDivisive.DEFAULT_MAX_PVALUE) double maxPvalue; - @CommandLine.Option(names = {"minMagnitude"}, arity = "0..1", description = "minimum relative change magnitude to report (e.g., 0.1 = 10%)", + @Option(name = "minMagnitude", acceptNameWithoutDashes = true, description = "minimum relative change magnitude to report (e.g., 0.1 = 10%)", defaultValue = "" + EDivisive.DEFAULT_MIN_MAGNITUDE) double minMagnitude; - @CommandLine.Option(names = {"maxSeriesLength"}, arity = "0..1", description = "maximum number of recent data points to analyze", + @Option(name = "maxSeriesLength", acceptNameWithoutDashes = true, description = "maximum number of recent data points to analyze", defaultValue = "" + EDivisive.DEFAULT_MAX_SERIES_LENGTH) int maxSeriesLength; - @CommandLine.Option(names = {"fingerprint"}, description = "node names to use as fingerprint") + @OptionList(name = "fingerprint", description = "node names to use as fingerprint") List fingerprints; - @CommandLine.Option(names = {"--fingerprint-filter", "-ff"}, arity = "0..1", description = "jq filter expression for fingerprints") + @Option(name = "fingerprint-filter", acceptNameWithoutDashes = true, description = "jq filter expression for fingerprints") String fingerprintFilter; - @CommandLine.Option(names = {"by"}, description = "grouping node", arity = "0..1") - public String groupBy; + @Option(name = "by", acceptNameWithoutDashes = true, description = "grouping node") + String groupBy; - @CommandLine.Parameters(index = "0", arity = "1", description = "node name") + @Argument(description = "node name") String name; @Inject @@ -63,48 +67,48 @@ public class AddEDivisive implements Callable { NodeServiceInterface nodeService; @Override - public Integer call() throws Exception { + public CommandResult execute(H5mCommandInvocation invocation) throws InterruptedException { + if (groupName == null && invocation.hasFolderContext()) groupName = invocation.getFolderName(); if (name == null || name.isEmpty()) { - System.err.println("missing node name"); - return 1; + invocation.println("missing node name"); + return CommandResult.FAILURE; } if (groupName == null || groupName.isEmpty()) { - System.err.println("missing group name"); - return 1; + invocation.println("missing group name"); + return CommandResult.FAILURE; } NodeGroup foundGroup = nodeGroupService.byName(groupName); if (foundGroup == null) { - System.err.println("node group with name " + groupName + " does not exist"); - return 1; + invocation.println("node group with name " + groupName + " does not exist"); + return CommandResult.FAILURE; } List foundNodes = nodeService.findNodeByFqdn(name, foundGroup.id()); if (!foundNodes.isEmpty()) { - System.err.println(groupName + " already has " + name + " node(s)\n " + foundNodes.stream().map(Node::fqdn).collect(Collectors.joining("\n "))); + invocation.println(groupName + " already has " + name + " node(s)\n " + foundNodes.stream().map(Node::fqdn).collect(Collectors.joining("\n "))); } if (rangeName == null || rangeName.isEmpty()) { - System.err.println("Missing range"); - return 1; + invocation.println("Missing range"); + return CommandResult.FAILURE; } foundNodes = nodeService.findNodeByFqdn(rangeName, foundGroup.id()); if (foundNodes.isEmpty()) { - System.err.println("could not find matching range node by name " + rangeName); - return 1; + invocation.println("could not find matching range node by name " + rangeName); + return CommandResult.FAILURE; } else if (foundNodes.size() > 1) { - System.err.println("found more than one matching range node by name " + rangeName + "\n " + foundNodes.stream().map(Node::fqdn).collect(Collectors.joining("\n "))); - return 1; + invocation.println("found more than one matching range node by name " + rangeName + "\n " + foundNodes.stream().map(Node::fqdn).collect(Collectors.joining("\n "))); + return CommandResult.FAILURE; } Node rangeNode = foundNodes.getFirst(); - // domainName is required=true, so always present foundNodes = nodeService.findNodeByFqdn(domainName, foundGroup.id()); if (foundNodes.isEmpty()) { - System.err.println("could not find matching domain node by name " + domainName); - return 1; + invocation.println("could not find matching domain node by name " + domainName); + return CommandResult.FAILURE; } else if (foundNodes.size() > 1) { - System.err.println("found more than one matching domain node by name " + domainName + "\n " + foundNodes.stream().map(Node::fqdn).collect(Collectors.joining("\n "))); - return 1; + invocation.println("found more than one matching domain node by name " + domainName + "\n " + foundNodes.stream().map(Node::fqdn).collect(Collectors.joining("\n "))); + return CommandResult.FAILURE; } Node domainNode = foundNodes.getFirst(); @@ -112,11 +116,11 @@ public Integer call() throws Exception { if (groupBy != null && !groupBy.isEmpty()) { foundNodes = nodeService.findNodeByFqdn(groupBy, foundGroup.id()); if (foundNodes.isEmpty()) { - System.err.println("could not find matching group by node with name " + groupBy); - return 1; + invocation.println("could not find matching group by node with name " + groupBy); + return CommandResult.FAILURE; } else if (foundNodes.size() > 1) { - System.err.println("found more than one matching group by node for name " + groupBy + "\n " + foundNodes.stream().map(Node::fqdn).collect(Collectors.joining("\n "))); - return 1; + invocation.println("found more than one matching group by node for name " + groupBy + "\n " + foundNodes.stream().map(Node::fqdn).collect(Collectors.joining("\n "))); + return CommandResult.FAILURE; } groupByNode = foundNodes.getFirst(); } @@ -126,15 +130,14 @@ public Integer call() throws Exception { List fingerprintNodes = new ArrayList<>(); if (fingerprints != null && !fingerprints.isEmpty()) { - List fingerprintNames = fingerprints.stream().flatMap(fp -> Arrays.stream(fp.split(","))).map(String::trim).filter(v -> !v.isBlank()).toList(); - for (String fingerprintName : fingerprintNames) { + for (String fingerprintName : fingerprints) { foundNodes = nodeService.findNodeByFqdn(fingerprintName, foundGroup.id()); if (foundNodes.isEmpty()) { - System.err.println("could not find matching fingerprint node by name " + fingerprintName); - return 1; + invocation.println("could not find matching fingerprint node by name " + fingerprintName); + return CommandResult.FAILURE; } else if (foundNodes.size() > 1) { - System.err.println("found more than one matching fingerprint node by name " + fingerprintName + "\n " + foundNodes.stream().map(Node::fqdn).collect(Collectors.joining("\n "))); - return 1; + invocation.println("found more than one matching fingerprint node by name " + fingerprintName + "\n " + foundNodes.stream().map(Node::fqdn).collect(Collectors.joining("\n "))); + return CommandResult.FAILURE; } fingerprintNodes.add(foundNodes.getFirst().id()); } @@ -145,6 +148,6 @@ public Integer call() throws Exception { nodeService.createConfigured(name, foundGroup.id(), NodeType.EDIVISIVE, sources, new EDivisiveConfig(windowLen, maxPvalue, minMagnitude, maxSeriesLength, fingerprintFilter)); - return 0; + return CommandResult.SUCCESS; } } diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/AddFingerprint.java b/src/main/java/io/hyperfoil/tools/h5m/cli/AddFingerprint.java new file mode 100644 index 00000000..aa643fcc --- /dev/null +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/AddFingerprint.java @@ -0,0 +1,89 @@ +package io.hyperfoil.tools.h5m.cli; + +import io.hyperfoil.tools.h5m.api.Node; +import io.hyperfoil.tools.h5m.api.NodeGroup; +import io.hyperfoil.tools.h5m.api.NodeType; +import io.hyperfoil.tools.h5m.api.svc.NodeGroupServiceInterface; +import io.hyperfoil.tools.h5m.api.svc.NodeServiceInterface; +import jakarta.inject.Inject; + +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; +import org.aesh.command.option.Argument; +import org.aesh.command.option.Option; + +import java.util.ArrayList; +import java.util.List; + +@CommandDefinition(name = "fingerprint", description = "Add a fingerprint node that groups values by a unique identity", generateHelp = true) +public class AddFingerprint implements Command { + + @Option(name = "to", acceptNameWithoutDashes = true, description = "target group / test") + String groupName; + + @Argument(description = "source expression (e.g., \"{mem,cpu}:.\" or node names comma-separated)") + String sourceExpr; + + @Inject + NodeGroupServiceInterface nodeGroupService; + + @Inject + NodeServiceInterface nodeService; + + @Override + public CommandResult execute(H5mCommandInvocation invocation) throws InterruptedException { + if (groupName == null && invocation.hasFolderContext()) groupName = invocation.getFolderName(); + if (sourceExpr == null || sourceExpr.isEmpty()) { + invocation.println("missing source expression for fingerprint"); + return CommandResult.FAILURE; + } + if (groupName == null) { + invocation.println("missing group name (use --to)"); + return CommandResult.FAILURE; + } + NodeGroup foundGroup = nodeGroupService.byName(groupName); + if (foundGroup == null) { + invocation.println("could not find group " + groupName); + return CommandResult.FAILURE; + } + + // Parse source node names from expression like "{mem,cpu}:." or "({mem,cpu})=>..." + List sourceIds = new ArrayList<>(); + int start = sourceExpr.indexOf('{'); + int end = sourceExpr.indexOf('}'); + if (start >= 0 && end > start) { + String nodeNames = sourceExpr.substring(start + 1, end); + for (String nodeName : nodeNames.split(",")) { + nodeName = nodeName.trim(); + if (nodeName.isEmpty()) continue; + List found = nodeService.findNodeByFqdn(nodeName, foundGroup.id()); + if (found.isEmpty()) { + invocation.println("could not find node: " + nodeName); + return CommandResult.FAILURE; + } + sourceIds.add(found.getFirst().id()); + } + } else { + // Treat as comma-separated node names + for (String nodeName : sourceExpr.split(",")) { + nodeName = nodeName.trim(); + if (nodeName.isEmpty()) continue; + List found = nodeService.findNodeByFqdn(nodeName, foundGroup.id()); + if (found.isEmpty()) { + invocation.println("could not find node: " + nodeName); + return CommandResult.FAILURE; + } + sourceIds.add(found.getFirst().id()); + } + } + + if (sourceIds.isEmpty()) { + invocation.println("no source nodes found for fingerprint"); + return CommandResult.FAILURE; + } + + nodeService.createConfigured("_fp-fingerprint", foundGroup.id(), NodeType.FINGERPRINT, sourceIds, null); + return CommandResult.SUCCESS; + } +} diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/AddFixedThreshold.java b/src/main/java/io/hyperfoil/tools/h5m/cli/AddFixedThreshold.java index 4a410761..17973456 100644 --- a/src/main/java/io/hyperfoil/tools/h5m/cli/AddFixedThreshold.java +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/AddFixedThreshold.java @@ -7,44 +7,48 @@ import io.hyperfoil.tools.h5m.api.svc.NodeGroupServiceInterface; import io.hyperfoil.tools.h5m.api.svc.NodeServiceInterface; import jakarta.inject.Inject; -import picocli.CommandLine; + +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; +import org.aesh.command.option.Argument; +import org.aesh.command.option.Option; +import org.aesh.command.option.OptionList; import java.util.ArrayList; -import java.util.Arrays; import java.util.List; -import java.util.concurrent.Callable; import java.util.stream.Collectors; -@CommandLine.Command(name = "fixedthreshold", separator = " ", description = "add a fixed threshold node", mixinStandardHelpOptions = true) -public class AddFixedThreshold implements Callable { +@CommandDefinition(name = "fixedthreshold", description = "Add a fixed threshold change detection node that flags values exceeding a configured bound", generateHelp = true) +public class AddFixedThreshold implements Command { - @CommandLine.Option(names = {"to"}, description = "target group / test") String groupName; + @Option(name = "to", acceptNameWithoutDashes = true, description = "target group / test") String groupName; - @CommandLine.Option(names = {"range"}, arity = "1", description = "node that produces the value to inspect") + @Option(name = "range", acceptNameWithoutDashes = true, description = "node that produces the value to inspect") String rangeName; - @CommandLine.Option(names = {"by"}, description = "grouping node", arity = "0..1") + @Option(name = "by", acceptNameWithoutDashes = true, description = "grouping node") public String groupBy; - @CommandLine.Option(names = {"fingerprint"}, description = "node names to use as fingerprint") + @OptionList(name = "fingerprint", description = "node names to use as fingerprint") List fingerprints; - @CommandLine.Option(names = {"--fingerprint-filter", "-ff"}, arity = "0..1", description = "jq filter expression for fingerprints") + @Option(name = "fingerprint-filter", acceptNameWithoutDashes = true, shortName = 'f', description = "jq filter expression for fingerprints") String fingerprintFilter; - @CommandLine.Option(names = {"min"}, arity = "0..1", description = "minimum threshold value") + @Option(name = "min", acceptNameWithoutDashes = true, description = "minimum threshold value") Double min; - @CommandLine.Option(names = {"max"}, arity = "0..1", description = "maximum threshold value") + @Option(name = "max", acceptNameWithoutDashes = true, description = "maximum threshold value") Double max; - @CommandLine.Option(names = {"min-inclusive"}, arity = "0..1", description = "whether min boundary value is within range", defaultValue = "true") + @Option(name = "min-inclusive", acceptNameWithoutDashes = true, description = "whether min boundary value is within range", defaultValue = {"true"}) boolean minInclusive; - @CommandLine.Option(names = {"max-inclusive"}, arity = "0..1", description = "whether max boundary value is within range", defaultValue = "true") + @Option(name = "max-inclusive", acceptNameWithoutDashes = true, description = "whether max boundary value is within range", defaultValue = {"true"}) boolean maxInclusive; - @CommandLine.Parameters(index = "0", arity = "1", description = "node name") String name; + @Argument(description = "node name") String name; @Inject NodeGroupServiceInterface nodeGroupService; @@ -53,37 +57,38 @@ public class AddFixedThreshold implements Callable { NodeServiceInterface nodeService; @Override - public Integer call() throws Exception { + public CommandResult execute(H5mCommandInvocation invocation) throws InterruptedException { + if (groupName == null && invocation.hasFolderContext()) groupName = invocation.getFolderName(); if (name == null || name.isEmpty()) { - System.err.println("missing node name"); - return 1; + invocation.println("missing node name"); + return CommandResult.FAILURE; } if (groupName == null || groupName.isEmpty()) { - System.err.println("missing group name"); - return 1; + invocation.println("missing group name"); + return CommandResult.FAILURE; } NodeGroup foundGroup = nodeGroupService.byName(groupName); if (foundGroup == null) { - System.err.println("node group with name " + groupName + " does not exist"); - return 1; + invocation.println("node group with name " + groupName + " does not exist"); + return CommandResult.FAILURE; } List foundNodes = nodeService.findNodeByFqdn(name, foundGroup.id()); if (!foundNodes.isEmpty()) { - System.err.println(groupName + " already has " + name + " node(s)\n " + foundNodes.stream().map(Node::fqdn).collect(Collectors.joining("\n "))); + invocation.println(groupName + " already has " + name + " node(s)\n " + foundNodes.stream().map(Node::fqdn).collect(Collectors.joining("\n "))); } if (rangeName == null || rangeName.isEmpty()) { - System.err.println("Missing range"); - return 1; + invocation.println("Missing range"); + return CommandResult.FAILURE; } foundNodes = nodeService.findNodeByFqdn(rangeName, foundGroup.id()); if (foundNodes.isEmpty()) { - System.err.println("could not find matching range node by name " + rangeName); - return 1; + invocation.println("could not find matching range node by name " + rangeName); + return CommandResult.FAILURE; } else if (foundNodes.size() > 1) { - System.err.println("found more than one matching range node by name " + rangeName + "\n " + foundNodes.stream().map(Node::fqdn).collect(Collectors.joining("\n "))); - return 1; + invocation.println("found more than one matching range node by name " + rangeName + "\n " + foundNodes.stream().map(Node::fqdn).collect(Collectors.joining("\n "))); + return CommandResult.FAILURE; } Node rangeNode = foundNodes.getFirst(); @@ -91,11 +96,11 @@ public Integer call() throws Exception { if (groupBy != null && !groupBy.isEmpty()) { foundNodes = nodeService.findNodeByFqdn(groupBy, foundGroup.id()); if (foundNodes.isEmpty()) { - System.err.println("could not find matching group by node with name" + groupBy); - return 1; + invocation.println("could not find matching group by node with name " + groupBy); + return CommandResult.FAILURE; } else if (foundNodes.size() > 1) { - System.err.println("found more than one matching group by node for name " + groupBy + "\n " + foundNodes.stream().map(Node::fqdn).collect(Collectors.joining("\n "))); - return 1; + invocation.println("found more than one matching group by node for name " + groupBy + "\n " + foundNodes.stream().map(Node::fqdn).collect(Collectors.joining("\n "))); + return CommandResult.FAILURE; } groupByNode = foundNodes.getFirst(); } @@ -105,23 +110,27 @@ public Integer call() throws Exception { List fingerprintNodes = new ArrayList<>(); if (fingerprints != null && !fingerprints.isEmpty()) { - List fingerprintNames = fingerprints.stream().flatMap(fp -> Arrays.stream(fp.split(","))).map(String::trim).filter(v -> !v.isBlank()).toList(); - for (String fingerprintName : fingerprintNames) { + for (String fingerprintName : fingerprints) { foundNodes = nodeService.findNodeByFqdn(fingerprintName, foundGroup.id()); if (foundNodes.isEmpty()) { - System.err.println("could not find matching fingerprint node by name " + fingerprintName); - return 1; + invocation.println("could not find matching fingerprint node by name " + fingerprintName); + return CommandResult.FAILURE; } else if (foundNodes.size() > 1) { - System.err.println("found more than one matching fingerprint node by name " + fingerprintName + "\n " + foundNodes.stream().map(Node::fqdn).collect(Collectors.joining("\n "))); - return 1; + invocation.println("found more than one matching fingerprint node by name " + fingerprintName + "\n " + foundNodes.stream().map(Node::fqdn).collect(Collectors.joining("\n "))); + return CommandResult.FAILURE; } fingerprintNodes.add(foundNodes.getFirst().id()); } } - Long fingerprintId = nodeService.createConfigured("_fp-" + name, foundGroup.id(), NodeType.FINGERPRINT, fingerprintNodes, null); - nodeService.createConfigured(name, foundGroup.id(), NodeType.FIXED_THRESHOLD, List.of(fingerprintId, groupByNode.id(), rangeNode.id()), new FixedThresholdConfig(min, max, minInclusive, maxInclusive, fingerprintFilter)); + try { + Long fingerprintId = nodeService.createConfigured("_fp-" + name, foundGroup.id(), NodeType.FINGERPRINT, fingerprintNodes, null); + nodeService.createConfigured(name, foundGroup.id(), NodeType.FIXED_THRESHOLD, List.of(fingerprintId, groupByNode.id(), rangeNode.id()), new FixedThresholdConfig(min, max, minInclusive, maxInclusive, fingerprintFilter)); + } catch (Exception e) { + invocation.println("Error creating node: " + e.getMessage()); + return CommandResult.FAILURE; + } - return 0; + return CommandResult.SUCCESS; } } diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/AddFolder.java b/src/main/java/io/hyperfoil/tools/h5m/cli/AddFolder.java index 98518366..0090f501 100644 --- a/src/main/java/io/hyperfoil/tools/h5m/cli/AddFolder.java +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/AddFolder.java @@ -4,13 +4,16 @@ import io.hyperfoil.tools.h5m.api.svc.FolderServiceInterface; import io.hyperfoil.tools.h5m.api.svc.NodeGroupServiceInterface; import jakarta.inject.Inject; -import picocli.CommandLine; + +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; +import org.aesh.command.option.Argument; import java.util.Scanner; -import java.util.concurrent.Callable; -@CommandLine.Command(name="folder", description = "add a folder", mixinStandardHelpOptions = true) -public class AddFolder implements Callable { +@CommandDefinition(name="add", description = "Create a new folder for organizing uploaded data and computation nodes", generateHelp = true) +public class AddFolder implements Command { @Inject FolderServiceInterface folderService; @@ -18,22 +21,22 @@ public class AddFolder implements Callable { @Inject NodeGroupServiceInterface nodeGroupService; - @CommandLine.Parameters(index="0",arity="0..1") + @Argument(description = "folder name") public String name; @Override - public Integer call() throws Exception { - if(name == null && H5m.consoleAttached()){ + public CommandResult execute(H5mCommandInvocation invocation) throws InterruptedException { + if(name == null){ Scanner sc = new Scanner(System.in); - System.out.printf("Enter name: "); + invocation.print("Enter name: "); name = sc.nextLine(); } NodeGroup existingGroup = nodeGroupService.byName(name); if(existingGroup != null){ - System.err.println(name+" conflicts with an existing node group"); - return 1; + invocation.println(name+" conflicts with an existing node group"); + return CommandResult.FAILURE; } folderService.create(name); - return 0; + return CommandResult.SUCCESS; } } diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/AddJq.java b/src/main/java/io/hyperfoil/tools/h5m/cli/AddJq.java index 38278d66..4479132e 100644 --- a/src/main/java/io/hyperfoil/tools/h5m/cli/AddJq.java +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/AddJq.java @@ -5,19 +5,24 @@ import io.hyperfoil.tools.h5m.api.svc.NodeGroupServiceInterface; import io.hyperfoil.tools.h5m.api.svc.NodeServiceInterface; import jakarta.inject.Inject; -import picocli.CommandLine; + +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; +import org.aesh.command.option.Arguments; +import org.aesh.command.option.Option; + import java.io.BufferedReader; import java.io.InputStreamReader; +import java.util.List; import java.util.Scanner; -import java.util.concurrent.Callable; -@CommandLine.Command(name="jq", separator = " ", description = "add jq node", mixinStandardHelpOptions = true) -public class AddJq implements Callable { +@CommandDefinition(name="jq", description = "Add a JQ transformation node that applies a jq filter expression to input data", generateHelp = true) +public class AddJq implements Command { - @CommandLine.Option(names = {"to"},description = "target group / test" ) String groupName; - @CommandLine.Parameters(index="0",arity="0..1",description = "node name") String name; - @CommandLine.Parameters(index="1",arity="0..1",description = "jq filter") String jq; + @Option(name = "to", acceptNameWithoutDashes = true, description = "target group / test") String groupName; + @Arguments(description = "name and jq filter") List args; @Inject NodeGroupServiceInterface nodeGroupService; @@ -26,48 +31,56 @@ public class AddJq implements Callable { NodeServiceInterface nodeService; @Override - public Integer call() throws Exception { - Scanner sc = new Scanner(System.in); - if(name == null && H5m.consoleAttached()){ - System.out.printf("Enter name: "); - name = sc.nextLine(); - } - NodeGroup foundGroup; - do{ - if(groupName == null && H5m.consoleAttached()){ - System.out.printf("Enter target group / folder name: "); - groupName = sc.nextLine(); + public CommandResult execute(H5mCommandInvocation invocation) throws InterruptedException { + try { + if (groupName == null && invocation.hasFolderContext()) groupName = invocation.getFolderName(); + String name = (args != null && args.size() >= 1) ? args.get(0) : null; + String jq = (args != null && args.size() >= 2) ? args.get(1) : null; + Scanner sc = new Scanner(System.in); + if(name == null){ + invocation.print("Enter name: "); + name = sc.nextLine(); } - foundGroup = nodeGroupService.byName(groupName); - if(foundGroup == null){ - System.err.println("could not find "+groupName); - groupName = null; - } - }while(groupName == null && H5m.consoleAttached()); + NodeGroup foundGroup; + do{ + if(groupName == null){ + invocation.print("Enter target group / folder name: "); + groupName = sc.nextLine(); + } + foundGroup = nodeGroupService.byName(groupName); + if(foundGroup == null){ + invocation.println("could not find "+groupName); + groupName = null; + } + }while(groupName == null); - if("-".equals(jq)){ - StringBuilder sb = new StringBuilder(); - try (BufferedReader reader = new BufferedReader(new InputStreamReader(System.in))) { - String line; - while ((line = reader.readLine()) != null) { - sb.append(line); - sb.append(System.lineSeparator()); + if("-".equals(jq)){ + StringBuilder sb = new StringBuilder(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(System.in))) { + String line; + while ((line = reader.readLine()) != null) { + sb.append(line); + sb.append(System.lineSeparator()); + } + } + if(sb.length()>0){ + jq = sb.toString().trim(); + }else{ + invocation.println("unable to read function from input"); + return CommandResult.FAILURE; } } - if(sb.length()>0){ - jq = sb.toString().trim(); - }else{ - System.err.println("unable to read function from input"); - return 1; + if(jq == null){ + invocation.print("Enter jq filter: "); + jq = sc.nextLine(); } - } - if(jq == null && H5m.consoleAttached()){ - System.out.printf("Enter jq filter: "); - jq = sc.nextLine(); - } - nodeService.create(name, foundGroup.id(), NodeType.JQ, jq); + nodeService.create(name, foundGroup.id(), NodeType.JQ, jq); - return 0; + return CommandResult.SUCCESS; + } catch (Exception e) { + invocation.println("Error: " + e.getMessage()); + return CommandResult.FAILURE; + } } } diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/AddJs.java b/src/main/java/io/hyperfoil/tools/h5m/cli/AddJs.java index 5b51c7e0..6616e5be 100644 --- a/src/main/java/io/hyperfoil/tools/h5m/cli/AddJs.java +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/AddJs.java @@ -1,20 +1,24 @@ package io.hyperfoil.tools.h5m.cli; -import io.hyperfoil.tools.h5m.api.Node; import io.hyperfoil.tools.h5m.api.NodeGroup; import io.hyperfoil.tools.h5m.api.NodeType; import io.hyperfoil.tools.h5m.api.svc.NodeGroupServiceInterface; import io.hyperfoil.tools.h5m.api.svc.NodeServiceInterface; -import io.hyperfoil.tools.h5m.entity.node.JsNode; import jakarta.inject.Inject; -import picocli.CommandLine; + +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; +import org.aesh.command.option.Arguments; +import org.aesh.command.option.Option; + import java.io.BufferedReader; import java.io.InputStreamReader; -import java.util.concurrent.Callable; +import java.util.List; -@CommandLine.Command(name="js", separator = " ", description = "add javascript node", mixinStandardHelpOptions = true) -public class AddJs implements Callable { +@CommandDefinition(name="js", description = "Add a JavaScript transformation node that applies a JS function to input data", generateHelp = true) +public class AddJs implements Command { @Inject NodeGroupServiceInterface nodeGroupService; @@ -22,47 +26,53 @@ public class AddJs implements Callable { @Inject NodeServiceInterface nodeService; - @CommandLine.Option(names = {"to"},description = "target group / test" ) String groupName; + @Option(name = "to", acceptNameWithoutDashes = true, description = "target group / test") String groupName; - @CommandLine.Parameters(index="0",arity="1",description = "node name") String name; - @CommandLine.Parameters(index="1",arity="1",description = "javascript function") String function; + @Arguments(description = "name and javascript function") List args; @Override - public Integer call() throws Exception { - - if("-".equals(function)){ - StringBuilder sb = new StringBuilder(); - try (BufferedReader reader = new BufferedReader(new InputStreamReader(System.in))) { - String line; - while ((line = reader.readLine()) != null) { - sb.append(line); - sb.append(System.lineSeparator()); + public CommandResult execute(H5mCommandInvocation invocation) throws InterruptedException { + try { + if (groupName == null && invocation.hasFolderContext()) groupName = invocation.getFolderName(); + String name = (args != null && args.size() >= 1) ? args.get(0) : null; + String function = (args != null && args.size() >= 2) ? args.get(1) : null; + if("-".equals(function)){ + StringBuilder sb = new StringBuilder(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(System.in))) { + String line; + while ((line = reader.readLine()) != null) { + sb.append(line); + sb.append(System.lineSeparator()); + } + } + if(sb.length()>0){ + function = sb.toString().trim(); + }else{ + invocation.println("unable to read function from input"); + return CommandResult.FAILURE; } } - if(sb.length()>0){ - function = sb.toString().trim(); - }else{ - System.err.println("unable to read function from input"); - return 1; + if(function == null || "-".equals(function)){ + invocation.println("unable to read function from input "+function); + return CommandResult.FAILURE; + } + if(groupName == null){ + invocation.println("missing group name"); + return CommandResult.FAILURE; } - } - if(function == null || "-".equals(function)){ - System.err.println("unable to read function from input "+function); - return 1; - } - if(groupName == null){ - System.err.println("missing group name"); - return 1; - } - NodeGroup foundGroup = nodeGroupService.byName(groupName); - if(foundGroup == null){ - System.err.println("unable to find group: "+groupName); - return 1; - } + NodeGroup foundGroup = nodeGroupService.byName(groupName); + if(foundGroup == null){ + invocation.println("unable to find group: "+groupName); + return CommandResult.FAILURE; + } - nodeService.create(name, foundGroup.id(), NodeType.JS, function); + nodeService.create(name, foundGroup.id(), NodeType.JS, function); - return 0; + return CommandResult.SUCCESS; + } catch (Exception e) { + invocation.println("Error: " + e.getMessage()); + return CommandResult.FAILURE; + } } } diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/AddJsonata.java b/src/main/java/io/hyperfoil/tools/h5m/cli/AddJsonata.java index 15c0d510..9f1a4590 100644 --- a/src/main/java/io/hyperfoil/tools/h5m/cli/AddJsonata.java +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/AddJsonata.java @@ -5,19 +5,23 @@ import io.hyperfoil.tools.h5m.api.svc.NodeGroupServiceInterface; import io.hyperfoil.tools.h5m.api.svc.NodeServiceInterface; import jakarta.inject.Inject; -import picocli.CommandLine; + +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; +import org.aesh.command.option.Arguments; +import org.aesh.command.option.Option; + import java.io.BufferedReader; -import java.io.IOException; import java.io.InputStreamReader; -import java.util.concurrent.Callable; +import java.util.List; -@CommandLine.Command(name="jsonata", separator = " ", description = "add jsonata node", mixinStandardHelpOptions = true) -public class AddJsonata implements Callable { +@CommandDefinition(name="jsonata", description = "Add a JSONata transformation node that applies a JSONata expression to input data", generateHelp = true) +public class AddJsonata implements Command { - @CommandLine.Option(names = {"to"},description = "target group / test" ) String groupName; - @CommandLine.Parameters(index="0",arity="1",description = "node name") String name; - @CommandLine.Parameters(index="1",arity="1",description = "jq filter") String jsonata; + @Option(name = "to", acceptNameWithoutDashes = true, description = "target group / test") String groupName; + @Arguments(description = "name and jsonata expression") List args; @Inject NodeGroupServiceInterface nodeGroupService; @@ -26,51 +30,55 @@ public class AddJsonata implements Callable { NodeServiceInterface nodeService; @Override - public Integer call() throws IOException { - - if(name == null || name.isBlank()){ - System.err.println("missing jsonata node name"); - return 1; - } - if(name.matches("\\d+")){ - System.err.println("nodes names cannot be numbers"); - return 1; - } - if("-".equals(jsonata)){ - StringBuilder sb = new StringBuilder(); - try (BufferedReader reader = new BufferedReader(new InputStreamReader(System.in))) { - String line; - while ((line = reader.readLine()) != null) { - sb.append(line); - sb.append(System.lineSeparator()); + public CommandResult execute(H5mCommandInvocation invocation) throws InterruptedException { + try { + if (groupName == null && invocation.hasFolderContext()) groupName = invocation.getFolderName(); + String name = (args != null && args.size() >= 1) ? args.get(0) : null; + String jsonata = (args != null && args.size() >= 2) ? args.get(1) : null; + if(name == null || name.isBlank()){ + invocation.println("missing jsonata node name"); + return CommandResult.FAILURE; + } + if(name.matches("\\d+")){ + invocation.println("nodes names cannot be numbers"); + return CommandResult.FAILURE; + } + if("-".equals(jsonata)){ + StringBuilder sb = new StringBuilder(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(System.in))) { + String line; + while ((line = reader.readLine()) != null) { + sb.append(line); + sb.append(System.lineSeparator()); + } + } + if(sb.length()>0){ + jsonata = sb.toString().trim(); + }else{ + invocation.println("unable to read function from input"); + return CommandResult.FAILURE; } } - if(sb.length()>0){ - jsonata = sb.toString().trim(); - }else{ - System.err.println("unable to read function from input"); - return 1; + if(jsonata == null || jsonata.isEmpty()){ + invocation.println("missing jsonata operation"); + return CommandResult.FAILURE; + } + if(groupName == null){ + invocation.println("missing group name"); + return CommandResult.FAILURE; + } + NodeGroup foundGroup = nodeGroupService.byName(groupName); + if(foundGroup == null){ + invocation.println("group not found"); + return CommandResult.FAILURE; } - } - if(jsonata == null || jsonata.isEmpty()){ - System.err.println("missing jsonata operation"); - return 1; - } - if(groupName == null){ - System.err.println("missing group name"); - return 1; - } - NodeGroup foundGroup = nodeGroupService.byName(groupName); - if(foundGroup == null){ - System.err.println("group not found"); - return 1; - } - nodeService.create(name, foundGroup.id(), NodeType.JSONATA, jsonata); + nodeService.create(name, foundGroup.id(), NodeType.JSONATA, jsonata); - return 0; + return CommandResult.SUCCESS; + } catch (Exception e) { + invocation.println("Error: " + e.getMessage()); + return CommandResult.FAILURE; + } } - - - } diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/AddNotification.java b/src/main/java/io/hyperfoil/tools/h5m/cli/AddNotification.java index 0833fa14..be35ff98 100644 --- a/src/main/java/io/hyperfoil/tools/h5m/cli/AddNotification.java +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/AddNotification.java @@ -7,29 +7,31 @@ import io.hyperfoil.tools.h5m.notification.NotificationMethod; import io.hyperfoil.tools.h5m.svc.NotificationService; import jakarta.inject.Inject; -import jakarta.transaction.Transactional; -import picocli.CommandLine; -import java.util.concurrent.Callable; +import io.quarkus.narayana.jta.QuarkusTransaction; -@CommandLine.Command(name = "notification", separator = " ", - description = "add a notification config to a folder", - mixinStandardHelpOptions = true) -public class AddNotification implements Callable { +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; +import org.aesh.command.option.Argument; +import org.aesh.command.option.Option; - @CommandLine.Parameters(index = "0", arity = "1", description = "notification method: ${COMPLETION-CANDIDATES}") - NotificationMethod method; +@CommandDefinition(name = "add", description = "Configure a notification (email, Slack, webhook, or GitHub issue) for change detection events in a folder", generateHelp = true) +public class AddNotification implements Command { - @CommandLine.Option(names = {"to"}, description = "target folder name", required = true) + @Argument(description = "notification method (WEBHOOK, EMAIL, SLACK, GITHUB_ISSUE)", required = true) + String method; + + @Option(name = "to", acceptNameWithoutDashes = true, description = "target folder name", completer = FolderCompleter.class) String folderName; - @CommandLine.Parameters(index = "1", arity = "1", description = "configuration data (URL, email, JSON, etc.)") + @Option(name = "data", acceptNameWithoutDashes = true, description = "configuration data (URL, email, JSON, etc.)", required = true) String data; - @CommandLine.Option(names = {"--secrets"}, description = "secret configuration (tokens, passwords)") + @Option(name = "secrets", acceptNameWithoutDashes = true, description = "secret configuration (tokens, passwords)") String secrets; - @CommandLine.Option(names = {"--template"}, description = "custom message template with placeholders: {folderName}, {nodeName}, {nodeType}, {changeCount}") + @Option(name = "template", acceptNameWithoutDashes = true, description = "custom message template with placeholders: {folderName}, {nodeName}, {nodeType}, {changeCount}") String template; @Inject @@ -39,26 +41,40 @@ public class AddNotification implements Callable { NotificationService notificationService; @Override - @Transactional - public Integer call() throws Exception { + public CommandResult execute(H5mCommandInvocation invocation) throws InterruptedException { + NotificationMethod notificationMethod; + try { + notificationMethod = NotificationMethod.valueOf(method.toUpperCase()); + } catch (IllegalArgumentException e) { + invocation.println("Invalid notification method: " + method + ". Use WEBHOOK, EMAIL, SLACK, or GITHUB_ISSUE"); + return CommandResult.FAILURE; + } + + if (folderName == null && invocation.hasFolderContext()) { + folderName = invocation.getFolderName(); + } + Folder folder = folderService.byName(folderName); if (folder == null) { - System.err.println("Folder not found: " + folderName); - return 1; + invocation.println("Folder not found: " + folderName); + return CommandResult.FAILURE; } try { - notificationService.validateConfig(method, data); + notificationService.validateConfig(notificationMethod, data); } catch (IllegalArgumentException e) { - System.err.println("Invalid notification config: " + e.getMessage()); - return 1; + invocation.println("Invalid notification config: " + e.getMessage()); + return CommandResult.FAILURE; } - FolderEntity folderEntity = FolderEntity.findById(folder.id()); - NotificationConfig config = new NotificationConfig(folderEntity, method, data, secrets); - config.template = template; - config.persist(); - System.out.println("Added " + method.label() + " notification to " + folderName + " (id=" + config.id + ")"); - return 0; + long configId = QuarkusTransaction.requiringNew().call(() -> { + FolderEntity folderEntity = FolderEntity.findById(folder.id()); + NotificationConfig config = new NotificationConfig(folderEntity, notificationMethod, data, secrets); + config.template = template; + config.persist(); + return config.id; + }); + invocation.println("Added " + notificationMethod.label() + " notification to " + folderName + " (id=" + configId + ")"); + return CommandResult.SUCCESS; } } diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/AddRelativeDifference.java b/src/main/java/io/hyperfoil/tools/h5m/cli/AddRelativeDifference.java index 4e7037fe..8c287675 100644 --- a/src/main/java/io/hyperfoil/tools/h5m/cli/AddRelativeDifference.java +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/AddRelativeDifference.java @@ -8,40 +8,44 @@ import io.hyperfoil.tools.h5m.api.svc.NodeServiceInterface; import io.hyperfoil.tools.h5m.entity.node.RelativeDifference; import jakarta.inject.Inject; -import picocli.CommandLine; + +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; +import org.aesh.command.option.Argument; +import org.aesh.command.option.Option; +import org.aesh.command.option.OptionList; import java.util.ArrayList; -import java.util.Arrays; import java.util.List; -import java.util.concurrent.Callable; import java.util.stream.Collectors; -@CommandLine.Command(name="relativedifference", separator = " ", description = "add a relative difference node", mixinStandardHelpOptions = true) -public class AddRelativeDifference implements Callable { +@CommandDefinition(name="relativedifference", description = "Add a relative difference change detection node that detects percentage changes between consecutive values", generateHelp = true) +public class AddRelativeDifference implements Command { - @CommandLine.Option(names = {"to"},description = "target group / test" ) String groupName; + @Option(name = "to", acceptNameWithoutDashes = true, description = "target group / test") String groupName; - @CommandLine.Option(names={"range"}, arity="1",description = "node that produces the value to inspect") + @Option(name = "range", acceptNameWithoutDashes = true, description = "node that produces the value to inspect") String rangeName; - @CommandLine.Option(names={"domain"}, arity="0..1", description = "node used to sort the rang values") + @Option(name = "domain", acceptNameWithoutDashes = true, description = "node used to sort the rang values") String domainName; - @CommandLine.Option(names={"threshold"}, arity="0..1", description = "Maximum difference between the aggregated value of last datapoints and the mean of preceding values.", defaultValue = ""+RelativeDifference.DEFAULT_THRESHOLD) + @Option(name = "threshold", acceptNameWithoutDashes = true, description = "Maximum difference between the aggregated value of last datapoints and the mean of preceding values.", defaultValue = {""+RelativeDifference.DEFAULT_THRESHOLD}) double threshold; - @CommandLine.Option(names={"window"}, arity="0..1",description = "Number of most recent datapoints used for aggregating the value for comparison.", defaultValue = ""+RelativeDifference.DEFAULT_WINDOW) + @Option(name = "window", acceptNameWithoutDashes = true, description = "Number of most recent datapoints used for aggregating the value for comparison.", defaultValue = {""+RelativeDifference.DEFAULT_WINDOW}) int window; - @CommandLine.Option(names={"minPrevious"}, arity = "0..1", description = "Number of datapoints preceding the aggregation window.", defaultValue = ""+RelativeDifference.DEFAULT_MIN_PREVIOUS) + @Option(name = "minPrevious", acceptNameWithoutDashes = true, description = "Number of datapoints preceding the aggregation window.", defaultValue = {""+RelativeDifference.DEFAULT_MIN_PREVIOUS}) int minPrevious; - @CommandLine.Option(names={"filter"}, arity = "0..1",description = "Function used to aggregate datapoints from the floating window.", defaultValue = RelativeDifference.DEFAULT_FILTER) + @Option(name = "filter", acceptNameWithoutDashes = true, description = "Function used to aggregate datapoints from the floating window.", defaultValue = {RelativeDifference.DEFAULT_FILTER}) String filter; - @CommandLine.Option(names={"fingerprint"}, description = "node names to use as fingerprint") + @OptionList(name = "fingerprint", description = "node names to use as fingerprint") List fingerprints; - @CommandLine.Option(names={"--fingerprint-filter", "-ff"}, arity = "0..1", description = "jq filter expression for fingerprints") + @Option(name = "fingerprint-filter", acceptNameWithoutDashes = true, shortName = 'f', description = "jq filter expression for fingerprints") String fingerprintFilter; - @CommandLine.Option(names = {"by"},description = "grouping node" ,arity = "0..1") + @Option(name = "by", acceptNameWithoutDashes = true, description = "grouping node") public String groupBy; - @CommandLine.Parameters(index="0",arity="1",description = "node name") String name; + @Argument(description = "node name") String name; @Inject NodeGroupServiceInterface nodeGroupService; @@ -50,37 +54,38 @@ public class AddRelativeDifference implements Callable { NodeServiceInterface nodeService; @Override - public Integer call() throws Exception { + public CommandResult execute(H5mCommandInvocation invocation) throws InterruptedException { + if (groupName == null && invocation.hasFolderContext()) groupName = invocation.getFolderName(); if(name==null || name.isEmpty()){ - System.err.println("missing node name"); - return 1; + invocation.println("missing node name"); + return CommandResult.FAILURE; } if(groupName==null || groupName.isEmpty()){ - System.err.println("missing group name"); - return 1; + invocation.println("missing group name"); + return CommandResult.FAILURE; } NodeGroup foundGroup = nodeGroupService.byName(groupName); if(foundGroup==null){ - System.err.println("node group with name "+groupName+" does not exist"); - return 1; + invocation.println("node group with name "+groupName+" does not exist"); + return CommandResult.FAILURE; } List foundNodes = nodeService.findNodeByFqdn(name,foundGroup.id()); if(!foundNodes.isEmpty()){ - System.err.println(groupName+" already has "+name+" node(s)\n "+foundNodes.stream().map(Node::fqdn).collect(Collectors.joining("\n "))); + invocation.println(groupName+" already has "+name+" node(s)\n "+foundNodes.stream().map(Node::fqdn).collect(Collectors.joining("\n "))); } if(rangeName==null || rangeName.isEmpty()){ - System.err.println("Missing range"); - return 1; + invocation.println("Missing range"); + return CommandResult.FAILURE; } foundNodes = nodeService.findNodeByFqdn(rangeName,foundGroup.id()); if(foundNodes.isEmpty()){ - System.err.println("could not find matching range node by name "+rangeName); - return 1; + invocation.println("could not find matching range node by name "+rangeName); + return CommandResult.FAILURE; }else if (foundNodes.size()>1){ - System.err.println("found more than one matching range node by name "+rangeName+"\n "+foundNodes.stream().map(Node::fqdn).collect(Collectors.joining("\n "))); - return 1; + invocation.println("found more than one matching range node by name "+rangeName+"\n "+foundNodes.stream().map(Node::fqdn).collect(Collectors.joining("\n "))); + return CommandResult.FAILURE; } Node rangeNode = foundNodes.getFirst(); @@ -88,11 +93,11 @@ public Integer call() throws Exception { if(domainName!=null && !domainName.isEmpty()){ foundNodes = nodeService.findNodeByFqdn(domainName, foundGroup.id()); if(foundNodes.isEmpty()){ - System.err.println("could not find matching domain node by name "+domainName); - return 1; + invocation.println("could not find matching domain node by name "+domainName); + return CommandResult.FAILURE; }else if (foundNodes.size()>1){ - System.err.println("found more than one matching domain node by name "+domainName+"\n "+foundNodes.stream().map(Node::fqdn).collect(Collectors.joining("\n "))); - return 1; + invocation.println("found more than one matching domain node by name "+domainName+"\n "+foundNodes.stream().map(Node::fqdn).collect(Collectors.joining("\n "))); + return CommandResult.FAILURE; } domainNode = foundNodes.getFirst(); } @@ -101,11 +106,11 @@ public Integer call() throws Exception { if(groupBy!=null && !groupBy.isEmpty()){ foundNodes = nodeService.findNodeByFqdn(groupBy, foundGroup.id()); if(foundNodes.isEmpty()){ - System.err.println("could not find matching group by node with name"+groupBy); - return 1; + invocation.println("could not find matching group by node with name " + groupBy); + return CommandResult.FAILURE; }else if (foundNodes.size()>1){ - System.err.println("found more than one matching group by node for name "+groupBy+"\n "+foundNodes.stream().map(Node::fqdn).collect(Collectors.joining("\n "))); - return 1; + invocation.println("found more than one matching group by node for name "+groupBy+"\n "+foundNodes.stream().map(Node::fqdn).collect(Collectors.joining("\n "))); + return CommandResult.FAILURE; } groupByNode = foundNodes.getFirst(); } @@ -115,24 +120,28 @@ public Integer call() throws Exception { List fingerprintNodes = new ArrayList<>(); if(fingerprints!=null && !fingerprints.isEmpty()){ - List fingerprintNames = fingerprints.stream().flatMap(fp->Arrays.stream(fp.split(","))).map(String::trim).filter(v->!v.isBlank()).toList(); - for(String fingeprintName : fingerprintNames){ + for(String fingeprintName : fingerprints){ foundNodes = nodeService.findNodeByFqdn(fingeprintName,foundGroup.id()); if(foundNodes.isEmpty()){ - System.err.println("could not find matching fingerprint node by name "+fingeprintName); - return 1; + invocation.println("could not find matching fingerprint node by name "+fingeprintName); + return CommandResult.FAILURE; }else if (foundNodes.size()>1){ - System.err.println("found more than one matching fingerprint node by name "+fingeprintName+"\n "+foundNodes.stream().map(Node::fqdn).collect(Collectors.joining("\n "))); - return 1; + invocation.println("found more than one matching fingerprint node by name "+fingeprintName+"\n "+foundNodes.stream().map(Node::fqdn).collect(Collectors.joining("\n "))); + return CommandResult.FAILURE; } fingerprintNodes.add(foundNodes.getFirst().id()); } } - Long fingerprintId = nodeService.createConfigured("_fp-" + name, foundGroup.id(), NodeType.FINGERPRINT, fingerprintNodes, null); - List sources = domainNode == null ? List.of(fingerprintId, groupByNode.id(), rangeNode.id()) : List.of(fingerprintId, groupByNode.id(), rangeNode.id(), domainNode.id()); - nodeService.createConfigured(name, foundGroup.id(), NodeType.RELATIVE_DIFFERENCE, sources, new RelativeDifferenceConfig(filter, threshold, window, minPrevious, fingerprintFilter)); + try { + Long fingerprintId = nodeService.createConfigured("_fp-" + name, foundGroup.id(), NodeType.FINGERPRINT, fingerprintNodes, null); + List sources = domainNode == null ? List.of(fingerprintId, groupByNode.id(), rangeNode.id()) : List.of(fingerprintId, groupByNode.id(), rangeNode.id(), domainNode.id()); + nodeService.createConfigured(name, foundGroup.id(), NodeType.RELATIVE_DIFFERENCE, sources, new RelativeDifferenceConfig(filter, threshold, window, minPrevious, fingerprintFilter)); + } catch (Exception e) { + invocation.println("Error creating node: " + e.getMessage()); + return CommandResult.FAILURE; + } - return 0; + return CommandResult.SUCCESS; } } diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/AddSplit.java b/src/main/java/io/hyperfoil/tools/h5m/cli/AddSplit.java index f478c495..c6fb7c7a 100644 --- a/src/main/java/io/hyperfoil/tools/h5m/cli/AddSplit.java +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/AddSplit.java @@ -1,19 +1,24 @@ package io.hyperfoil.tools.h5m.cli; import io.hyperfoil.tools.h5m.api.NodeGroup; +import io.hyperfoil.tools.h5m.api.NodeType; import io.hyperfoil.tools.h5m.api.svc.NodeGroupServiceInterface; import io.hyperfoil.tools.h5m.api.svc.NodeServiceInterface; import jakarta.inject.Inject; -import picocli.CommandLine; -import java.util.concurrent.Callable; +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; +import org.aesh.command.option.Arguments; +import org.aesh.command.option.Option; -@CommandLine.Command(name="split", separator = " ", description = "add split node", mixinStandardHelpOptions = true) -public class AddSplit implements Callable { +import java.util.List; - @CommandLine.Option(names = {"to"},description = "target group / test" ) String groupName; - @CommandLine.Parameters(index="0",arity="1",description = "node name") String name; - @CommandLine.Parameters(index="1",arity="1",description = "operation") String operation; +@CommandDefinition(name="split", description = "Add a split node that divides array values into individual elements for downstream processing", generateHelp = true) +public class AddSplit implements Command { + + @Option(name = "to", acceptNameWithoutDashes = true, description = "target group / test") String groupName; + @Arguments(description = "name and operation") List args; @Inject NodeGroupServiceInterface nodeGroupService; @@ -23,27 +28,29 @@ public class AddSplit implements Callable { @Override - public Integer call() throws Exception { + public CommandResult execute(H5mCommandInvocation invocation) throws InterruptedException { + if (groupName == null && invocation.hasFolderContext()) groupName = invocation.getFolderName(); + String name = (args != null && args.size() >= 1) ? args.get(0) : null; + String operation = (args != null && args.size() >= 2) ? args.get(1) : null; if(name == null){ - System.err.println("missing node name"); - return 1; + invocation.println("missing node name"); + return CommandResult.FAILURE; } if(groupName == null){ - System.err.println("missing group name"); - return 1; + invocation.println("missing group name"); + return CommandResult.FAILURE; } NodeGroup foundGroup = nodeGroupService.byName(groupName); if(foundGroup == null){ - System.err.println("could not find target group/test "+groupName); - return 1; + invocation.println("could not find target group/test "+groupName); + return CommandResult.FAILURE; } if(operation == null){ - System.err.println("missing operation"); - return 1; + invocation.println("missing operation"); + return CommandResult.FAILURE; } - - - return 0; + nodeService.create(name, foundGroup.id(), NodeType.SPLIT, operation); + return CommandResult.SUCCESS; } } diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/AddStdDevAnomaly.java b/src/main/java/io/hyperfoil/tools/h5m/cli/AddStdDevAnomaly.java index 86c82f49..9e2de4cf 100644 --- a/src/main/java/io/hyperfoil/tools/h5m/cli/AddStdDevAnomaly.java +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/AddStdDevAnomaly.java @@ -8,51 +8,55 @@ import io.hyperfoil.tools.h5m.api.svc.NodeServiceInterface; import io.hyperfoil.tools.h5m.entity.node.StdDevAnomaly; import jakarta.inject.Inject; -import picocli.CommandLine; + +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; +import org.aesh.command.option.Argument; +import org.aesh.command.option.Option; +import org.aesh.command.option.OptionList; import java.util.ArrayList; -import java.util.Arrays; import java.util.List; -import java.util.concurrent.Callable; import java.util.stream.Collectors; -@CommandLine.Command(name = "stddev", separator = " ", description = "add a standard deviation anomaly detection node", mixinStandardHelpOptions = true) -public class AddStdDevAnomaly implements Callable { +@CommandDefinition(name = "stddev", description = "Add a standard deviation anomaly detection node that identifies statistical outliers in time series data", generateHelp = true) +public class AddStdDevAnomaly implements Command { - @CommandLine.Option(names = {"to"}, description = "target group / test") String groupName; + @Option(name = "to", acceptNameWithoutDashes = true, description = "target group / test") String groupName; - @CommandLine.Option(names = {"range"}, arity = "1", description = "node that produces the value to inspect") + @Option(name = "range", acceptNameWithoutDashes = true, description = "node that produces the value to inspect") String rangeName; - @CommandLine.Option(names = {"domain"}, arity = "0..1", description = "node used to sort the range values") + @Option(name = "domain", acceptNameWithoutDashes = true, description = "node used to sort the range values") String domainName; - @CommandLine.Option(names = {"by"}, description = "grouping node", arity = "0..1") + @Option(name = "by", acceptNameWithoutDashes = true, description = "grouping node") public String groupBy; - @CommandLine.Option(names = {"fingerprint"}, description = "node names to use as fingerprint") + @OptionList(name = "fingerprint", description = "node names to use as fingerprint") List fingerprints; - @CommandLine.Option(names = {"--fingerprint-filter", "-ff"}, arity = "0..1", description = "jq filter expression for fingerprints") + @Option(name = "fingerprint-filter", acceptNameWithoutDashes = true, shortName = 'f', description = "jq filter expression for fingerprints") String fingerprintFilter; - @CommandLine.Option(names = {"windowSize"}, arity = "0..1", description = "number of preceding data points for baseline", - defaultValue = "" + StdDevAnomaly.DEFAULT_WINDOW_SIZE) + @Option(name = "windowSize", acceptNameWithoutDashes = true, description = "number of preceding data points for baseline", + defaultValue = {"" + StdDevAnomaly.DEFAULT_WINDOW_SIZE}) int windowSize; - @CommandLine.Option(names = {"deviations"}, arity = "0..1", description = "number of standard deviations for alert threshold", - defaultValue = "" + StdDevAnomaly.DEFAULT_DEVIATIONS) + @Option(name = "deviations", acceptNameWithoutDashes = true, description = "number of standard deviations for alert threshold", + defaultValue = {"" + StdDevAnomaly.DEFAULT_DEVIATIONS}) double deviations; - @CommandLine.Option(names = {"direction"}, arity = "0..1", description = "UPPER, LOWER, or BOTH", - defaultValue = "BOTH") + @Option(name = "direction", acceptNameWithoutDashes = true, description = "UPPER, LOWER, or BOTH", + defaultValue = {"BOTH"}) StdDevAnomalyConfig.Direction direction; - @CommandLine.Option(names = {"minDataPoints"}, arity = "0..1", description = "minimum data points before alerting", - defaultValue = "" + StdDevAnomaly.DEFAULT_MIN_DATA_POINTS) + @Option(name = "minDataPoints", acceptNameWithoutDashes = true, description = "minimum data points before alerting", + defaultValue = {"" + StdDevAnomaly.DEFAULT_MIN_DATA_POINTS}) int minDataPoints; - @CommandLine.Parameters(index = "0", arity = "1", description = "node name") String name; + @Argument(description = "node name") String name; @Inject NodeGroupServiceInterface nodeGroupService; @@ -61,37 +65,38 @@ public class AddStdDevAnomaly implements Callable { NodeServiceInterface nodeService; @Override - public Integer call() throws Exception { + public CommandResult execute(H5mCommandInvocation invocation) throws InterruptedException { + if (groupName == null && invocation.hasFolderContext()) groupName = invocation.getFolderName(); if (name == null || name.isEmpty()) { - System.err.println("missing node name"); - return 1; + invocation.println("missing node name"); + return CommandResult.FAILURE; } if (groupName == null || groupName.isEmpty()) { - System.err.println("missing group name"); - return 1; + invocation.println("missing group name"); + return CommandResult.FAILURE; } NodeGroup foundGroup = nodeGroupService.byName(groupName); if (foundGroup == null) { - System.err.println("node group with name " + groupName + " does not exist"); - return 1; + invocation.println("node group with name " + groupName + " does not exist"); + return CommandResult.FAILURE; } List foundNodes = nodeService.findNodeByFqdn(name, foundGroup.id()); if (!foundNodes.isEmpty()) { - System.err.println(groupName + " already has " + name + " node(s)\n " + foundNodes.stream().map(Node::fqdn).collect(Collectors.joining("\n "))); + invocation.println(groupName + " already has " + name + " node(s)\n " + foundNodes.stream().map(Node::fqdn).collect(Collectors.joining("\n "))); } if (rangeName == null || rangeName.isEmpty()) { - System.err.println("Missing range"); - return 1; + invocation.println("Missing range"); + return CommandResult.FAILURE; } foundNodes = nodeService.findNodeByFqdn(rangeName, foundGroup.id()); if (foundNodes.isEmpty()) { - System.err.println("could not find matching range node by name " + rangeName); - return 1; + invocation.println("could not find matching range node by name " + rangeName); + return CommandResult.FAILURE; } else if (foundNodes.size() > 1) { - System.err.println("found more than one matching range node by name " + rangeName + "\n " + foundNodes.stream().map(Node::fqdn).collect(Collectors.joining("\n "))); - return 1; + invocation.println("found more than one matching range node by name " + rangeName + "\n " + foundNodes.stream().map(Node::fqdn).collect(Collectors.joining("\n "))); + return CommandResult.FAILURE; } Node rangeNode = foundNodes.getFirst(); @@ -99,11 +104,11 @@ public Integer call() throws Exception { if (domainName != null && !domainName.isEmpty()) { foundNodes = nodeService.findNodeByFqdn(domainName, foundGroup.id()); if (foundNodes.isEmpty()) { - System.err.println("could not find matching domain node by name " + domainName); - return 1; + invocation.println("could not find matching domain node by name " + domainName); + return CommandResult.FAILURE; } else if (foundNodes.size() > 1) { - System.err.println("found more than one matching domain node by name " + domainName + "\n " + foundNodes.stream().map(Node::fqdn).collect(Collectors.joining("\n "))); - return 1; + invocation.println("found more than one matching domain node by name " + domainName + "\n " + foundNodes.stream().map(Node::fqdn).collect(Collectors.joining("\n "))); + return CommandResult.FAILURE; } domainNode = foundNodes.getFirst(); } @@ -112,11 +117,11 @@ public Integer call() throws Exception { if (groupBy != null && !groupBy.isEmpty()) { foundNodes = nodeService.findNodeByFqdn(groupBy, foundGroup.id()); if (foundNodes.isEmpty()) { - System.err.println("could not find matching group by node with name " + groupBy); - return 1; + invocation.println("could not find matching group by node with name " + groupBy); + return CommandResult.FAILURE; } else if (foundNodes.size() > 1) { - System.err.println("found more than one matching group by node for name " + groupBy + "\n " + foundNodes.stream().map(Node::fqdn).collect(Collectors.joining("\n "))); - return 1; + invocation.println("found more than one matching group by node for name " + groupBy + "\n " + foundNodes.stream().map(Node::fqdn).collect(Collectors.joining("\n "))); + return CommandResult.FAILURE; } groupByNode = foundNodes.getFirst(); } @@ -126,27 +131,31 @@ public Integer call() throws Exception { List fingerprintNodes = new ArrayList<>(); if (fingerprints != null && !fingerprints.isEmpty()) { - List fingerprintNames = fingerprints.stream().flatMap(fp -> Arrays.stream(fp.split(","))).map(String::trim).filter(v -> !v.isBlank()).toList(); - for (String fingerprintName : fingerprintNames) { + for (String fingerprintName : fingerprints) { foundNodes = nodeService.findNodeByFqdn(fingerprintName, foundGroup.id()); if (foundNodes.isEmpty()) { - System.err.println("could not find matching fingerprint node by name " + fingerprintName); - return 1; + invocation.println("could not find matching fingerprint node by name " + fingerprintName); + return CommandResult.FAILURE; } else if (foundNodes.size() > 1) { - System.err.println("found more than one matching fingerprint node by name " + fingerprintName + "\n " + foundNodes.stream().map(Node::fqdn).collect(Collectors.joining("\n "))); - return 1; + invocation.println("found more than one matching fingerprint node by name " + fingerprintName + "\n " + foundNodes.stream().map(Node::fqdn).collect(Collectors.joining("\n "))); + return CommandResult.FAILURE; } fingerprintNodes.add(foundNodes.getFirst().id()); } } - Long fingerprintId = nodeService.createConfigured("_fp-" + name, foundGroup.id(), NodeType.FINGERPRINT, fingerprintNodes, null); - List sources = domainNode == null - ? List.of(fingerprintId, groupByNode.id(), rangeNode.id()) - : List.of(fingerprintId, groupByNode.id(), rangeNode.id(), domainNode.id()); - nodeService.createConfigured(name, foundGroup.id(), NodeType.STDDEV_ANOMALY, sources, - new StdDevAnomalyConfig(windowSize, deviations, direction, minDataPoints, fingerprintFilter)); + try { + Long fingerprintId = nodeService.createConfigured("_fp-" + name, foundGroup.id(), NodeType.FINGERPRINT, fingerprintNodes, null); + List sources = domainNode == null + ? List.of(fingerprintId, groupByNode.id(), rangeNode.id()) + : List.of(fingerprintId, groupByNode.id(), rangeNode.id(), domainNode.id()); + nodeService.createConfigured(name, foundGroup.id(), NodeType.STDDEV_ANOMALY, sources, + new StdDevAnomalyConfig(windowSize, deviations, direction, minDataPoints, fingerprintFilter)); + } catch (Exception e) { + invocation.println("Error creating node: " + e.getMessage()); + return CommandResult.FAILURE; + } - return 0; + return CommandResult.SUCCESS; } } diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/AdminAddMember.java b/src/main/java/io/hyperfoil/tools/h5m/cli/AdminAddMember.java index 5d89e6e0..f4692ddd 100644 --- a/src/main/java/io/hyperfoil/tools/h5m/cli/AdminAddMember.java +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/AdminAddMember.java @@ -5,12 +5,15 @@ import io.hyperfoil.tools.h5m.api.Team; import io.hyperfoil.tools.h5m.api.User; import jakarta.inject.Inject; -import picocli.CommandLine; -import java.util.concurrent.Callable; +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; +import org.aesh.command.option.Argument; +import org.aesh.command.option.Option; -@CommandLine.Command(name = "add-member", description = "add a user to a team", mixinStandardHelpOptions = true) -public class AdminAddMember implements Callable { +@CommandDefinition(name = "add-member", description = "Add a user to a team", generateHelp = true) +public class AdminAddMember implements Command { @Inject TeamServiceInterface teamService; @@ -18,26 +21,26 @@ public class AdminAddMember implements Callable { @Inject UserServiceInterface userService; - @CommandLine.Parameters(index = "0", description = "username") + @Argument(description = "username") public String username; - @CommandLine.Parameters(index = "1", description = "team name") + @Option(name = "team", acceptNameWithoutDashes = true, description = "team name") public String teamName; @Override - public Integer call() { + public CommandResult execute(H5mCommandInvocation invocation) throws InterruptedException { User user = userService.byUsername(username); if (user == null) { - System.err.println("User not found: " + username); - return 1; + invocation.println("User not found: " + username); + return CommandResult.FAILURE; } Team team = teamService.byName(teamName); if (team == null) { - System.err.println("Team not found: " + teamName); - return 1; + invocation.println("Team not found: " + teamName); + return CommandResult.FAILURE; } teamService.addMember(team.id(), user.id()); - System.out.println("Added " + username + " to team " + teamName); - return 0; + invocation.println("Added " + username + " to team " + teamName); + return CommandResult.SUCCESS; } } diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/AdminCmd.java b/src/main/java/io/hyperfoil/tools/h5m/cli/AdminCmd.java index 50c266ef..6f6d0727 100644 --- a/src/main/java/io/hyperfoil/tools/h5m/cli/AdminCmd.java +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/AdminCmd.java @@ -1,14 +1,14 @@ package io.hyperfoil.tools.h5m.cli; -import picocli.CommandLine; +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; -import java.util.concurrent.Callable; -@CommandLine.Command( +@CommandDefinition( name = "admin", - description = "admin operations", - mixinStandardHelpOptions = true, - subcommands = { + description = "Administration commands for managing users, teams, and API keys", + groupCommands = { AdminCreateTeam.class, AdminCreateUser.class, AdminListTeams.class, @@ -17,13 +17,12 @@ AdminCreateApiKey.class, AdminListApiKeys.class, AdminRevokeApiKey.class, - } + }, + generateHelp = true ) -public class AdminCmd implements Callable { +public class AdminCmd implements Command { @Override - public Integer call() throws Exception { - CommandLine cmd = new CommandLine(this); - cmd.usage(System.out); - return 0; + public CommandResult execute(H5mCommandInvocation invocation) { + return CommandResult.SUCCESS; } } diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/AdminCreateApiKey.java b/src/main/java/io/hyperfoil/tools/h5m/cli/AdminCreateApiKey.java index 465b5a40..1dc4f0a3 100644 --- a/src/main/java/io/hyperfoil/tools/h5m/cli/AdminCreateApiKey.java +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/AdminCreateApiKey.java @@ -3,28 +3,31 @@ import io.hyperfoil.tools.h5m.api.ApiKey; import io.hyperfoil.tools.h5m.api.svc.ApiKeyServiceInterface; import jakarta.inject.Inject; -import picocli.CommandLine; -import java.util.concurrent.Callable; +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; +import org.aesh.command.option.Argument; +import org.aesh.command.option.Option; -@CommandLine.Command(name = "create-api-key", description = "create an API key for a user", mixinStandardHelpOptions = true) -public class AdminCreateApiKey implements Callable { +@CommandDefinition(name = "create-api-key", description = "Generate a new API key for programmatic access", generateHelp = true) +public class AdminCreateApiKey implements Command { @Inject ApiKeyServiceInterface apiKeyService; - @CommandLine.Parameters(index = "0", description = "username") + @Argument(description = "username", required = true) public String username; - @CommandLine.Option(names = {"--description"}, description = "key description", defaultValue = "") + @Option(name = "description", acceptNameWithoutDashes = true, description = "key description", defaultValue = "") public String description; @Override - public Integer call() { + public CommandResult execute(H5mCommandInvocation invocation) throws InterruptedException { ApiKey key = apiKeyService.create(username, description); - System.out.println("API key created for user: " + username); - System.out.println("Key: " + key.rawKey()); - System.out.println("WARNING: This key cannot be retrieved again. Store it securely."); - return 0; + invocation.println("API key created for user: " + username); + invocation.println("Key: " + key.rawKey()); + invocation.println("WARNING: This key cannot be retrieved again. Store it securely."); + return CommandResult.SUCCESS; } } diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/AdminCreateTeam.java b/src/main/java/io/hyperfoil/tools/h5m/cli/AdminCreateTeam.java index e4e74fae..4fdb305f 100644 --- a/src/main/java/io/hyperfoil/tools/h5m/cli/AdminCreateTeam.java +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/AdminCreateTeam.java @@ -2,23 +2,25 @@ import io.hyperfoil.tools.h5m.api.svc.TeamServiceInterface; import jakarta.inject.Inject; -import picocli.CommandLine; -import java.util.concurrent.Callable; +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; +import org.aesh.command.option.Argument; -@CommandLine.Command(name = "create-team", description = "create a new team", mixinStandardHelpOptions = true) -public class AdminCreateTeam implements Callable { +@CommandDefinition(name = "create-team", description = "Create a new team for organizing access control", generateHelp = true) +public class AdminCreateTeam implements Command { @Inject TeamServiceInterface teamService; - @CommandLine.Parameters(index = "0", description = "team name") + @Argument(description = "team name") public String name; @Override - public Integer call() { + public CommandResult execute(H5mCommandInvocation invocation) throws InterruptedException { long id = teamService.create(name); - System.out.println("Created team: " + name + " (id=" + id + ")"); - return 0; + invocation.println("Created team: " + name + " (id=" + id + ")"); + return CommandResult.SUCCESS; } } diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/AdminCreateUser.java b/src/main/java/io/hyperfoil/tools/h5m/cli/AdminCreateUser.java index 4e92a7fd..298a5ae7 100644 --- a/src/main/java/io/hyperfoil/tools/h5m/cli/AdminCreateUser.java +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/AdminCreateUser.java @@ -3,26 +3,29 @@ import io.hyperfoil.tools.h5m.api.svc.UserServiceInterface; import io.hyperfoil.tools.h5m.api.Role; import jakarta.inject.Inject; -import picocli.CommandLine; -import java.util.concurrent.Callable; +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; +import org.aesh.command.option.Argument; +import org.aesh.command.option.Option; -@CommandLine.Command(name = "create-user", description = "create a new user", mixinStandardHelpOptions = true) -public class AdminCreateUser implements Callable { +@CommandDefinition(name = "create-user", description = "Create a new user account", generateHelp = true) +public class AdminCreateUser implements Command { @Inject UserServiceInterface userService; - @CommandLine.Parameters(index = "0", description = "username") + @Argument(description = "username") public String username; - @CommandLine.Option(names = {"--role"}, description = "role (ADMIN or USER)", defaultValue = "USER") + @Option(name = "role", acceptNameWithoutDashes = true, description = "role (ADMIN or USER)", defaultValue = {"USER"}) public Role role; @Override - public Integer call() { + public CommandResult execute(H5mCommandInvocation invocation) throws InterruptedException { long id = userService.create(username, role); - System.out.println("Created user: " + username + " (id=" + id + ", role=" + role + ")"); - return 0; + invocation.println("Created user: " + username + " (id=" + id + ", role=" + role + ")"); + return CommandResult.SUCCESS; } } diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/AdminListApiKeys.java b/src/main/java/io/hyperfoil/tools/h5m/cli/AdminListApiKeys.java index 17f41ae2..c98cc14d 100644 --- a/src/main/java/io/hyperfoil/tools/h5m/cli/AdminListApiKeys.java +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/AdminListApiKeys.java @@ -3,23 +3,27 @@ import io.hyperfoil.tools.h5m.api.ApiKey; import io.hyperfoil.tools.h5m.api.svc.ApiKeyServiceInterface; import jakarta.inject.Inject; -import picocli.CommandLine; + +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; +import org.aesh.command.option.Argument; import java.util.List; -@CommandLine.Command(name = "list-api-keys", description = "list API keys for a user", mixinStandardHelpOptions = true) -public class AdminListApiKeys implements Runnable { +@CommandDefinition(name = "list-api-keys", description = "List API keys for a user", generateHelp = true) +public class AdminListApiKeys implements Command { @Inject ApiKeyServiceInterface apiKeyService; - @CommandLine.Parameters(index = "0", description = "username") + @Argument(description = "username", required = true) public String username; @Override - public void run() { + public CommandResult execute(H5mCommandInvocation invocation) throws InterruptedException { List keys = apiKeyService.listByUser(username); - System.out.println(ListCmd.table(100, keys, + invocation.println(ListCmd.table(100, keys, List.of("id", "description", "created", "last_used", "revoked", "expired"), List.of(k -> String.valueOf(k.id()), k -> k.description() != null ? k.description() : "", @@ -27,5 +31,6 @@ public void run() { k -> k.lastUsedAt() != null ? k.lastUsedAt().toString() : "never", k -> String.valueOf(k.revoked()), k -> String.valueOf(k.expired())))); + return CommandResult.SUCCESS; } } diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/AdminListTeams.java b/src/main/java/io/hyperfoil/tools/h5m/cli/AdminListTeams.java index 8fe6eb4c..5be8a81a 100644 --- a/src/main/java/io/hyperfoil/tools/h5m/cli/AdminListTeams.java +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/AdminListTeams.java @@ -3,21 +3,24 @@ import io.hyperfoil.tools.h5m.api.svc.TeamServiceInterface; import io.hyperfoil.tools.h5m.api.Team; import jakarta.inject.Inject; -import picocli.CommandLine; +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; import java.util.List; -@CommandLine.Command(name = "list-teams", description = "list all teams", mixinStandardHelpOptions = true) -public class AdminListTeams implements Runnable { +@CommandDefinition(name = "list-teams", description = "List all teams", generateHelp = true) +public class AdminListTeams implements Command { @Inject TeamServiceInterface teamService; @Override - public void run() { + public CommandResult execute(H5mCommandInvocation invocation) throws InterruptedException { List teams = teamService.list(); - System.out.println(ListCmd.table(80, teams, + invocation.println(ListCmd.table(80, teams, List.of("name", "members"), List.of(t -> t.name(), t -> t.memberCount()))); + return CommandResult.SUCCESS; } } diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/AdminListUsers.java b/src/main/java/io/hyperfoil/tools/h5m/cli/AdminListUsers.java index 71272df3..24e3b220 100644 --- a/src/main/java/io/hyperfoil/tools/h5m/cli/AdminListUsers.java +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/AdminListUsers.java @@ -3,21 +3,24 @@ import io.hyperfoil.tools.h5m.api.svc.UserServiceInterface; import io.hyperfoil.tools.h5m.api.User; import jakarta.inject.Inject; -import picocli.CommandLine; +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; import java.util.List; -@CommandLine.Command(name = "list-users", description = "list all users", mixinStandardHelpOptions = true) -public class AdminListUsers implements Runnable { +@CommandDefinition(name = "list-users", description = "List all registered users", generateHelp = true) +public class AdminListUsers implements Command { @Inject UserServiceInterface userService; @Override - public void run() { + public CommandResult execute(H5mCommandInvocation invocation) throws InterruptedException { List users = userService.list(); - System.out.println(ListCmd.table(80, users, + invocation.println(ListCmd.table(80, users, List.of("username", "role"), List.of(u -> u.username(), u -> u.role().name()))); + return CommandResult.SUCCESS; } } diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/AdminRevokeApiKey.java b/src/main/java/io/hyperfoil/tools/h5m/cli/AdminRevokeApiKey.java index 717662cf..569aa1ff 100644 --- a/src/main/java/io/hyperfoil/tools/h5m/cli/AdminRevokeApiKey.java +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/AdminRevokeApiKey.java @@ -2,23 +2,25 @@ import io.hyperfoil.tools.h5m.api.svc.ApiKeyServiceInterface; import jakarta.inject.Inject; -import picocli.CommandLine; -import java.util.concurrent.Callable; +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; +import org.aesh.command.option.Argument; -@CommandLine.Command(name = "revoke-api-key", description = "revoke an API key", mixinStandardHelpOptions = true) -public class AdminRevokeApiKey implements Callable { +@CommandDefinition(name = "revoke-api-key", description = "Revoke an existing API key", generateHelp = true) +public class AdminRevokeApiKey implements Command { @Inject ApiKeyServiceInterface apiKeyService; - @CommandLine.Parameters(index = "0", description = "API key ID") + @Argument(description = "API key ID", required = true) public long keyId; @Override - public Integer call() { + public CommandResult execute(H5mCommandInvocation invocation) throws InterruptedException { apiKeyService.revoke(keyId); - System.out.println("API key " + keyId + " revoked."); - return 0; + invocation.println("API key " + keyId + " revoked."); + return CommandResult.SUCCESS; } } diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/ChangeFolderCmd.java b/src/main/java/io/hyperfoil/tools/h5m/cli/ChangeFolderCmd.java new file mode 100644 index 00000000..7c7dd20a --- /dev/null +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/ChangeFolderCmd.java @@ -0,0 +1,50 @@ +package io.hyperfoil.tools.h5m.cli; + +import jakarta.inject.Inject; + +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; +import org.aesh.command.option.Argument; + +import io.hyperfoil.tools.h5m.api.Folder; +import io.hyperfoil.tools.h5m.api.svc.FolderServiceInterface; + +@CommandDefinition(name = "cd", description = "Set the active folder context so subsequent commands don't need --to/--from", generateHelp = true) +public class ChangeFolderCmd implements Command { + + @Inject + FolderServiceInterface folderService; + + @Argument(description = "folder name (or '..' to exit current folder)", completer = FolderCompleter.class) + String folderName; + + @Override + public CommandResult execute(H5mCommandInvocation invocation) throws InterruptedException { + if (folderName == null || folderName.isEmpty()) { + invocation.clearFolderContext(); + invocation.println("Folder context cleared"); + return CommandResult.SUCCESS; + } + + if ("..".equals(folderName)) { + invocation.clearFolderContext(); + invocation.println("Folder context cleared"); + return CommandResult.SUCCESS; + } + + String targetFolder = folderName; + if (targetFolder.startsWith("../")) { + targetFolder = targetFolder.substring(3); + } + + Folder folder = folderService.byName(targetFolder); + if (folder == null) { + invocation.println("Folder not found: " + targetFolder); + return CommandResult.FAILURE; + } + invocation.setFolderName(targetFolder); + invocation.println("Using folder: " + targetFolder); + return CommandResult.SUCCESS; + } +} diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/ChangeFormatter.java b/src/main/java/io/hyperfoil/tools/h5m/cli/ChangeFormatter.java new file mode 100644 index 00000000..5f8905fb --- /dev/null +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/ChangeFormatter.java @@ -0,0 +1,89 @@ +package io.hyperfoil.tools.h5m.cli; + +import io.hyperfoil.tools.h5m.api.NodeType; +import io.hyperfoil.tools.h5m.api.Value; +import io.hyperfoil.tools.jjq.value.JqObject; +import io.hyperfoil.tools.jjq.value.JqValue; + +import java.util.List; + +/** + * Shared formatting for detection values displayed in CLI output. + * Used by UploadCmd (synchronous completion summary) and ChangesCmd. + */ +final class ChangeFormatter { + + private ChangeFormatter() {} + + static String formatSummary(List detectionValues) { + if (detectionValues.isEmpty()) { + return "No changes detected."; + } + StringBuilder sb = new StringBuilder(); + sb.append(detectionValues.size()).append(detectionValues.size() == 1 ? " change" : " changes").append(" detected:"); + for (Value v : detectionValues) { + sb.append("\n ").append(formatChange(v)); + } + return sb.toString(); + } + + static String formatChange(Value value) { + String nodeName = value.node() != null ? value.node().name() : "unknown"; + NodeType nodeType = value.node() != null ? value.node().type() : null; + String typeName = nodeType != null ? nodeType.name() : "unknown"; + String details = formatDetails(value.data(), nodeType); + String fingerprint = formatFingerprint(value.data()); + if (fingerprint != null) { + return String.format("%s (%s): %s, fingerprint=%s", nodeName, typeName, details, fingerprint); + } + return String.format("%s (%s): %s", nodeName, typeName, details); + } + + private static String formatDetails(JqValue data, NodeType nodeType) { + if (data == null || !(data instanceof JqObject obj)) { + return "no data"; + } + if (nodeType == null) { + return truncate(data.toJsonString(), 80); + } + return switch (nodeType) { + case FIXED_THRESHOLD -> { + String value = obj.has("value") ? obj.get("value").toJsonString() : "?"; + String bound = obj.has("bound") ? obj.get("bound").toJsonString() : "?"; + String direction = obj.has("direction") ? obj.get("direction").asText() : "?"; + yield String.format("value=%s %s bound %s", value, direction, bound); + } + case RELATIVE_DIFFERENCE -> { + String ratio = obj.has("ratio") ? String.format("%.1f%%", obj.get("ratio").asDouble(0.0)) : "?"; + yield String.format("ratio=%s", ratio); + } + case STDDEV_ANOMALY -> { + String direction = obj.has("direction") ? obj.get("direction").asText() : "?"; + String deviations = obj.has("deviations") ? obj.get("deviations").toJsonString() : "?"; + yield String.format("%s (%s deviations)", direction, deviations); + } + case EDIVISIVE -> { + String magnitude = obj.has("magnitude") ? String.format("%.2f", obj.get("magnitude").asDouble(0.0)) : "?"; + String pvalue = obj.has("pvalue") ? String.format("%.4f", obj.get("pvalue").asDouble(0.0)) : "?"; + yield String.format("magnitude=%s, p-value=%s", magnitude, pvalue); + } + default -> truncate(data.toJsonString(), 80); + }; + } + + private static String formatFingerprint(JqValue data) { + if (data == null || !data.has("fingerprint")) { + return null; + } + JqValue fp = data.getField("fingerprint"); + if (fp == null || fp.isNull()) { + return null; + } + return fp.toJsonString(); + } + + private static String truncate(String s, int maxLen) { + if (s == null) return "null"; + return s.length() > maxLen ? s.substring(0, maxLen) + "..." : s; + } +} diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/ChangesCmd.java b/src/main/java/io/hyperfoil/tools/h5m/cli/ChangesCmd.java new file mode 100644 index 00000000..0265de99 --- /dev/null +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/ChangesCmd.java @@ -0,0 +1,53 @@ +package io.hyperfoil.tools.h5m.cli; + +import io.hyperfoil.tools.h5m.api.Value; +import io.hyperfoil.tools.h5m.svc.ValueService; +import jakarta.inject.Inject; + +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; +import org.aesh.command.option.Argument; +import org.aesh.command.option.Option; + +import java.util.List; + +@CommandDefinition(name = "changes", description = "List change detection results for an upload", generateHelp = true) +public class ChangesCmd implements Command { + + @Inject + ValueService valueService; + + @Argument(description = "processing ID (root value ID) to query changes for") + String id; + + @Option(name = "node", acceptNameWithoutDashes = true, description = "filter changes to a specific detection node name") + String nodeName; + + @Override + public CommandResult execute(H5mCommandInvocation invocation) throws InterruptedException { + if (id == null) { + invocation.println("processing ID is required"); + return CommandResult.FAILURE; + } + long rootValueId; + try { + rootValueId = Long.parseLong(id); + } catch (NumberFormatException e) { + invocation.println("invalid processing ID: " + id); + return CommandResult.FAILURE; + } + + List detectionValues = valueService.getDetectionDescendants(rootValueId); + + // Filter by node name if specified + if (nodeName != null && !nodeName.isEmpty()) { + detectionValues = detectionValues.stream() + .filter(v -> v.node() != null && nodeName.equals(v.node().name())) + .toList(); + } + + invocation.println(ChangeFormatter.formatSummary(detectionValues)); + return CommandResult.SUCCESS; + } +} diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/ExportFolder.java b/src/main/java/io/hyperfoil/tools/h5m/cli/ExportFolder.java index b6269fa1..ef20362c 100644 --- a/src/main/java/io/hyperfoil/tools/h5m/cli/ExportFolder.java +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/ExportFolder.java @@ -2,38 +2,41 @@ import io.hyperfoil.tools.h5m.api.svc.FolderServiceInterface; import jakarta.inject.Inject; -import picocli.CommandLine; + +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; +import org.aesh.command.option.Argument; +import org.aesh.command.option.Option; + import java.nio.file.Path; -import java.util.concurrent.Callable; -@CommandLine.Command(name = "export", separator = " ", - description = "export a folder's node graph to a JSON file", - mixinStandardHelpOptions = true) -public class ExportFolder implements Callable { +@CommandDefinition(name = "export", description = "Export a folder's node graph definition to a JSON file for backup or migration", generateHelp = true) +public class ExportFolder implements Command { - @CommandLine.Parameters(index = "0", arity = "1", description = "folder name to export") + @Argument(description = "folder name to export", required = true, completer = FolderCompleter.class) String folderName; - @CommandLine.Option(names = {"to"}, description = "output JSON file path (default: .json)") + @Option(name = "to", acceptNameWithoutDashes = true, description = "output JSON file path (default: .json)") String outputPath; @Inject FolderServiceInterface folderService; @Override - public Integer call() throws Exception { + public CommandResult execute(H5mCommandInvocation invocation) throws InterruptedException { Path path = outputPath != null ? Path.of(outputPath) : Path.of(folderName + ".json"); try { folderService.export(folderName, path); - System.out.println("Exported folder '" + folderName + "' to " + path); - return 0; + invocation.println("Exported folder '" + folderName + "' to " + path); + return CommandResult.SUCCESS; } catch (Exception e) { - System.err.println("Export failed: " + e.getMessage()); - return 1; + invocation.println("Export failed: " + e.getMessage()); + return CommandResult.FAILURE; } } } diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/FolderCmd.java b/src/main/java/io/hyperfoil/tools/h5m/cli/FolderCmd.java new file mode 100644 index 00000000..51db09a5 --- /dev/null +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/FolderCmd.java @@ -0,0 +1,31 @@ +package io.hyperfoil.tools.h5m.cli; + +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; + + +@CommandDefinition( + name = "folder", + description = "Folder management: create, list, remove, upload, export, import, and more", + groupCommands = { + AddFolder.class, + ListFolder.class, + RemoveFolder.class, + UploadCmd.class, + ExportFolder.class, + ImportFolder.class, + StructureCmd.class, + RecalculateCmd.class, + PurgeValuesCmd.class, + ListValue.class, + }, + generateHelp = true +) +public class FolderCmd implements Command { + @Override + public CommandResult execute(H5mCommandInvocation invocation) throws InterruptedException { + invocation.println("Use 'folder '. Try 'folder --help' for available subcommands."); + return CommandResult.SUCCESS; + } +} diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/FolderCompleter.java b/src/main/java/io/hyperfoil/tools/h5m/cli/FolderCompleter.java new file mode 100644 index 00000000..e227f621 --- /dev/null +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/FolderCompleter.java @@ -0,0 +1,37 @@ +package io.hyperfoil.tools.h5m.cli; + +import java.util.List; + +import org.aesh.command.completer.CompleterInvocation; +import org.aesh.command.completer.OptionCompleter; + +import io.hyperfoil.tools.h5m.api.svc.FolderServiceInterface; +import io.quarkus.arc.Arc; + +public class FolderCompleter implements OptionCompleter { + + @Override + public void complete(CompleterInvocation completerInvocation) { + FolderServiceInterface folderService = Arc.container().instance(FolderServiceInterface.class).get(); + String input = completerInvocation.getGivenCompleteValue(); + + if (input != null && input.startsWith("../")) { + String prefix = input.substring(3); + List folderNames = folderService.getFolderUploadCount().keySet().stream() + .filter(name -> prefix.isEmpty() || name.startsWith(prefix)) + .map(name -> "../" + name) + .sorted() + .toList(); + completerInvocation.addAllCompleterValues(folderNames); + } else if ("..".equals(input) || ".".equals(input)) { + completerInvocation.addCompleterValue("../"); + completerInvocation.setAppendSpace(false); + } else { + List folderNames = folderService.getFolderUploadCount().keySet().stream() + .filter(name -> input == null || input.isEmpty() || name.startsWith(input)) + .sorted() + .toList(); + completerInvocation.addAllCompleterValues(folderNames); + } + } +} diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/FolderContext.java b/src/main/java/io/hyperfoil/tools/h5m/cli/FolderContext.java new file mode 100644 index 00000000..cce7e1f5 --- /dev/null +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/FolderContext.java @@ -0,0 +1,25 @@ +package io.hyperfoil.tools.h5m.cli; + +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class FolderContext { + + private String folderName; + + public String getFolderName() { + return folderName; + } + + public void setFolderName(String name) { + this.folderName = name; + } + + public boolean isSet() { + return folderName != null; + } + + public void clear() { + this.folderName = null; + } +} diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/H5m.java b/src/main/java/io/hyperfoil/tools/h5m/cli/H5m.java deleted file mode 100644 index b75ec26f..00000000 --- a/src/main/java/io/hyperfoil/tools/h5m/cli/H5m.java +++ /dev/null @@ -1,148 +0,0 @@ -package io.hyperfoil.tools.h5m.cli; - -import io.hyperfoil.tools.jjq.value.JqValue; -import io.hyperfoil.tools.jjq.value.JqValues; -import io.hyperfoil.tools.h5m.api.Folder; -import io.hyperfoil.tools.h5m.api.Upload; -import io.hyperfoil.tools.h5m.api.svc.FolderServiceInterface; -import io.hyperfoil.tools.h5m.api.svc.NodeGroupServiceInterface; -import io.hyperfoil.tools.h5m.api.svc.NodeServiceInterface; -import io.hyperfoil.tools.h5m.api.svc.ValueServiceInterface; -import io.hyperfoil.tools.h5m.api.svc.WorkServiceInterface; -import io.hyperfoil.tools.h5m.svc.*; - -import io.quarkus.picocli.runtime.annotations.TopCommand; -import io.quarkus.runtime.Quarkus; -import io.quarkus.runtime.configuration.ConfigUtils; -import io.quarkus.runtime.QuarkusApplication; -import io.quarkus.runtime.annotations.QuarkusMain; -import jakarta.enterprise.inject.spi.CDI; -import jakarta.inject.Inject; -import jakarta.persistence.NoResultException; -import picocli.AutoComplete; -import picocli.CommandLine; - -import java.io.File; -import java.io.IOException; -import java.nio.file.Files; -import java.time.Duration; -import java.util.List; -import java.util.concurrent.TimeUnit; - -@QuarkusMain -@TopCommand -@CommandLine.Command(name="", mixinStandardHelpOptions = true,separator = " ", subcommands={CommandLine.HelpCommand.class, AutoComplete.GenerateCompletion.class, ListCmd.class, AddCmd.class, RemoveCmd.class, AdminCmd.class, ExportFolder.class, ImportFolder.class,LoadLegacyTests.class, LoadLegacyRuns.class, VerifyLegacy.class, ResumeProcessing.class}) -public class H5m implements QuarkusApplication { - - //@Inject - FolderServiceInterface folderService; - - //@Inject - NodeServiceInterface nodeService; - - //@Inject - NodeGroupServiceInterface nodeGroupService; - - //@Inject - ValueServiceInterface valueService; - - //@Inject - WorkServiceInterface workService; - - public static boolean consoleAttached(){ - return System.console() != null; - } - - @CommandLine.Command(name="sleep",description = "keep the process idle for x seconds") - public int sleep(int seconds) throws InterruptedException { - Thread.sleep(Duration.ofSeconds(seconds).toMillis()); - return 0; - } - - @CommandLine.Command(name="purge-values", description = "remove all values (to re-upload)") - public int purgeValues(){ - valueService.purgeValues(); - return 0; - } - - @CommandLine.Command(name="structure",description = "use yaup to compute the structure of a folder",aliases = {"shape"}, mixinStandardHelpOptions = true) - public int structure(String folderName){ - try { - JqValue structure = folderService.structure(folderName); - System.out.println(JqValues.toPrettyJsonString(structure)); - } catch (NoResultException e) { - System.err.println("could not find folder "+folderName); - return 1; - } - return 0; - } - @CommandLine.Command(name="upload",description = "") - public int upload( - @CommandLine.Parameters(index="0") - String path, - @CommandLine.Option(names = {"to"},description = "grouping node" ,arity = "1") - String folderName - ){ - Folder folder = folderService.byName(folderName); - if(folder == null){ - System.err.println("could not find folder "+folderName); - return 1; - } - File pathFile = new File(path); - if(!pathFile.exists()){ - System.err.println("upload path does not exist: "+path); - return 1; - } - List todo = pathFile.isDirectory() ? List.of(pathFile.listFiles(s->s.toString().endsWith(".json") && !s.getName().startsWith("."))): List.of(pathFile); - for( File f : todo){ - try { - if( todo.size()>1) { - System.out.println(f.getName()); - } - JqValue read = JqValues.parse(Files.readString(f.toPath())); - if(read!=null){ - try { - Upload upload = folderService.upload(folderName, read); - upload.future.orTimeout(5, TimeUnit.MINUTES).join(); - } catch (NoResultException e) { - System.err.println("could not find folder " + folderName); - return 1; - } - }else{ - System.err.println(f.getPath()+" could not be loaded as json"); - } - } catch (IOException e) { - System.err.println("failure trying to read "+f.getPath()+"\n"+e.getMessage()); - return 1; - } - } - return 0; - } - - @Inject - CommandLine.IFactory factory; - - @Override - public int run(String... args) throws Exception { - //because no @Inject if we implement QuarkusApplication :( - this.folderService = CDI.current().select(FolderService.class).get(); - this.nodeGroupService = CDI.current().select(NodeGroupService.class).get(); - this.nodeService = CDI.current().select(NodeService.class).get(); - this.valueService = CDI.current().select(ValueService.class).get(); - this.workService = CDI.current().select(WorkService.class).get(); - if (!ConfigUtils.getProfiles().contains("cli")) { - Quarkus.waitForExit(); - return 0; - } - System.setProperty("polyglotimpl.DisableClassPathIsolation", "true"); - CommandLine cmd = new CommandLine(this,factory); - CommandLine gen = cmd.getSubcommands().get("generate-completion"); - gen.getCommandSpec().usageMessage().hidden(true); - int returnCode = cmd.execute(args); - workService.terminate(1,TimeUnit.HOURS); - return returnCode; - } - - - -} diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/H5mCliSettings.java b/src/main/java/io/hyperfoil/tools/h5m/cli/H5mCliSettings.java new file mode 100644 index 00000000..8cdbf65d --- /dev/null +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/H5mCliSettings.java @@ -0,0 +1,21 @@ +package io.hyperfoil.tools.h5m.cli; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import org.aesh.command.settings.SettingsBuilder; + +import io.quarkus.aesh.runtime.CliSettings; + +@ApplicationScoped +public class H5mCliSettings implements CliSettings { + + @Inject + FolderContext folderContext; + + @Override + @SuppressWarnings("unchecked") + public void customize(SettingsBuilder builder) { + ((SettingsBuilder) builder).commandInvocationProvider(new H5mCommandInvocationProvider(folderContext)); + } +} diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/H5mCommandInvocation.java b/src/main/java/io/hyperfoil/tools/h5m/cli/H5mCommandInvocation.java new file mode 100644 index 00000000..27d90fe3 --- /dev/null +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/H5mCommandInvocation.java @@ -0,0 +1,127 @@ +package io.hyperfoil.tools.h5m.cli; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +import org.aesh.command.CommandException; +import org.aesh.command.CommandNotFoundException; +import org.aesh.command.Executor; +import org.aesh.command.invocation.CommandInvocation; +import org.aesh.command.invocation.CommandInvocationConfiguration; +import org.aesh.command.parser.CommandLineParserException; +import org.aesh.command.shell.Shell; +import org.aesh.command.validator.CommandValidatorException; +import org.aesh.command.validator.OptionValidatorException; +import org.aesh.readline.prompt.Prompt; +import org.aesh.terminal.KeyAction; + +public class H5mCommandInvocation implements CommandInvocation { + + private final CommandInvocation delegate; + private final FolderContext folderContext; + + H5mCommandInvocation(CommandInvocation delegate, FolderContext folderContext) { + this.delegate = delegate; + this.folderContext = folderContext; + } + + public String getFolderName() { + return folderContext.getFolderName(); + } + + public void setFolderName(String folderName) { + folderContext.setFolderName(folderName); + if (folderName != null) { + delegate.setPrompt(new Prompt("[h5m:" + folderName + "]$ ")); + } else { + delegate.setPrompt(new Prompt("[h5m]$ ")); + } + } + + public boolean hasFolderContext() { + return folderContext.isSet(); + } + + public void clearFolderContext() { + setFolderName(null); + } + + @Override + public Shell getShell() { + return delegate.getShell(); + } + + @Override + public void setPrompt(Prompt prompt) { + delegate.setPrompt(prompt); + } + + @Override + public Prompt getPrompt() { + return delegate.getPrompt(); + } + + @Override + public String getHelpInfo(String commandName) { + return delegate.getHelpInfo(commandName); + } + + @Override + public String getHelpInfo() { + return delegate.getHelpInfo(); + } + + @Override + public void stop() { + delegate.stop(); + } + + @Override + public CommandInvocationConfiguration getConfiguration() { + return delegate.getConfiguration(); + } + + @Override + public KeyAction input() throws InterruptedException { + return delegate.input(); + } + + @Override + public KeyAction input(long timeout, TimeUnit unit) throws InterruptedException { + return delegate.input(timeout, unit); + } + + @Override + public String inputLine() throws InterruptedException { + return delegate.inputLine(); + } + + @Override + public String inputLine(Prompt prompt) throws InterruptedException { + return delegate.inputLine(prompt); + } + + @Override + public void print(String msg, boolean paging) { + delegate.print(msg, paging); + } + + @Override + public void println(String msg, boolean paging) { + delegate.println(msg, paging); + } + + @Override + public Executor buildExecutor(String line) + throws CommandNotFoundException, CommandLineParserException, + OptionValidatorException, CommandValidatorException, IOException { + return delegate.buildExecutor(line); + } + + @Override + public void executeCommand(String input) throws CommandNotFoundException, + CommandLineParserException, OptionValidatorException, + CommandValidatorException, CommandException, InterruptedException, IOException { + delegate.executeCommand(input); + } +} diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/H5mCommandInvocationProvider.java b/src/main/java/io/hyperfoil/tools/h5m/cli/H5mCommandInvocationProvider.java new file mode 100644 index 00000000..8b339fb2 --- /dev/null +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/H5mCommandInvocationProvider.java @@ -0,0 +1,18 @@ +package io.hyperfoil.tools.h5m.cli; + +import org.aesh.command.invocation.CommandInvocation; +import org.aesh.command.invocation.CommandInvocationProvider; + +public class H5mCommandInvocationProvider implements CommandInvocationProvider { + + private final FolderContext folderContext; + + public H5mCommandInvocationProvider(FolderContext folderContext) { + this.folderContext = folderContext; + } + + @Override + public H5mCommandInvocation enhanceCommandInvocation(CommandInvocation commandInvocation) { + return new H5mCommandInvocation(commandInvocation, folderContext); + } +} diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/H5mCommandRegistryFactory.java b/src/main/java/io/hyperfoil/tools/h5m/cli/H5mCommandRegistryFactory.java new file mode 100644 index 00000000..23497c37 --- /dev/null +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/H5mCommandRegistryFactory.java @@ -0,0 +1,78 @@ +package io.hyperfoil.tools.h5m.cli; + +import java.util.Set; + +import jakarta.annotation.Priority; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Alternative; +import jakarta.enterprise.inject.Instance; + +import org.aesh.command.Command; +import org.aesh.command.DefaultValueProvider; +import org.aesh.command.impl.registry.AeshCommandRegistryBuilder; +import org.aesh.command.invocation.CommandInvocation; + +import io.quarkus.aesh.runtime.AeshCdiCommandContainerBuilder; +import io.quarkus.aesh.runtime.CliCommandRegistryFactory; + +/** + * Custom command registry factory that only registers top-level commands. + * Subcommands (referenced via groupCommands in parent @CommandDefinition) + * are discovered automatically by aesh from the parent command. + *

+ * This prevents subcommands from appearing as top-level commands in tab completion. + */ +@Alternative +@Priority(1) +@ApplicationScoped +public class H5mCommandRegistryFactory implements CliCommandRegistryFactory { + + /** Top-level command classes — only these are registered in the registry */ + private static final Set> TOP_LEVEL_COMMANDS = Set.of( + FolderCmd.class, + NodeCmd.class, + NotificationCmd.class, + LegacyCmd.class, + AdminCmd.class, + ChangeFolderCmd.class, + UploadCmd.class, + StatusCmd.class, + ChangesCmd.class, + ViewCmd.class, + RunCmd.class + ); + + private final Instance> commands; + private final Instance defaultValueProvider; + + @SuppressWarnings("unchecked") + public H5mCommandRegistryFactory(Instance> commands, + Instance defaultValueProvider) { + this.commands = commands; + this.defaultValueProvider = defaultValueProvider; + } + + @Override + @SuppressWarnings({ "unchecked", "rawtypes" }) + public AeshCommandRegistryBuilder create() { + AeshCommandRegistryBuilder builder = AeshCommandRegistryBuilder.builder(); + builder.containerBuilder(new AeshCdiCommandContainerBuilder<>()); + + if (defaultValueProvider.isResolvable()) { + builder.defaultValueProvider(defaultValueProvider.get()); + } + + for (Command command : commands) { + if (TOP_LEVEL_COMMANDS.contains(command.getClass())) { + try { + builder.command(command); + } catch (Exception e) { + throw new RuntimeException( + "Failed to register command: " + command.getClass().getName(), e); + } + } + } + + return builder; + } +} diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/ImportFolder.java b/src/main/java/io/hyperfoil/tools/h5m/cli/ImportFolder.java index dc12432a..2806713c 100644 --- a/src/main/java/io/hyperfoil/tools/h5m/cli/ImportFolder.java +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/ImportFolder.java @@ -2,41 +2,45 @@ import io.hyperfoil.tools.h5m.api.svc.FolderServiceInterface; import jakarta.inject.Inject; -import picocli.CommandLine; + +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; +import org.aesh.command.option.Argument; +import org.aesh.command.option.Option; + import java.nio.file.Path; -import java.util.concurrent.Callable; -@CommandLine.Command(name = "import", separator = " ", - description = "import a folder's node graph from a JSON file", - mixinStandardHelpOptions = true) -public class ImportFolder implements Callable { +@CommandDefinition(name = "import", description = "Import a folder's node graph definition from a previously exported JSON file", generateHelp = true) +public class ImportFolder implements Command { - @CommandLine.Parameters(index = "0", arity = "1", description = "path to the folder JSON file to import") + @Argument(description = "path to the folder JSON file to import", required = true) String inputPath; - @CommandLine.Option(names = {"--overwrite"}, description = "delete and recreate the folder if it already exists") - boolean overwrite = false; + @Option(name = "overwrite", acceptNameWithoutDashes = true, description = "delete and recreate the folder if it already exists", + hasValue = false, defaultValue = "false") + boolean overwrite; @Inject FolderServiceInterface folderService; @Override - public Integer call() throws Exception { + public CommandResult execute(H5mCommandInvocation invocation) throws InterruptedException { Path path = Path.of(inputPath); if (!path.toFile().exists()) { - System.err.println("File not found: " + inputPath); - return 1; + invocation.println("File not found: " + inputPath); + return CommandResult.FAILURE; } try { String folderName = folderService.importFolder(path, overwrite); - System.out.println("Imported folder '" + folderName + "' from " + path); - return 0; + invocation.println("Imported folder '" + folderName + "' from " + path); + return CommandResult.SUCCESS; } catch (Exception e) { - System.err.println("Import failed: " + e.getMessage()); - return 1; + invocation.println("Import failed: " + e.getMessage()); + return CommandResult.FAILURE; } } } diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/LegacyCmd.java b/src/main/java/io/hyperfoil/tools/h5m/cli/LegacyCmd.java new file mode 100644 index 00000000..cfe0bbab --- /dev/null +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/LegacyCmd.java @@ -0,0 +1,24 @@ +package io.hyperfoil.tools.h5m.cli; + +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; + + +@CommandDefinition( + name = "legacy", + description = "Import data from a legacy Horreum PostgreSQL database", + groupCommands = { + LoadLegacyTests.class, + LoadLegacyRuns.class, + VerifyLegacy.class, + }, + generateHelp = true +) +public class LegacyCmd implements Command { + @Override + public CommandResult execute(H5mCommandInvocation invocation) throws InterruptedException { + invocation.println("Use 'legacy '. Try 'legacy --help' for available subcommands."); + return CommandResult.SUCCESS; + } +} diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/ListCmd.java b/src/main/java/io/hyperfoil/tools/h5m/cli/ListCmd.java index 641f58e4..9ec4907a 100644 --- a/src/main/java/io/hyperfoil/tools/h5m/cli/ListCmd.java +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/ListCmd.java @@ -4,15 +4,17 @@ import io.hyperfoil.tools.jjq.value.JqNumber; import io.hyperfoil.tools.jjq.value.JqString; import io.hyperfoil.tools.yaup.AsciiArt; -import picocli.CommandLine; import java.util.*; -import java.util.concurrent.Callable; import java.util.function.Function; import java.util.stream.Stream; -@CommandLine.Command(name="list", aliases = {"show","ls"}, description = "list entities", mixinStandardHelpOptions = true, subcommands={ListFolder.class, ListNode.class, ListValue.class, ListNotification.class, ListProcessing.class}) -public class ListCmd implements Callable { +/** + * Utility class providing table formatting helpers for CLI commands. + * Previously a top-level "list" group command; now table() is used by + * entity-group subcommands (ListFolder, ListNode, ListValue, etc.). + */ +public class ListCmd { /* HTL HBH HTI HBH HTR @@ -259,23 +261,12 @@ public static List> lineSplit(List input){ return rtrn; } - - @CommandLine.Parameters(index="0",arity="0..1") - public String name; - - @Override - public Integer call() throws Exception { - CommandLine cmd = new CommandLine(this); - cmd.usage(System.out); - return 0; - } - public static String table(int maxWidth, List values, Map> columns){ return table(maxWidth,values,List.copyOf(columns.keySet()),List.copyOf(columns.values())); } public static String table(int maxWidth, List values, List headers, List> accessors){ Map table = DUCKDB_TABLE; - return table(maxWidth,values,headers,List.copyOf(accessors),H5m.consoleAttached() ? prefix(AsciiArt.ANSI_DARK_GREY,table) : table); + return table(maxWidth,values,headers,List.copyOf(accessors), prefix(AsciiArt.ANSI_DARK_GREY,table)); } //creates a text table supporting multi-line headers but not multi-line values diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/ListFolder.java b/src/main/java/io/hyperfoil/tools/h5m/cli/ListFolder.java index 1c2d8b6c..d49eba61 100644 --- a/src/main/java/io/hyperfoil/tools/h5m/cli/ListFolder.java +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/ListFolder.java @@ -1,28 +1,28 @@ package io.hyperfoil.tools.h5m.cli; import io.hyperfoil.tools.h5m.api.svc.FolderServiceInterface; -import io.hyperfoil.tools.h5m.svc.FolderService; import jakarta.inject.Inject; -import picocli.CommandLine; + +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; import java.util.ArrayList; import java.util.List; import java.util.Map; -@CommandLine.Command(name="folder", aliases={"folders"}, description = "list folders", mixinStandardHelpOptions = true) -public class ListFolder implements Runnable { - - @CommandLine.ParentCommand - ListCmd listCmd; +@CommandDefinition(name="list", aliases={"folders"}, description = "List all folders and their upload counts", generateHelp = true) +public class ListFolder implements Command { @Inject FolderServiceInterface folderService; @Override - public void run() { + public CommandResult execute(H5mCommandInvocation invocation) throws InterruptedException { Map folderCounts = folderService.getFolderUploadCount(); List names = new ArrayList<>(folderCounts.keySet()); names.sort(String.CASE_INSENSITIVE_ORDER); - System.out.println(ListCmd.table(80,names,List.of("name","uploads"), List.of(Object::toString, folderCounts::get))); + invocation.println(ListCmd.table(80,names,List.of("name","uploads"), List.of(Object::toString, folderCounts::get))); + return CommandResult.SUCCESS; } } diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/ListNode.java b/src/main/java/io/hyperfoil/tools/h5m/cli/ListNode.java index 29b9f254..b9b8e4fc 100644 --- a/src/main/java/io/hyperfoil/tools/h5m/cli/ListNode.java +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/ListNode.java @@ -3,84 +3,250 @@ import io.hyperfoil.tools.h5m.api.Node; import io.hyperfoil.tools.h5m.api.NodeGroup; import io.hyperfoil.tools.h5m.api.svc.NodeGroupServiceInterface; -import io.hyperfoil.tools.h5m.entity.NodeEntity; import jakarta.inject.Inject; -import jakarta.transaction.Transactional; + +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; +import org.aesh.command.option.Option; +import org.aesh.util.graph.Graph; +import org.aesh.util.graph.GraphNode; import org.aesh.util.graph.GraphStyle; -import picocli.CommandLine; +import org.aesh.util.tree.Tree; +import org.aesh.util.tree.TreeNode; + +import io.hyperfoil.tools.jjq.JqProgram; +import io.hyperfoil.tools.jjq.value.JqObject; +import io.hyperfoil.tools.jjq.value.JqString; +import io.hyperfoil.tools.jjq.value.JqArray; +import io.hyperfoil.tools.jjq.value.JqValue; import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.concurrent.Callable; - -import org.aesh.util.graph.Graph; -import org.aesh.util.graph.GraphNode; - -@CommandLine.Command(name="nodes", separator = " ", description = "list nodes", mixinStandardHelpOptions = true) -public class ListNode implements Callable { +import java.util.stream.Collectors; - @CommandLine.ParentCommand - ListCmd parent; +@CommandDefinition(name = "list", aliases = {"nodes"}, description = "List computation nodes in a folder with their types, operations, and source relationships", generateHelp = true) +public class ListNode implements Command { @Inject NodeGroupServiceInterface nodeGroupService; - public static enum Render {Table, Graph}; + public enum Render { Table, Graph, Tree } - @CommandLine.Option(names = {"from"},description = "group name", arity="0..1") String groupName; + @Option(name = "from", acceptNameWithoutDashes = true, description = "group name") + String groupName; - @CommandLine.Option(names = {"as"}, description = "Valid values: ${COMPLETION-CANDIDATES}\")", defaultValue = "Table") Render render; + @Option(name = "as", acceptNameWithoutDashes = true, description = "render format (Table, Graph, or Tree)", defaultValue = { "Table" }) + Render render; + + @Option(name = "filter", acceptNameWithoutDashes = true, shortName = 'f', description = "filter nodes by name (substring match)") + String filter; + + @Option(name = "depth", acceptNameWithoutDashes = true, shortName = 'd', description = "max tree depth to display (Tree mode only)", defaultValue = { "-1" }) + int depth; + + @Option(name = "jq", acceptNameWithoutDashes = true, description = "jq expression to filter nodes, e.g. select(.type == \"JQ\")") + String jqFilter; + + @Option(name = "root", acceptNameWithoutDashes = true, shortName = 'r', description = "show only the subtree rooted at this node (substring match)") + String rootNode; @Override - @Transactional - public Integer call() throws Exception { - groupName = groupName==null ? parent.name : groupName; - if(groupName == null){ - CommandLine cmd = new CommandLine(this); - cmd.usage(System.err); - return 1; + public CommandResult execute(H5mCommandInvocation invocation) throws InterruptedException { + return doExecute(invocation); + } + + CommandResult doExecute(H5mCommandInvocation invocation) { + if (groupName == null && invocation.hasFolderContext()) + groupName = invocation.getFolderName(); + if (groupName == null) { + invocation.println("group name is required (use --from)"); + return CommandResult.FAILURE; } NodeGroup nodeGroup = nodeGroupService.byName(groupName); - if(nodeGroup == null){ - System.err.println("NodeEntity group "+groupName+" not found"); - return 1; + if (nodeGroup == null) { + invocation.println("Node group " + groupName + " not found"); + return CommandResult.FAILURE; } - if(render.equals(Render.Graph)){ + if (render == null) + render = Render.Table; + + List sources = nodeGroup.sources(); + + if (rootNode != null && !rootNode.isEmpty()) { + List matches = sources.stream() + .filter(n -> n.name().contains(rootNode) || n.fqdn().contains(rootNode)) + .collect(Collectors.toList()); + if (matches.isEmpty()) { + invocation.println("No node matching '" + rootNode + "' found"); + return CommandResult.FAILURE; + } else if (matches.size() > 1) { + invocation.println("Multiple nodes match '" + rootNode + "':"); + matches.forEach(n -> invocation.println(" " + n.fqdn())); + return CommandResult.FAILURE; + } + Node match = matches.getFirst(); + if (render.equals(Render.Tree)) { + TreeNode treeRoot = TreeNode.of(match.name() + " [" + match.type().display() + "]"); + Map nodes = new HashMap<>(); + nodes.put(match, treeRoot); + for (Node source : match.sources()) { + walkTree(source, nodes); + } + invocation.println(Tree.builder() + .label(TreeNode::label) + .children(TreeNode::children) + .maxDepth(depth) + .build() + .render(treeRoot)); + } else if (render.equals(Render.Graph)) { + GraphNode graphRoot = GraphNode.of(match.name()); + Map nodes = new HashMap<>(); + nodes.put(match, graphRoot); + for (Node source : match.sources()) { + walkGraph(source, nodes); + } + invocation.println(Graph.render(graphRoot, GraphStyle.ROUNDED)); + } else { + invocation.println( + ListCmd.table(80, match.sources(), List.of("name", "type", "fqdn", "operation"), + List.of(Node::name, + n -> n.type().display(), + Node::fqdn, + Node::operation))); + } + return CommandResult.SUCCESS; + } + + if (filter != null && !filter.isEmpty()) { + sources = sources.stream() + .filter(n -> n.name().contains(filter) || n.fqdn().contains(filter)) + .collect(Collectors.toList()); + } + if (jqFilter != null && !jqFilter.isEmpty()) { + try { + JqProgram program = JqProgram.compile(jqFilter); + sources = sources.stream() + .filter(n -> { + JqObject.Builder builder = JqObject.builder(); + builder.put("name", n.name()); + builder.put("fqdn", n.fqdn()); + builder.put("type", n.type().display()); + builder.put("operation", n.operation() != null ? n.operation() : ""); + JqValue[] sourceNames = n.sources() != null + ? n.sources().stream().map(s -> (JqValue) JqString.of(s.name())).toArray(JqValue[]::new) + : new JqValue[0]; + builder.put("sources", JqArray.of(sourceNames)); + JqObject json = builder.build(); + List result = program.applyAll(json); + return !result.isEmpty() && !result.getFirst().isNull() + && !(result.getFirst().isBoolean() && !result.getFirst().asBoolean(false)); + }) + .collect(Collectors.toList()); + } catch (Exception e) { + invocation.println("Invalid jq expression: " + e.getMessage()); + return CommandResult.FAILURE; + } + } + + boolean filtered = (filter != null && !filter.isEmpty()) || (jqFilter != null && !jqFilter.isEmpty()); + + if (render.equals(Render.Graph)) { GraphNode rootNode = GraphNode.of("root"); - Map nodes = new HashMap<>(); + Map nodes = new HashMap<>(); nodes.put(nodeGroup.root(), rootNode); - for(Node source: nodeGroup.sources()){ - walk(source,nodes); + for (Node source : sources) { + walkGraph(source, nodes); + } + invocation.println(Graph.render(rootNode, GraphStyle.ROUNDED)); + } else if (render.equals(Render.Tree)) { + if (filtered) { + // Build a set of filtered node names for lookup + java.util.Set filteredNames = sources.stream() + .map(Node::name) + .collect(Collectors.toSet()); + // Collect all source nodes referenced by filtered nodes that aren't in the filtered set + // These are the "context" parents that should appear at the top + Map sourceRoots = new HashMap<>(); + TreeNode treeRoot = TreeNode.of(groupName); + for (Node node : sources) { + boolean addedUnderParent = false; + if (node.sources() != null) { + for (Node parent : node.sources()) { + if (!filteredNames.contains(parent.name())) { + TreeNode parentTree = sourceRoots.computeIfAbsent(parent.name(), + name -> { + TreeNode pn = TreeNode.of(name + " [" + parent.type().display() + "]"); + treeRoot.child(pn); + return pn; + }); + parentTree.child(node.name() + " [" + node.type().display() + "]"); + addedUnderParent = true; + } + } + } + if (!addedUnderParent) { + treeRoot.child(node.name() + " [" + node.type().display() + "]"); + } } - System.out.println(Graph.render(rootNode, GraphStyle.ROUNDED)); - }else { - System.out.println( - ListCmd.table(80,nodeGroup.sources(),List.of("name","type","fqdn","operation"), - List.of(Node::name, - n->n.type().display(), - Node::fqdn, - Node::operation - ) - ) - ); + invocation.println(Tree.builder() + .label(TreeNode::label) + .children(TreeNode::children) + .maxDepth(depth) + .build() + .render(treeRoot)); + } else { + TreeNode rootNode = TreeNode.of(groupName); + Map nodes = new HashMap<>(); + nodes.put(nodeGroup.root(), rootNode); + for (Node source : sources) { + walkTree(source, nodes); + } + invocation.println(Tree.builder() + .label(TreeNode::label) + .children(TreeNode::children) + .maxDepth(depth) + .build() + .render(rootNode)); + } + } else { + invocation.println( + ListCmd.table(80, sources, List.of("name", "type", "fqdn", "operation"), + List.of(Node::name, + n -> n.type().display(), + Node::fqdn, + Node::operation))); } - return 0; + return CommandResult.SUCCESS; } - public GraphNode walk(Node node, Map nodes){ - if(nodes.containsKey(node)){ + public GraphNode walkGraph(Node node, Map nodes) { + if (nodes.containsKey(node)) { return nodes.get(node); - }else{ + } else { GraphNode rtrn = GraphNode.of(node.name()); nodes.put(node, rtrn); - for(Node source: node.sources()){ - GraphNode fromSource = walk(source,nodes); - fromSource.child(rtrn); - } + for (Node source : node.sources()) { + GraphNode fromSource = walkGraph(source, nodes); + fromSource.child(rtrn); + } return rtrn; } } + public TreeNode walkTree(Node node, Map nodes) { + if (nodes.containsKey(node)) { + return nodes.get(node); + } else { + TreeNode rtrn = TreeNode.of(node.name() + " [" + node.type().display() + "]"); + nodes.put(node, rtrn); + for (Node source : node.sources()) { + TreeNode fromSource = walkTree(source, nodes); + fromSource.child(rtrn); + } + return rtrn; + } + } } diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/ListNotification.java b/src/main/java/io/hyperfoil/tools/h5m/cli/ListNotification.java index c665d9f9..808cde3c 100644 --- a/src/main/java/io/hyperfoil/tools/h5m/cli/ListNotification.java +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/ListNotification.java @@ -4,57 +4,62 @@ import io.hyperfoil.tools.h5m.api.svc.FolderServiceInterface; import io.hyperfoil.tools.h5m.entity.NotificationConfig; import jakarta.inject.Inject; -import picocli.CommandLine; + +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; +import org.aesh.command.option.Argument; import java.util.List; -import java.util.concurrent.Callable; -@CommandLine.Command(name = "notification", aliases = {"notifications"}, separator = " ", - description = "list notification configs for a folder", - mixinStandardHelpOptions = true) -public class ListNotification implements Callable { +@CommandDefinition(name = "list", description = "List notification configurations for a folder", generateHelp = true) +public class ListNotification implements Command { - @CommandLine.Parameters(index = "0", arity = "0..1", description = "folder name") + @Argument(description = "folder name", completer = FolderCompleter.class) String folderName; @Inject FolderServiceInterface folderService; @Override - public Integer call() throws Exception { + public CommandResult execute(H5mCommandInvocation invocation) throws InterruptedException { + if (folderName == null && invocation.hasFolderContext()) { + folderName = invocation.getFolderName(); + } + if (folderName == null) { List all = NotificationConfig.listAll(); if (all.isEmpty()) { - System.out.println("No notification configs found."); + invocation.println("No notification configs found."); } else { - printConfigs(all); + printConfigs(invocation, all); } - return 0; + return CommandResult.SUCCESS; } Folder folder = folderService.byName(folderName); if (folder == null) { - System.err.println("Folder not found: " + folderName); - return 1; + invocation.println("Folder not found: " + folderName); + return CommandResult.FAILURE; } List configs = NotificationConfig.find("folder.id", folder.id()).list(); if (configs.isEmpty()) { - System.out.println("No notification configs for " + folderName); + invocation.println("No notification configs for " + folderName); } else { - printConfigs(configs); + printConfigs(invocation, configs); } - return 0; + return CommandResult.SUCCESS; } - private void printConfigs(List configs) { - System.out.printf("%-6s %-20s %-14s %-8s %-30s %s%n", "ID", "Folder", "Method", "Enabled", "Data", "Template"); - System.out.println("-".repeat(100)); + private void printConfigs(H5mCommandInvocation invocation, List configs) { + invocation.println(String.format("%-6s %-20s %-14s %-8s %-30s %s", "ID", "Folder", "Method", "Enabled", "Data", "Template")); + invocation.println("-".repeat(100)); for (NotificationConfig config : configs) { String folderDisplay = config.folder != null ? config.folder.name : "?"; String templateDisplay = config.template != null ? config.template : "(default)"; - System.out.printf("%-6d %-20s %-14s %-8s %-30s %s%n", - config.id, folderDisplay, config.method.label(), config.enabled, config.data, templateDisplay); + invocation.println(String.format("%-6d %-20s %-14s %-8s %-30s %s", + config.id, folderDisplay, config.method.label(), config.enabled, config.data, templateDisplay)); } } } diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/ListProcessing.java b/src/main/java/io/hyperfoil/tools/h5m/cli/ListProcessing.java index cfe77652..b6123099 100644 --- a/src/main/java/io/hyperfoil/tools/h5m/cli/ListProcessing.java +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/ListProcessing.java @@ -3,29 +3,30 @@ import io.hyperfoil.tools.h5m.entity.ProcessingTrackerEntity; import io.hyperfoil.tools.h5m.svc.ProcessingService; import jakarta.inject.Inject; -import jakarta.persistence.EntityManager; -import picocli.CommandLine; + +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; import java.util.List; -import java.util.concurrent.Callable; -@CommandLine.Command(name="processing", separator = " ", description = "list incomplete processing events", mixinStandardHelpOptions = true ) -public class ListProcessing implements Callable { +@CommandDefinition(name = "list-processing", description = "list incomplete processing events", generateHelp = true) +public class ListProcessing implements Command { @Inject ProcessingService processingService; @Override - public Integer call() throws Exception { + public CommandResult execute(H5mCommandInvocation invocation) throws InterruptedException { List incomplete = processingService.getIncompleteProcessing(); - System.out.println( - ListCmd.table(80,incomplete,List.of("folderId","referenceId","created"), + invocation.println( + ListCmd.table(80, incomplete, List.of("folderId", "referenceId", "created"), List.of( - e->e.folderId, - e->e.referenceId, - e->e.createdAt + e -> e.folderId, + e -> e.referenceId, + e -> e.createdAt )) ); - return 0; + return CommandResult.SUCCESS; } } diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/ListValue.java b/src/main/java/io/hyperfoil/tools/h5m/cli/ListValue.java index bb199a10..a97e17a2 100644 --- a/src/main/java/io/hyperfoil/tools/h5m/cli/ListValue.java +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/ListValue.java @@ -12,18 +12,18 @@ import io.hyperfoil.tools.h5m.api.svc.NodeServiceInterface; import io.hyperfoil.tools.h5m.api.svc.ValueServiceInterface; import jakarta.inject.Inject; -import jakarta.transaction.Transactional; -import picocli.CommandLine; + +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; +import org.aesh.command.option.Option; + import java.util.*; -import java.util.concurrent.Callable; import java.util.function.Function; -@CommandLine.Command(name="value", aliases = {"values"}, separator = " ", description = "list values", sortOptions = false, mixinStandardHelpOptions = true) -public class ListValue implements Callable { - - @CommandLine.ParentCommand - ListCmd parent; +@CommandDefinition(name="values", description = "List computed values in a folder, optionally grouped by a node. Default limit: 50 results", generateHelp = true) +public class ListValue implements Command { @Inject NodeServiceInterface nodeService; @@ -36,15 +36,18 @@ public class ListValue implements Callable { public enum Format { raw, table } - @CommandLine.Option(names = {"as"},description = "presentation option: ${COMPLETION-CANDIDATES}", arity = "0..1", order=3,defaultValue = "raw") + @Option(name = "as", acceptNameWithoutDashes = true, description = "presentation option (raw or table)", defaultValue = {"raw"}) Format format; - @CommandLine.Option(names = {"by"},description = "grouping node" ,arity = "0..1", order = 2) + @Option(name = "by", acceptNameWithoutDashes = true, description = "grouping node") public String groupBy; - @CommandLine.Option(names = {"from"},description = "group name" ,arity = "0..1", order = 1) + @Option(name = "from", acceptNameWithoutDashes = true, description = "group name") public String groupName; + @Option(name = "limit", acceptNameWithoutDashes = true, shortName = 'l', description = "maximum number of results to display", defaultValue = { "50" }) + int limit; + public ListValue() {} public ListValue(String groupName,String groupBy) { @@ -53,37 +56,43 @@ public ListValue(String groupName,String groupBy) { } @Override - @Transactional - public Integer call() throws Exception { - groupName = groupName==null ? parent.name: groupName; + public CommandResult execute(H5mCommandInvocation invocation) throws InterruptedException { + return doExecute(invocation); + } + + CommandResult doExecute(H5mCommandInvocation invocation) throws InterruptedException { + if (groupName == null && invocation.hasFolderContext()) groupName = invocation.getFolderName(); if(groupName==null){ - CommandLine cmd = new CommandLine(this); - cmd.usage(System.err); - return 1; + invocation.println("group name is required (use --from)"); + return CommandResult.FAILURE; } NodeGroup nodeGroup = nodeGroupService.byName(groupName); if(nodeGroup == null){ - System.err.println("NodeEntity group "+groupName+" not found"); - return 1; + invocation.println("Node group " + groupName + " not found"); + return CommandResult.FAILURE; } if(groupBy!=null){ List foundNodes = nodeService.findNodeByFqdn(groupBy,nodeGroup.id()); if(foundNodes.isEmpty()){ - System.err.println(groupBy+" not found"); - return 1; + invocation.println(groupBy+" not found"); + return CommandResult.FAILURE; }else if (foundNodes.size()>1){ - System.err.println(groupBy+" is ambiguous, matched the following nodes:"); + invocation.println(groupBy+" is ambiguous, matched the following nodes:"); for(int i=0;i jsons = valueService.getGroupedValues(foundNode.id()); + int totalCount = jsons.size(); + if (limit > 0 && jsons.size() > limit) { + jsons = jsons.subList(0, limit); + } if(Format.raw.equals(format)){ - System.out.println("Count: " + jsons.size()); - System.out.println(ListCmd.table(80, jsons, + invocation.println("Count: " + totalCount + (limit > 0 ? " (showing " + jsons.size() + ")" : "")); + invocation.println(ListCmd.table(80, jsons, List.of("data"), List.of(JqValue::toJsonString))); }else{ @@ -111,15 +120,20 @@ public Integer call() throws Exception { return found.toJsonString(); } }).toList(); - System.out.println("Count: " + jsons.size()); - System.out.println(ListCmd.table(80, jsons, keyList, accessors)); + invocation.println("Count: " + totalCount + (limit > 0 ? " (showing " + jsons.size() + ")" : "")); + invocation.println(ListCmd.table(80, jsons, keyList, accessors)); } } }else { + if (Thread.interrupted()) throw new InterruptedException("List values interrupted"); List values = valueService.getNodeDescendantValues(nodeGroup.root().id()); - System.out.println("Count: " + values.size()); - System.out.println(ListCmd.table(80, values, + int totalCount = values.size(); + if (limit > 0 && values.size() > limit) { + values = values.subList(0, limit); + } + invocation.println("Count: " + totalCount + (limit > 0 ? " (showing " + values.size() + ")" : "")); + invocation.println(ListCmd.table(80, values, List.of("id", "data", "node.id"), List.of(v -> v.id(), v -> { JqValue found = v.data(); @@ -135,6 +149,6 @@ public Integer call() throws Exception { }, v -> v.node().id()))); } - return 0; + return CommandResult.SUCCESS; } } diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/LoadLegacyRuns.java b/src/main/java/io/hyperfoil/tools/h5m/cli/LoadLegacyRuns.java index 65dbdc1d..55c4e967 100644 --- a/src/main/java/io/hyperfoil/tools/h5m/cli/LoadLegacyRuns.java +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/LoadLegacyRuns.java @@ -5,11 +5,15 @@ import io.agroal.api.AgroalDataSource; import io.agroal.api.configuration.supplier.AgroalPropertiesReader; import io.hyperfoil.tools.h5m.api.Folder; -import io.hyperfoil.tools.h5m.entity.FolderEntity; import io.hyperfoil.tools.h5m.svc.FolderService; import io.hyperfoil.tools.h5m.svc.WorkService; import jakarta.inject.Inject; -import picocli.CommandLine; + +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; +import org.aesh.command.option.Option; + import java.sql.Connection; import java.sql.PreparedStatement; @@ -17,16 +21,14 @@ import java.sql.Statement; import java.util.ArrayList; import java.util.HashMap; -import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Scanner; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; -import java.util.concurrent.Callable; -@CommandLine.Command(name="load-legacy-runs") -public class LoadLegacyRuns implements Callable { +@CommandDefinition(name = "load-runs", description = "Import run data from a legacy Horreum PostgreSQL database and process through the node graph", generateHelp = true) +public class LoadLegacyRuns implements Command { @Inject FolderService folderService; @@ -34,45 +36,67 @@ public class LoadLegacyRuns implements Callable { @Inject WorkService workService; - @CommandLine.Option(names = {"username"}, description = "legacy db username", defaultValue = "quarkus") String username; - @CommandLine.Option(names = {"password"}, description = "legacy db password", defaultValue = "quarkus") String password; - @CommandLine.Option(names = {"url"}, description = "legacy connection url",defaultValue = "jdbc:postgresql://0.0.0.0:") String url; - @CommandLine.Option(names = {"testId"}, description = "specify which test(s) to load. Comma-separated for multiple. Loads all if unspecified", split = ",") List testId; - @CommandLine.Option(names = {"limit"}, description = "max runs to load", defaultValue = "-1") int limit; - @CommandLine.Option(names = {"offset"}, description = "how many runs to skip ", defaultValue = "-1") int offset; - @CommandLine.Option(names = {"batch"}, description = "max runs to batch at once", defaultValue = "-1") int batch; - @CommandLine.Option(names = {"pause"}, description = "pause for user input after every batch", defaultValue = "false") boolean pause; + @Option(name = "username", acceptNameWithoutDashes = true, description = "legacy db username", defaultValue = "quarkus") + String username; + + @Option(name = "password", acceptNameWithoutDashes = true, description = "legacy db password", defaultValue = "quarkus") + String password; + + @Option(name = "url", acceptNameWithoutDashes = true, description = "legacy connection url", defaultValue = "jdbc:postgresql://0.0.0.0:") + String url; + + @Option(name = "testId", acceptNameWithoutDashes = true, description = "specify which test to load. Loads all if unspecified") + Long testId; + + @Option(name = "limit", acceptNameWithoutDashes = true, description = "max runs to load", defaultValue = "-1") + int limit; + + @Option(name = "offset", acceptNameWithoutDashes = true, description = "how many runs to skip", defaultValue = "-1") + int offset; + + @Option(name = "batch", acceptNameWithoutDashes = true, description = "max runs to batch at once", defaultValue = "-1") + int batch; + + @Option(name = "pause", acceptNameWithoutDashes = true, description = "pause for user input after every batch", hasValue = false, defaultValue = "false") + boolean pause; @Override - public Integer call() throws Exception { + public CommandResult execute(H5mCommandInvocation invocation) throws InterruptedException { + try { + return doExecute(invocation); + } catch (Exception e) { + invocation.println("Error: " + e.getMessage()); + return CommandResult.FAILURE; + } + } + + private CommandResult doExecute(H5mCommandInvocation invocation) throws Exception { Map props = new HashMap<>(); props.put(AgroalPropertiesReader.MAX_SIZE, "1"); props.put(AgroalPropertiesReader.MIN_SIZE, "1"); props.put(AgroalPropertiesReader.INITIAL_SIZE, "1"); props.put(AgroalPropertiesReader.MAX_LIFETIME_S, "57"); props.put(AgroalPropertiesReader.ACQUISITION_TIMEOUT_S, "54"); - props.put(AgroalPropertiesReader.PRINCIPAL,username); //username - props.put(AgroalPropertiesReader.CREDENTIAL,password);//password - props.put(AgroalPropertiesReader.PROVIDER_CLASS_NAME , "org.postgresql.Driver"); - props.put(AgroalPropertiesReader.JDBC_URL, url ); - AgroalDataSource ds = AgroalDataSource.from(new AgroalPropertiesReader() + props.put(AgroalPropertiesReader.PRINCIPAL, username); + props.put(AgroalPropertiesReader.CREDENTIAL, password); + props.put(AgroalPropertiesReader.PROVIDER_CLASS_NAME, "org.postgresql.Driver"); + props.put(AgroalPropertiesReader.JDBC_URL, url); + AgroalDataSource ds = AgroalDataSource.from(new AgroalPropertiesReader() .readProperties(props) .get()); - Map tests = new LinkedHashMap<>(); - try(Connection connection = ds.getConnection()){ - if(testId!=null && !testId.isEmpty()){ - for (Long id : testId) { - try(PreparedStatement statement = connection.prepareStatement("select name from test where id = ?")){ - statement.setLong(1, id); - try (ResultSet rs = statement.executeQuery()){ - while(rs.next()){ - tests.put(id, rs.getString("name")); - } + Map tests = new HashMap<>(); + try (Connection connection = ds.getConnection()) { + if (testId != null && testId > -1) { + try (PreparedStatement statement = connection.prepareStatement("select name from test where id = ?")) { + statement.setLong(1, testId); + try (ResultSet rs = statement.executeQuery()) { + while (rs.next()) { + tests.put(testId, rs.getString("name")); } } } - }else { + } else { try (Statement statement = connection.createStatement()) { try (ResultSet rs = statement.executeQuery("select id,name from test")) { while (rs.next()) { @@ -81,98 +105,89 @@ public Integer call() throws Exception { } } } - System.out.println("loaded "+tests.size()+" legacy tests"); - for(Long testId : tests.keySet()){ + invocation.println("loaded " + tests.size() + " legacy tests"); + for (Long testId : tests.keySet()) { String name = tests.get(testId); Folder folder = folderService.byName(name); - if(folder == null){ - System.out.println("Failed to find Folder for test "+name+" id="+testId); + if (folder == null) { + invocation.println("Failed to find Folder for test " + name + " id=" + testId); continue; } - // Phase 1: Fetch run IDs (lightweight, no large data transfer) - String idQuery = "select id from run where testid = ? and trashed = false order by id desc"; - if (limit > 0) idQuery += " limit ?"; - if (offset > 0) idQuery += " offset ?"; - List runIds = new ArrayList<>(); - try (PreparedStatement ps = connection.prepareStatement(idQuery)) { + try (PreparedStatement ps = connection.prepareStatement("select count(id) from run where testid = ? and trashed = false")) { ps.setLong(1, testId); - int paramIdx = 2; - if (limit > 0) ps.setInt(paramIdx++, limit); - if (offset > 0) ps.setInt(paramIdx, offset); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { - runIds.add(rs.getLong(1)); + invocation.println("loading " + rs.getLong(1) + " uploads to " + name); } } } - System.out.println("loading " + runIds.size() + " uploads to " + name); - - // Phase 2: Process in batches, fetching data per batch - int batchSize = batch > 0 ? batch : runIds.size(); - int count = 0; - Scanner scanner = new Scanner(System.in); - for (int batchStart = 0; batchStart < runIds.size(); batchStart += batchSize) { - if (Thread.interrupted()) throw new InterruptedException("Import interrupted"); - - int batchEnd = Math.min(batchStart + batchSize, runIds.size()); - List batchIds = runIds.subList(batchStart, batchEnd); - - // Fetch data for this batch only (short-lived query, no long cursor) - String placeholders = String.join(",", batchIds.stream().map(id -> "?").toList()); - List> batchFutures = new ArrayList<>(); - try (PreparedStatement ps = connection.prepareStatement( - "select id, data from run where id in (" + placeholders + ")")) { - for (int i = 0; i < batchIds.size(); i++) { - ps.setLong(i + 1, batchIds.get(i)); + String runQuery = limit > 0 + ? "select id,data from run where testid = ? and trashed = false order by id desc limit ?" + : "select id,data from run where testid = ? and trashed = false order by id desc"; + if (offset > 0) { + runQuery += " offset ?"; + } + connection.setAutoCommit(false); + try (PreparedStatement ps = connection.prepareStatement(runQuery)) { + ps.setFetchSize(5); + ps.setLong(1, testId); + if (limit > 0) ps.setInt(2, limit); + if (offset > 0) { + if (limit > 0) { + ps.setInt(3, offset); + } else { + ps.setInt(2, limit); } - try (ResultSet rs = ps.executeQuery()) { - while (rs.next()) { - long id = rs.getLong(1); - System.out.println(name + " " + id); - // Parse directly from bytes — avoids UTF-8→char decoding, - // StringBuilder doubling, and String copy that the previous - // getCharacterStream() path required. - byte[] bytes = rs.getBytes("data"); - JqValue data = JqValues.parse(bytes); - batchFutures.add(folderService.upload(folder.name(), data).future); - count++; + } + int count = 0; + int batchCount = 0; + Scanner scanner = new Scanner(System.in); + List> batchFutures = new ArrayList<>(); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + if (Thread.interrupted()) throw new InterruptedException("Import interrupted"); + Long id = rs.getLong(1); + invocation.println(name + " " + id); + java.io.Reader reader = rs.getCharacterStream("data"); + StringBuilder sb = new StringBuilder(); + char[] buf = new char[8192]; + int charsRead; + while ((charsRead = reader.read(buf)) != -1) { + sb.append(buf, 0, charsRead); + } + JqValue data = JqValues.parse(sb.toString()); + batchFutures.add(folderService.upload(folder.name(), data).future); + count++; + batchCount++; + if (batch > 0 && batchCount >= batch) { + invocation.println("waiting for batch of " + batchCount + " to complete"); + CompletableFuture.allOf(batchFutures.toArray(new CompletableFuture[0])) + .orTimeout(10, TimeUnit.MINUTES) + .join(); + invocation.println("batch complete"); + batchFutures.clear(); + if (pause) { + scanner.nextLine(); + } + batchCount = 0; } } } - - // Wait for this batch to complete before fetching next + // Wait for any remaining uploads if (!batchFutures.isEmpty()) { - System.out.println("waiting for batch of " + batchFutures.size() + " to complete"); + invocation.println("waiting for final " + batchFutures.size() + " uploads to complete"); CompletableFuture.allOf(batchFutures.toArray(new CompletableFuture[0])) .orTimeout(10, TimeUnit.MINUTES) .join(); - System.out.println("batch complete"); - } - - if (pause) { - scanner.nextLine(); } + invocation.println("loaded " + count + " runs"); + } finally { + connection.setAutoCommit(true); } - System.out.println("loaded " + count + " runs"); } } finally { ds.close(); } - // Wait for the work queue to drain — async cascade workers may still be - // processing after the upload futures complete. Without this, the CLI - // process exits and CDI context is destroyed while workers are active. - System.out.println("waiting for work queue to drain..."); - long deadline = System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(10); - int stableChecks = 0; - while (stableChecks < 5 && System.currentTimeMillis() < deadline) { - if (workService.isIdle()) { - stableChecks++; - } else { - stableChecks = 0; - } - try { Thread.sleep(200); } catch (InterruptedException e) { break; } - } - System.out.println("work queue drained"); - return 0; + return CommandResult.SUCCESS; } } diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/LoadLegacyTests.java b/src/main/java/io/hyperfoil/tools/h5m/cli/LoadLegacyTests.java index e67a1a44..9a9fc3b8 100644 --- a/src/main/java/io/hyperfoil/tools/h5m/cli/LoadLegacyTests.java +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/LoadLegacyTests.java @@ -20,20 +20,31 @@ import io.hyperfoil.tools.yaup.HashedSets; import io.hyperfoil.tools.yaup.StringUtil; import jakarta.inject.Inject; -import picocli.CommandLine; + +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; +import org.aesh.command.option.Option; + import java.sql.*; import java.util.*; -import java.util.concurrent.Callable; import java.util.stream.Collectors; -@CommandLine.Command(name="load-legacy-tests") -public class LoadLegacyTests implements Callable { +@CommandDefinition(name = "load-tests", description = "Import test definitions (folder + node graph) from a legacy Horreum PostgreSQL database", generateHelp = true) +public class LoadLegacyTests implements Command { + + @Option(name = "username", acceptNameWithoutDashes = true, description = "legacy db username", defaultValue = "quarkus") + String username; + + @Option(name = "password", acceptNameWithoutDashes = true, description = "legacy db password", defaultValue = "quarkus") + String password; - @CommandLine.Option(names = {"username"}, description = "legacy db username", defaultValue = "quarkus") String username; - @CommandLine.Option(names = {"password"}, description = "legacy db password", defaultValue = "quarkus") String password; - @CommandLine.Option(names = {"url"}, description = "legacy connection url",defaultValue = "jdbc:postgresql://0.0.0.0:5432/horreum") String url; - @CommandLine.Option(names = {"testId"}, description = "specify which test to load. Loads all if unspecified" ) Long testId; + @Option(name = "url", acceptNameWithoutDashes = true, description = "legacy connection url", defaultValue = "jdbc:postgresql://0.0.0.0:5432/horreum") + String url; + + @Option(name = "testId", acceptNameWithoutDashes = true, description = "specify which test to load. Loads all if unspecified") + Long testId; public static String printTest(Test t){ StringBuilder sb = new StringBuilder(); @@ -599,7 +610,16 @@ public FolderImportResult createFolder(Test test){ } @Override - public Integer call() throws Exception { + public CommandResult execute(H5mCommandInvocation invocation) throws InterruptedException { + try { + return doExecute(invocation); + } catch (Exception e) { + log("Error: " + e.getMessage()); + return CommandResult.FAILURE; + } + } + + private CommandResult doExecute(H5mCommandInvocation invocation) throws Exception { Map props = new HashMap<>(); props.put(AgroalPropertiesReader.MAX_SIZE, "1"); props.put(AgroalPropertiesReader.MIN_SIZE, "1"); @@ -900,7 +920,7 @@ IF jsonb_typeof(value) IN ('array', 'object') THEN finally { ds.close(); } - return 0; + return CommandResult.SUCCESS; } /** diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/NodeAddCmd.java b/src/main/java/io/hyperfoil/tools/h5m/cli/NodeAddCmd.java new file mode 100644 index 00000000..c7304606 --- /dev/null +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/NodeAddCmd.java @@ -0,0 +1,29 @@ +package io.hyperfoil.tools.h5m.cli; + +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; + +@CommandDefinition( + name = "add", + description = "Add a new computation node to a folder", + groupCommands = { + AddJq.class, + AddJs.class, + AddJsonata.class, + AddSplit.class, + AddFingerprint.class, + AddFixedThreshold.class, + AddRelativeDifference.class, + AddStdDevAnomaly.class, + AddEDivisive.class, + }, + generateHelp = true +) +public class NodeAddCmd implements Command { + @Override + public CommandResult execute(H5mCommandInvocation invocation) throws InterruptedException { + invocation.println("Use 'node add '. Types: jq, js, jsonata, split, fingerprint, fixedthreshold, relativedifference, stddev, edivisive"); + return CommandResult.SUCCESS; + } +} diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/NodeCmd.java b/src/main/java/io/hyperfoil/tools/h5m/cli/NodeCmd.java new file mode 100644 index 00000000..7f652f3d --- /dev/null +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/NodeCmd.java @@ -0,0 +1,25 @@ +package io.hyperfoil.tools.h5m.cli; + +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; + + +@CommandDefinition( + name = "node", + description = "Node management: add computation nodes (jq, js, jsonata, etc.), list, remove, and update", + groupCommands = { + NodeAddCmd.class, + ListNode.class, + RemoveNode.class, + UpdateNode.class, + }, + generateHelp = true +) +public class NodeCmd implements Command { + @Override + public CommandResult execute(H5mCommandInvocation invocation) throws InterruptedException { + invocation.println("Use 'node '. Try 'node --help' for available subcommands."); + return CommandResult.SUCCESS; + } +} diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/NodeNameCompleter.java b/src/main/java/io/hyperfoil/tools/h5m/cli/NodeNameCompleter.java new file mode 100644 index 00000000..8d878d71 --- /dev/null +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/NodeNameCompleter.java @@ -0,0 +1,69 @@ +package io.hyperfoil.tools.h5m.cli; + +import java.util.List; + +import org.aesh.command.completer.CompleterInvocation; +import org.aesh.command.completer.OptionCompleter; + +import io.hyperfoil.tools.h5m.api.Node; +import io.hyperfoil.tools.h5m.api.NodeGroup; +import io.hyperfoil.tools.h5m.api.svc.NodeGroupServiceInterface; +import io.quarkus.arc.Arc; + +/** + * Completer that suggests node names from the current folder context. + * Used by view update --add/--remove and other commands that accept node names. + */ +public class NodeNameCompleter implements OptionCompleter { + + @Override + public void complete(CompleterInvocation completerInvocation) { + String input = completerInvocation.getGivenCompleteValue(); + + String folderName = getFolderName(completerInvocation); + if (folderName == null) { + return; + } + + try { + NodeGroupServiceInterface nodeGroupService = Arc.container().instance(NodeGroupServiceInterface.class).get(); + NodeGroup nodeGroup = nodeGroupService.byName(folderName); + if (nodeGroup == null || nodeGroup.sources() == null) { + return; + } + + List nodeNames = nodeGroup.sources().stream() + .filter(n -> n.name() != null && !n.name().isEmpty()) + .map(Node::name) + .filter(name -> input == null || input.isEmpty() || name.startsWith(input)) + .sorted() + .toList(); + + completerInvocation.addAllCompleterValues(nodeNames); + } catch (Exception e) { + // Silently ignore completion errors + } + } + + private String getFolderName(CompleterInvocation completerInvocation) { + // Try to get folder name from the command's option + var command = completerInvocation.getCommand(); + if (command instanceof ViewUpdateCmd viewUpdate) { + if (viewUpdate.folderName != null) return viewUpdate.folderName; + } + if (command instanceof ViewShowCmd viewShow) { + if (viewShow.folderName != null) return viewShow.folderName; + } + if (command instanceof RunShowCmd runShow) { + if (runShow.folderName != null) return runShow.folderName; + } + + // Try folder context + FolderContext folderContext = Arc.container().instance(FolderContext.class).get(); + if (folderContext != null && folderContext.isSet()) { + return folderContext.getFolderName(); + } + + return null; + } +} diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/NotificationCmd.java b/src/main/java/io/hyperfoil/tools/h5m/cli/NotificationCmd.java new file mode 100644 index 00000000..804b49c6 --- /dev/null +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/NotificationCmd.java @@ -0,0 +1,24 @@ +package io.hyperfoil.tools.h5m.cli; + +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; + + +@CommandDefinition( + name = "notification", + description = "Notification configuration management for change detection events", + groupCommands = { + AddNotification.class, + ListNotification.class, + RemoveNotification.class, + }, + generateHelp = true +) +public class NotificationCmd implements Command { + @Override + public CommandResult execute(H5mCommandInvocation invocation) throws InterruptedException { + invocation.println("Use 'notification '. Try 'notification --help' for available subcommands."); + return CommandResult.SUCCESS; + } +} diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/PurgeValuesCmd.java b/src/main/java/io/hyperfoil/tools/h5m/cli/PurgeValuesCmd.java new file mode 100644 index 00000000..269e2c20 --- /dev/null +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/PurgeValuesCmd.java @@ -0,0 +1,22 @@ +package io.hyperfoil.tools.h5m.cli; + +import jakarta.inject.Inject; + +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; +import io.hyperfoil.tools.h5m.api.svc.ValueServiceInterface; + +@CommandDefinition(name = "purge", description = "Delete all computed values from the database", generateHelp = true) +public class PurgeValuesCmd implements Command { + + @Inject + ValueServiceInterface valueService; + + @Override + public CommandResult execute(H5mCommandInvocation invocation) throws InterruptedException { + valueService.purgeValues(); + invocation.println("All values purged"); + return CommandResult.SUCCESS; + } +} diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/RecalculateCmd.java b/src/main/java/io/hyperfoil/tools/h5m/cli/RecalculateCmd.java new file mode 100644 index 00000000..297e866b --- /dev/null +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/RecalculateCmd.java @@ -0,0 +1,65 @@ +package io.hyperfoil.tools.h5m.cli; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import jakarta.inject.Inject; + +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; +import org.aesh.command.option.Argument; + +import io.hyperfoil.tools.h5m.api.Node; +import io.hyperfoil.tools.h5m.api.NodeGroup; +import io.hyperfoil.tools.h5m.api.svc.FolderServiceInterface; +import io.hyperfoil.tools.h5m.api.svc.NodeGroupServiceInterface; +import io.hyperfoil.tools.h5m.svc.RecalculationTracker; + +@CommandDefinition(name = "recalculate", description = "Recalculate all computed values in a folder by reprocessing through the node graph", generateHelp = true) +public class RecalculateCmd implements Command { + + @Inject + FolderServiceInterface folderService; + + @Inject + NodeGroupServiceInterface nodeGroupService; + + @Argument(description = "folder name") + String folderName; + + @Override + public CommandResult execute(H5mCommandInvocation invocation) throws InterruptedException { + if (folderName == null && invocation.hasFolderContext()) folderName = invocation.getFolderName(); + if (folderName == null) { + invocation.println("folder name is required"); + return CommandResult.FAILURE; + } + NodeGroup group = nodeGroupService.byName(folderName); + if (group == null) { + invocation.println("Folder " + folderName + " not found"); + return CommandResult.FAILURE; + } + List topLevelNodes = group.sources(); + if (topLevelNodes == null || topLevelNodes.isEmpty()) { + invocation.println("No nodes to recalculate in " + folderName); + return CommandResult.SUCCESS; + } + List> futures = new ArrayList<>(); + for (Node node : topLevelNodes) { + RecalculationTracker tracker = folderService.recalculateNode(node.id()); + futures.add(tracker.getFuture()); + } + try { + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])) + .orTimeout(10, TimeUnit.MINUTES) + .join(); + } catch (Exception e) { + invocation.println("Recalculation failed: " + e.getMessage()); + return CommandResult.FAILURE; + } + return CommandResult.SUCCESS; + } +} diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/RemoveCmd.java b/src/main/java/io/hyperfoil/tools/h5m/cli/RemoveCmd.java deleted file mode 100644 index 583904e0..00000000 --- a/src/main/java/io/hyperfoil/tools/h5m/cli/RemoveCmd.java +++ /dev/null @@ -1,73 +0,0 @@ -package io.hyperfoil.tools.h5m.cli; - -import io.hyperfoil.tools.h5m.api.Folder; -import io.hyperfoil.tools.h5m.api.Node; -import io.hyperfoil.tools.h5m.api.NodeGroup; -import io.hyperfoil.tools.h5m.api.svc.FolderServiceInterface; -import io.hyperfoil.tools.h5m.api.svc.NodeGroupServiceInterface; -import io.hyperfoil.tools.h5m.api.svc.NodeServiceInterface; -import jakarta.inject.Inject; -import picocli.CommandLine; - -import java.util.List; -import java.util.concurrent.Callable; - - -@CommandLine.Command(name="remove", description = "remove entity",aliases = {"rm","del","delete"}, mixinStandardHelpOptions = true, subcommands = {RemoveFolder.class, RemoveNode.class, RemoveNotification.class, RemoveProcessing.class}) -public class RemoveCmd implements Callable { - - @Inject - FolderServiceInterface folderService; - @Inject - NodeGroupServiceInterface nodeGroupService; - @Inject - NodeServiceInterface nodeService; - - @CommandLine.Parameters(index="0",arity="0..1") - public String name; - - @Override - public Integer call() throws Exception { - if(name == null) { - CommandLine cmd = new CommandLine(this); - cmd.usage(System.out); - return 0; - } - Folder folder = folderService.byName(name); - NodeGroup nodeGroup = nodeGroupService.byName(name); - List nodes = nodeService.findNodeByFqdn(name); - - if (folder != null) { - if(!nodes.isEmpty()) { - System.err.println("Cannot delete, matched folder and nodes"); - System.err.println(" folder = "+name); - nodes.forEach(n-> System.err.println(" node = "+n.fqdn())); - }else{ - System.out.println("deleting "+name+" folder"); - folderService.delete(name); - } - } else if (nodeGroup != null) { - if(!nodes.isEmpty()) { - System.err.println("Cannot delete, matched node group and nodes"); - System.err.println(" group = "+nodeGroup.name()); - nodes.forEach(n-> System.err.println(" node = "+n.fqdn())); - }else{ - System.out.println("deleted "+name+" node group"); - nodeGroupService.delete(nodeGroup.id()); - } - } else if (!nodes.isEmpty()) { - if(nodes.size() != 1){ - System.err.println("Cannot delete, matched multiple and nodes"); - nodes.forEach(n-> System.out.println(" node = "+n.fqdn())); - }else{ - System.out.println("deleting " + nodes.getFirst().fqdn() + " node"); - nodeService.delete(nodes.getFirst().id()); - } - } else { - System.err.println("failed to match folder, nodegroup, or node with name "+name); - } - - - return 0; - } -} diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/RemoveFolder.java b/src/main/java/io/hyperfoil/tools/h5m/cli/RemoveFolder.java index 3f539094..f9726d38 100644 --- a/src/main/java/io/hyperfoil/tools/h5m/cli/RemoveFolder.java +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/RemoveFolder.java @@ -2,24 +2,31 @@ import io.hyperfoil.tools.h5m.api.svc.FolderServiceInterface; import jakarta.inject.Inject; -import picocli.CommandLine; -import java.util.concurrent.Callable; +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; +import org.aesh.command.option.Argument; -@CommandLine.Command(name="folder",description = "remove a folder", mixinStandardHelpOptions = true) -public class RemoveFolder implements Callable { +@CommandDefinition(name="remove", description = "Delete a folder and all its associated nodes and values", generateHelp = true) +public class RemoveFolder implements Command { @Inject FolderServiceInterface folderService; - @CommandLine.Parameters + @Argument(description = "folder name") String name; @Override - public Integer call() throws Exception { + public CommandResult execute(H5mCommandInvocation invocation) throws InterruptedException { + if (name == null) { + invocation.println("folder name is required"); + return CommandResult.FAILURE; + } if(folderService.delete(name) == 0){ - System.err.println("FolderEntity "+name+" not found"); + invocation.println("Folder " + name + " not found"); + return CommandResult.FAILURE; } - return 0; + return CommandResult.SUCCESS; } } diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/RemoveNode.java b/src/main/java/io/hyperfoil/tools/h5m/cli/RemoveNode.java index 6c9ac0f7..ef8833b6 100644 --- a/src/main/java/io/hyperfoil/tools/h5m/cli/RemoveNode.java +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/RemoveNode.java @@ -4,35 +4,41 @@ import io.hyperfoil.tools.h5m.api.svc.NodeServiceInterface; import io.hyperfoil.tools.h5m.entity.NodeEntity; import jakarta.inject.Inject; -import picocli.CommandLine; + +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; +import org.aesh.command.option.Argument; +import org.aesh.command.option.Option; + import java.util.List; -import java.util.concurrent.Callable; -@CommandLine.Command(name="node",separator = " ", description = "remove a node", mixinStandardHelpOptions = true) -public class RemoveNode implements Callable { +@CommandDefinition(name="remove", description = "Remove a computation node from a folder", generateHelp = true) +public class RemoveNode implements Command { @Inject NodeServiceInterface nodeService; - @CommandLine.Parameters(index="0",arity="1",description = "node name") String name; + @Argument(description = "node name") String name; - @CommandLine.Option(names = {"from"},description = "target group / test",arity = "0..1") String groupName; + @Option(name = "from", acceptNameWithoutDashes = true, description = "target group / test") String groupName; @Override - public Integer call() throws Exception { + public CommandResult execute(H5mCommandInvocation invocation) throws InterruptedException { + if (groupName == null && invocation.hasFolderContext()) groupName = invocation.getFolderName(); String fqdn = groupName == null ? name : groupName + NodeEntity.FQDN_SEPARATOR + name; List found = nodeService.findNodeByFqdn(fqdn); if(found==null || found.isEmpty()) { - System.err.println("could not find " + fqdn); - return 1; + invocation.println("could not find " + fqdn); + return CommandResult.FAILURE; }else if (found.size()>1){ - System.err.println("found too many matching nodes"); - found.forEach(System.out::println); - return 1; + invocation.println("found too many matching nodes"); + found.forEach(n -> invocation.println(n.toString())); + return CommandResult.FAILURE; }else{ nodeService.delete(found.getFirst().id()); } - return 0; + return CommandResult.SUCCESS; } } diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/RemoveNotification.java b/src/main/java/io/hyperfoil/tools/h5m/cli/RemoveNotification.java index fdbb3f51..6fb45e60 100644 --- a/src/main/java/io/hyperfoil/tools/h5m/cli/RemoveNotification.java +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/RemoveNotification.java @@ -1,29 +1,28 @@ package io.hyperfoil.tools.h5m.cli; import io.hyperfoil.tools.h5m.entity.NotificationConfig; -import jakarta.transaction.Transactional; -import picocli.CommandLine; +import io.quarkus.narayana.jta.QuarkusTransaction; -import java.util.concurrent.Callable; +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; +import org.aesh.command.option.Argument; -@CommandLine.Command(name = "notification", separator = " ", - description = "remove a notification config by ID", - mixinStandardHelpOptions = true) -public class RemoveNotification implements Callable { +@CommandDefinition(name = "remove", description = "Remove a notification configuration from a folder", generateHelp = true) +public class RemoveNotification implements Command { - @CommandLine.Parameters(index = "0", arity = "1", description = "notification config ID") + @Argument(description = "notification config ID", required = true) long configId; @Override - @Transactional - public Integer call() throws Exception { - boolean deleted = NotificationConfig.deleteById(configId); + public CommandResult execute(H5mCommandInvocation invocation) throws InterruptedException { + boolean deleted = QuarkusTransaction.requiringNew().call(() -> NotificationConfig.deleteById(configId)); if (deleted) { - System.out.println("Removed notification config " + configId); - return 0; + invocation.println("Removed notification config " + configId); + return CommandResult.SUCCESS; } else { - System.err.println("Notification config not found: " + configId); - return 1; + invocation.println("Notification config not found: " + configId); + return CommandResult.FAILURE; } } } diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/RemoveProcessing.java b/src/main/java/io/hyperfoil/tools/h5m/cli/RemoveProcessing.java index 6ef3b47c..49959168 100644 --- a/src/main/java/io/hyperfoil/tools/h5m/cli/RemoveProcessing.java +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/RemoveProcessing.java @@ -1,24 +1,22 @@ package io.hyperfoil.tools.h5m.cli; -import io.hyperfoil.tools.h5m.entity.ProcessingTrackerEntity; import io.hyperfoil.tools.h5m.svc.ProcessingService; -import io.quarkus.hibernate.orm.panache.PanacheEntityBase; import jakarta.inject.Inject; -import picocli.CommandLine; -import java.util.List; -import java.util.concurrent.Callable; +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; -@CommandLine.Command(name="processing", separator = " ", description = "remove unfinished processing from queue", mixinStandardHelpOptions = true) -public class RemoveProcessing implements Callable { +@CommandDefinition(name = "remove-processing", description = "remove unfinished processing from queue", generateHelp = true) +public class RemoveProcessing implements Command { @Inject ProcessingService service; @Override - public Integer call() throws Exception { + public CommandResult execute(H5mCommandInvocation invocation) throws InterruptedException { int count = service.removeIncompleteProcessing(); - System.out.println("removed "+count); - return 0; + invocation.println("removed " + count); + return CommandResult.SUCCESS; } } diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/ResumeProcessing.java b/src/main/java/io/hyperfoil/tools/h5m/cli/ResumeProcessing.java index 935cb02a..d8018c26 100644 --- a/src/main/java/io/hyperfoil/tools/h5m/cli/ResumeProcessing.java +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/ResumeProcessing.java @@ -2,19 +2,20 @@ import io.hyperfoil.tools.h5m.svc.ProcessingService; import jakarta.inject.Inject; -import picocli.CommandLine; -import java.util.concurrent.Callable; +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; -@CommandLine.Command(name="resume",description = "resume incomplete processing events") -public class ResumeProcessing implements Callable { +@CommandDefinition(name = "resume", description = "resume incomplete processing events", generateHelp = true) +public class ResumeProcessing implements Command { @Inject ProcessingService service; @Override - public Integer call() throws Exception { + public CommandResult execute(H5mCommandInvocation invocation) throws InterruptedException { service.recoverIncompleteProcessing(null); - return 0; + return CommandResult.SUCCESS; } } diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/RunCmd.java b/src/main/java/io/hyperfoil/tools/h5m/cli/RunCmd.java new file mode 100644 index 00000000..17f6c432 --- /dev/null +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/RunCmd.java @@ -0,0 +1,23 @@ +package io.hyperfoil.tools.h5m.cli; + +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; + +@CommandDefinition( + name = "run", + description = "Run management: list uploads, show run data, and upload new data", + groupCommands = { + RunListCmd.class, + RunShowCmd.class, + RunUploadCmd.class, + }, + generateHelp = true +) +public class RunCmd implements Command { + @Override + public CommandResult execute(H5mCommandInvocation invocation) throws InterruptedException { + invocation.println("Use 'run '. Try 'run --help' for available subcommands."); + return CommandResult.SUCCESS; + } +} diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/RunListCmd.java b/src/main/java/io/hyperfoil/tools/h5m/cli/RunListCmd.java new file mode 100644 index 00000000..612da167 --- /dev/null +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/RunListCmd.java @@ -0,0 +1,85 @@ +package io.hyperfoil.tools.h5m.cli; + +import java.util.List; + +import jakarta.inject.Inject; + +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; +import org.aesh.command.option.Option; +import org.aesh.util.table.Table; + +import io.hyperfoil.tools.h5m.api.NodeGroup; +import io.hyperfoil.tools.h5m.api.Value; +import io.hyperfoil.tools.h5m.api.svc.NodeGroupServiceInterface; +import io.hyperfoil.tools.h5m.api.svc.ValueServiceInterface; + +@CommandDefinition(name = "list", description = "List uploaded runs (root values) for a folder", generateHelp = true) +public class RunListCmd implements Command { + + @Inject + NodeGroupServiceInterface nodeGroupService; + + @Inject + ValueServiceInterface valueService; + + @Option(name = "from", acceptNameWithoutDashes = true, description = "folder name") + String folderName; + + @Option(name = "limit", acceptNameWithoutDashes = true, shortName = 'l', + description = "maximum number of results to display", defaultValue = { "50" }) + int limit; + + @Override + public CommandResult execute(H5mCommandInvocation invocation) throws InterruptedException { + if (folderName == null && invocation.hasFolderContext()) folderName = invocation.getFolderName(); + if (folderName == null) { + invocation.println("folder name is required (use --from)"); + return CommandResult.FAILURE; + } + + NodeGroup nodeGroup = nodeGroupService.byName(folderName); + if (nodeGroup == null) { + invocation.println("Node group '" + folderName + "' not found"); + return CommandResult.FAILURE; + } + + if (nodeGroup.root() == null) { + invocation.println("No root node found for folder '" + folderName + "'"); + return CommandResult.FAILURE; + } + + List rootValues; + try { + rootValues = valueService.getNodeValues(nodeGroup.root().id()); + } catch (Exception e) { + invocation.println("Failed to list runs: " + e.getMessage()); + return CommandResult.FAILURE; + } + + if (rootValues.isEmpty()) { + invocation.println("No runs found for folder '" + folderName + "'"); + return CommandResult.SUCCESS; + } + + int totalCount = rootValues.size(); + if (limit > 0 && rootValues.size() > limit) { + rootValues = rootValues.subList(rootValues.size() - limit, rootValues.size()); + } + + String output = Table.builder() + .maxWidth(120) + .column("id", v -> v.id()) + .column("preview", v -> { + if (v.data() == null || v.data().isNull()) return "(no data)"; + String json = v.data().toJsonString(); + return json.length() > 60 ? json.substring(0, 57) + "..." : json; + }) + .build() + .render(rootValues); + invocation.println("Count: " + totalCount + (limit > 0 ? " (showing " + rootValues.size() + ")" : "")); + invocation.println(output); + return CommandResult.SUCCESS; + } +} diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/RunShowCmd.java b/src/main/java/io/hyperfoil/tools/h5m/cli/RunShowCmd.java new file mode 100644 index 00000000..17eb1e8e --- /dev/null +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/RunShowCmd.java @@ -0,0 +1,148 @@ +package io.hyperfoil.tools.h5m.cli; + +import java.util.List; + +import jakarta.inject.Inject; + +import io.quarkus.narayana.jta.QuarkusTransaction; + +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; +import org.aesh.command.option.Argument; +import org.aesh.command.option.Option; + +import io.hyperfoil.tools.jjq.value.JqValue; +import io.hyperfoil.tools.jjq.value.JqValues; +import io.hyperfoil.tools.h5m.api.NodeGroup; +import io.hyperfoil.tools.h5m.api.Value; +import io.hyperfoil.tools.h5m.api.svc.NodeGroupServiceInterface; +import io.hyperfoil.tools.h5m.api.svc.NodeServiceInterface; +import io.hyperfoil.tools.h5m.api.svc.ValueServiceInterface; +import io.hyperfoil.tools.h5m.entity.ValueEntity; + +@CommandDefinition(name = "show", description = "Show the JSON data for a specific run (upload)", generateHelp = true) +public class RunShowCmd implements Command { + + @Inject + NodeGroupServiceInterface nodeGroupService; + + @Inject + ValueServiceInterface valueService; + + @Inject + NodeServiceInterface nodeService; + + @Argument(description = "run ID (omit to show the newest run)") + Long runId; + + @Option(name = "from", acceptNameWithoutDashes = true, description = "folder name") + String folderName; + + @Option(name = "node", acceptNameWithoutDashes = true, description = "show value for a specific node from this run", + completer = NodeNameCompleter.class) + String nodeName; + + @Override + public CommandResult execute(H5mCommandInvocation invocation) throws InterruptedException { + if (folderName == null && invocation.hasFolderContext()) folderName = invocation.getFolderName(); + if (folderName == null) { + invocation.println("folder name is required (use --from)"); + return CommandResult.FAILURE; + } + + NodeGroup nodeGroup = nodeGroupService.byName(folderName); + if (nodeGroup == null) { + invocation.println("Node group '" + folderName + "' not found"); + return CommandResult.FAILURE; + } + + if (nodeGroup.root() == null) { + invocation.println("No root node found for folder '" + folderName + "'"); + return CommandResult.FAILURE; + } + + // Use QuarkusTransaction for entity access + final NodeGroup ng = nodeGroup; + return QuarkusTransaction.requiringNew().call(() -> { + ValueEntity rootValue; + if (runId != null) { + rootValue = ValueEntity.findById(runId); + if (rootValue == null) { + invocation.println("Run with id " + runId + " not found"); + return CommandResult.FAILURE; + } + if (rootValue.node.id != ng.root().id().longValue()) { + invocation.println("Value " + runId + " is not a root value (run) for folder '" + folderName + "'"); + return CommandResult.FAILURE; + } + } else { + // Get the newest root value + List rootValues = valueService.getNodeValues(ng.root().id()); + if (rootValues.isEmpty()) { + invocation.println("No runs found for folder '" + folderName + "'"); + return CommandResult.SUCCESS; + } + Value newest = rootValues.getLast(); + rootValue = ValueEntity.findById(newest.id()); + if (rootValue == null) { + invocation.println("Failed to load run data"); + return CommandResult.FAILURE; + } + } + + if (nodeName != null && !nodeName.isEmpty()) { + return showNodeValue(invocation, rootValue, ng); + } + + // Default: show the root value's data + JqValue data = rootValue.data; + if (data == null || data.isNull()) { + invocation.println("Run " + rootValue.id + " has no data"); + return CommandResult.SUCCESS; + } + + invocation.println("Run " + rootValue.id + ":"); + invocation.println(JqValues.toPrettyJsonString(data)); + return CommandResult.SUCCESS; + }); + } + + private CommandResult showNodeValue(H5mCommandInvocation invocation, ValueEntity rootValue, NodeGroup nodeGroup) { + var foundNodes = nodeService.findNodeByFqdn(nodeName, nodeGroup.id()); + if (foundNodes.isEmpty()) { + invocation.println("Node '" + nodeName + "' not found in folder '" + folderName + "'"); + return CommandResult.FAILURE; + } + if (foundNodes.size() > 1) { + invocation.println("'" + nodeName + "' is ambiguous, matched multiple nodes:"); + for (var n : foundNodes) { + invocation.println(" " + n.fqdn()); + } + return CommandResult.FAILURE; + } + + var node = foundNodes.getFirst(); + // Find the descendant value for this node from this run + List descendants = valueService.getNodeDescendantValues(rootValue.node.id); + Value match = descendants.stream() + .filter(v -> v.node() != null && v.node().id().equals(node.id())) + .findFirst() + .orElse(null); + + if (match == null) { + invocation.println("No value found for node '" + nodeName + "' in run " + rootValue.id); + return CommandResult.SUCCESS; + } + + JqValue data = match.data(); + if (data == null || data.isNull()) { + invocation.println("Node '" + nodeName + "' value is null in run " + rootValue.id); + return CommandResult.SUCCESS; + } + + invocation.println("Run " + rootValue.id + ", node '" + nodeName + "':"); + invocation.println(JqValues.toPrettyJsonString(data)); + return CommandResult.SUCCESS; + } +} diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/RunUploadCmd.java b/src/main/java/io/hyperfoil/tools/h5m/cli/RunUploadCmd.java new file mode 100644 index 00000000..be5edd24 --- /dev/null +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/RunUploadCmd.java @@ -0,0 +1,11 @@ +package io.hyperfoil.tools.h5m.cli; + +import org.aesh.command.CommandDefinition; + +/** + * Upload command registered under the {@code run} command group. + * Delegates all logic to {@link UploadCmd}. + */ +@CommandDefinition(name = "upload", description = "Upload JSON files to a folder for processing through its computation node graph", generateHelp = true) +public class RunUploadCmd extends UploadCmd { +} diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/StatusCmd.java b/src/main/java/io/hyperfoil/tools/h5m/cli/StatusCmd.java new file mode 100644 index 00000000..25214edb --- /dev/null +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/StatusCmd.java @@ -0,0 +1,63 @@ +package io.hyperfoil.tools.h5m.cli; + +import io.hyperfoil.tools.h5m.api.NodeType; +import io.hyperfoil.tools.h5m.entity.ValueEntity; +import io.hyperfoil.tools.h5m.queue.UploadTracker; +import io.hyperfoil.tools.h5m.svc.WorkService; +import jakarta.inject.Inject; + +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; +import org.aesh.command.option.Arguments; + +import java.util.List; +import java.util.Optional; + +@CommandDefinition(name = "status", description = "Check processing status of one or more uploads by their processing ID", generateHelp = true) +public class StatusCmd implements Command { + + @Inject + WorkService workService; + + @Arguments(description = "processing ID(s) to check") + List ids; + + @Override + public CommandResult execute(H5mCommandInvocation invocation) throws InterruptedException { + if (ids == null || ids.isEmpty()) { + invocation.println("at least one processing ID is required"); + return CommandResult.FAILURE; + } + for (String idStr : ids) { + long id; + try { + id = Long.parseLong(idStr); + } catch (NumberFormatException e) { + invocation.println(idStr + ": invalid ID"); + continue; + } + Optional tracker = workService.getTracker(id); + if (tracker.isPresent()) { + if (tracker.get().getFuture().isDone()) { + if (tracker.get().getFuture().isCompletedExceptionally()) { + invocation.println(id + ": FAILED"); + } else { + invocation.println(id + ": COMPLETED"); + } + } else { + invocation.println(id + ": PROCESSING"); + } + } else { + // Tracker already cleaned up — check if root value exists in DB + ValueEntity rootValue = ValueEntity.findById(id); + if (rootValue != null && rootValue.node != null && rootValue.node.type() == NodeType.ROOT) { + invocation.println(id + ": COMPLETED"); + } else { + invocation.println(id + ": not found"); + } + } + } + return CommandResult.SUCCESS; + } +} diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/StructureCmd.java b/src/main/java/io/hyperfoil/tools/h5m/cli/StructureCmd.java new file mode 100644 index 00000000..848508e4 --- /dev/null +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/StructureCmd.java @@ -0,0 +1,36 @@ +package io.hyperfoil.tools.h5m.cli; + +import jakarta.inject.Inject; +import jakarta.persistence.NoResultException; + +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; +import org.aesh.command.option.Argument; + + +import io.hyperfoil.tools.h5m.api.svc.FolderServiceInterface; +import io.hyperfoil.tools.jjq.value.JqValue; + +@CommandDefinition(name = "structure", description = "Display the hierarchical structure of a folder's node graph", generateHelp = true) +public class StructureCmd implements Command { + + @Inject + FolderServiceInterface folderService; + + @Argument(description = "folder name") + String folderName; + + @Override + public CommandResult execute(H5mCommandInvocation invocation) throws InterruptedException { + if (folderName == null && invocation.hasFolderContext()) folderName = invocation.getFolderName(); + try { + JqValue structure = folderService.structure(folderName); + invocation.println(structure.toString()); + } catch (NoResultException e) { + invocation.println("could not find folder " + folderName); + return CommandResult.FAILURE; + } + return CommandResult.SUCCESS; + } +} diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/UpdateNode.java b/src/main/java/io/hyperfoil/tools/h5m/cli/UpdateNode.java new file mode 100644 index 00000000..d75f88a3 --- /dev/null +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/UpdateNode.java @@ -0,0 +1,137 @@ +package io.hyperfoil.tools.h5m.cli; + +import io.hyperfoil.tools.h5m.api.Node; +import io.hyperfoil.tools.h5m.api.NodeGroup; +import io.hyperfoil.tools.h5m.api.NodeType; +import io.hyperfoil.tools.h5m.api.svc.NodeGroupServiceInterface; +import io.hyperfoil.tools.h5m.api.svc.NodeServiceInterface; +import io.hyperfoil.tools.h5m.entity.NodeEntity; +import jakarta.inject.Inject; + +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; +import org.aesh.command.option.Argument; +import org.aesh.command.option.Option; + +import io.hyperfoil.tools.jjq.JqProgram; +import io.hyperfoil.tools.jjq.jsonata.JsonataCompiler; + +import java.util.List; +import java.util.stream.Collectors; + +@CommandDefinition(name = "update", description = "Modify a node's name or operation expression", generateHelp = true) +public class UpdateNode implements Command { + + @Argument(description = "node name", required = true) + String name; + + @Option(name = "from", acceptNameWithoutDashes = true, description = "folder / group name", completer = FolderCompleter.class) + String groupName; + + @Option(name = "operation", acceptNameWithoutDashes = true, shortName = 'o', description = "new operation (jq filter, js function, etc.)") + String operation; + + @Option(name = "name", acceptNameWithoutDashes = true, shortName = 'n', description = "rename the node") + String newName; + + @Inject + NodeGroupServiceInterface nodeGroupService; + + @Inject + NodeServiceInterface nodeService; + + @Override + public CommandResult execute(H5mCommandInvocation invocation) throws InterruptedException { + if (groupName == null && invocation.hasFolderContext()) { + groupName = invocation.getFolderName(); + } + if (groupName == null) { + invocation.println("folder name is required (use --from or cd into a folder)"); + return CommandResult.FAILURE; + } + + NodeGroup foundGroup = nodeGroupService.byName(groupName); + if (foundGroup == null) { + invocation.println("folder " + groupName + " not found"); + return CommandResult.FAILURE; + } + + List foundNodes = nodeService.findNodeByFqdn(name, foundGroup.id()); + if (foundNodes.isEmpty()) { + invocation.println("node " + name + " not found in " + groupName); + return CommandResult.FAILURE; + } else if (foundNodes.size() > 1) { + invocation.println("ambiguous node name " + name + ", matches:\n " + + foundNodes.stream().map(Node::fqdn).collect(Collectors.joining("\n "))); + return CommandResult.FAILURE; + } + + Node node = foundNodes.getFirst(); + + if (operation == null && newName == null) { + invocation.println("nothing to update (specify --operation or --name)"); + return CommandResult.FAILURE; + } + + // Validate the new operation before applying + if (operation != null) { + String validationError = validateOperation(node.type(), operation); + if (validationError != null) { + invocation.println("invalid operation: " + validationError); + return CommandResult.FAILURE; + } + } + + // Load the entity and apply changes + NodeEntity entity = NodeEntity.findById(node.id()); + if (entity == null) { + invocation.println("node entity not found: " + node.id()); + return CommandResult.FAILURE; + } + + if (newName != null) { + entity.name = newName; + } + if (operation != null) { + entity.operation = operation; + } + + nodeService.update(entity); + + if (newName != null && operation != null) { + invocation.println("updated node " + name + " -> name=" + newName + ", operation=" + operation); + } else if (newName != null) { + invocation.println("renamed node " + name + " -> " + newName); + } else { + invocation.println("updated operation for " + name); + } + + return CommandResult.SUCCESS; + } + + /** + * Validates the operation expression based on the node type. + * Returns null if valid, or an error message if invalid. + */ + private String validateOperation(NodeType type, String operation) { + try { + switch (type) { + case JQ -> { + JqProgram.compile(operation); + } + case JSONATA -> { + JsonataCompiler.compile(operation); + } + // JS validation would require GraalVM context — skip for now + // SQL jsonpath validation is complex — skip for now + default -> { + // No validation for other types + } + } + return null; + } catch (Exception e) { + return e.getMessage(); + } + } +} diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/UploadCmd.java b/src/main/java/io/hyperfoil/tools/h5m/cli/UploadCmd.java new file mode 100644 index 00000000..47c89d2b --- /dev/null +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/UploadCmd.java @@ -0,0 +1,120 @@ +package io.hyperfoil.tools.h5m.cli; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import jakarta.inject.Inject; +import jakarta.persistence.NoResultException; + +import io.hyperfoil.tools.h5m.api.Folder; +import io.hyperfoil.tools.h5m.api.Upload; +import io.hyperfoil.tools.h5m.api.Value; +import io.hyperfoil.tools.h5m.api.svc.FolderServiceInterface; +import io.hyperfoil.tools.h5m.svc.ValueService; +import io.hyperfoil.tools.jjq.value.JqValue; +import io.hyperfoil.tools.jjq.value.JqValues; + +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; +import org.aesh.command.option.Argument; +import org.aesh.command.option.Option; + +@CommandDefinition(name = "upload", description = "Upload JSON files to a folder for processing through its computation node graph", generateHelp = true) +public class UploadCmd implements Command { + + @Inject + FolderServiceInterface folderService; + + @Inject + ValueService valueService; + + @Option(name = "async", hasValue = false, acceptNameWithoutDashes = true, + description = "return immediately without waiting for processing to complete") + boolean async; + + @Argument(description = "path to JSON file or directory") + String path; + + @Option(name = "to", acceptNameWithoutDashes = true, description = "target folder name") + String folderName; + + @Override + public CommandResult execute(H5mCommandInvocation invocation) throws InterruptedException { + if (folderName == null && invocation.hasFolderContext()) folderName = invocation.getFolderName(); + if (path == null) { + invocation.println("path to JSON file or directory is required"); + return CommandResult.FAILURE; + } + Folder folder = folderService.byName(folderName); + if (folder == null) { + invocation.println("could not find folder " + folderName); + return CommandResult.FAILURE; + } + File pathFile = new File(path); + if (!pathFile.exists()) { + invocation.println("upload path does not exist: " + path); + return CommandResult.FAILURE; + } + List todo = pathFile.isDirectory() + ? List.of(pathFile.listFiles(s -> s.toString().endsWith(".json") && !s.getName().startsWith("."))) + : List.of(pathFile); + List uploads = new ArrayList<>(); + for (File f : todo) { + if (Thread.interrupted()) throw new InterruptedException("Upload interrupted"); + try { + JqValue read = JqValues.parse(new String(java.nio.file.Files.readAllBytes(f.toPath()))); + if (read != null) { + try { + Upload upload = folderService.upload(folderName, read); + uploads.add(upload); + if (todo.size() > 1) { + invocation.println(f.getName() + " -> processing id: " + upload.uploadId); + } else { + invocation.println("Processing id: " + upload.uploadId); + } + } catch (NoResultException e) { + invocation.println("could not find folder " + folderName); + return CommandResult.FAILURE; + } + } else { + invocation.println(f.getPath() + " could not be loaded as json"); + } + } catch (IOException e) { + invocation.println("failure trying to read " + f.getPath() + "\n" + e.getMessage()); + return CommandResult.FAILURE; + } + } + + if (async) { + // Async mode — return immediately, user can poll with 'status' command + return CommandResult.SUCCESS; + } + + // Synchronous mode — wait for all uploads to complete, then show detection results + if (!uploads.isEmpty()) { + try { + CompletableFuture.allOf(uploads.stream() + .map(u -> u.future) + .toArray(CompletableFuture[]::new)) + .orTimeout(5, TimeUnit.MINUTES) + .join(); + } catch (Exception e) { + invocation.println("Upload processing failed: " + e.getMessage()); + return CommandResult.FAILURE; + } + + // Query for detection results across all uploads + List allChanges = new ArrayList<>(); + for (Upload upload : uploads) { + allChanges.addAll(valueService.getDetectionDescendants(upload.uploadId)); + } + invocation.println("Processing complete. " + ChangeFormatter.formatSummary(allChanges)); + } + return CommandResult.SUCCESS; + } +} diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/VerifyLegacy.java b/src/main/java/io/hyperfoil/tools/h5m/cli/VerifyLegacy.java index 9daf5c3c..c4d31b53 100644 --- a/src/main/java/io/hyperfoil/tools/h5m/cli/VerifyLegacy.java +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/VerifyLegacy.java @@ -11,29 +11,38 @@ import io.hyperfoil.tools.h5m.svc.ValueService; import jakarta.inject.Inject; import jakarta.persistence.EntityManager; -import jakarta.transaction.Transactional; -import picocli.CommandLine; + +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; +import org.aesh.command.option.Option; + import java.sql.*; import java.util.*; -import java.util.concurrent.Callable; -@CommandLine.Command(name = "verifyimport", description = "Compare h5m imported data against Horreum source data") -public class VerifyLegacy implements Callable { +@CommandDefinition(name = "verify", description = "Verify that imported data matches the original Horreum database values", generateHelp = true) +public class VerifyLegacy implements Command { - @CommandLine.Option(names = {"username"}, description = "legacy db username", defaultValue = "quarkus") + @Option(name = "username", acceptNameWithoutDashes = true, description = "legacy db username", defaultValue = "quarkus") String username; - @CommandLine.Option(names = {"password"}, description = "legacy db password", defaultValue = "quarkus") + + @Option(name = "password", acceptNameWithoutDashes = true, description = "legacy db password", defaultValue = "quarkus") String password; - @CommandLine.Option(names = {"url"}, description = "legacy connection url", defaultValue = "jdbc:postgresql://0.0.0.0:6000/horreum") + + @Option(name = "url", acceptNameWithoutDashes = true, description = "legacy connection url", defaultValue = "jdbc:postgresql://0.0.0.0:6000/horreum") String url; - @CommandLine.Option(names = {"testId"}, description = "Horreum test ID") + + @Option(name = "testId", acceptNameWithoutDashes = true, description = "Horreum test ID") Long testId; - @CommandLine.Option(names = {"runId"}, description = "verify a specific run (optional)") + + @Option(name = "runId", acceptNameWithoutDashes = true, description = "verify a specific run (optional)") Long runId; - @CommandLine.Option(names = {"limit"}, description = "max runs to verify", defaultValue = "5") + + @Option(name = "limit", acceptNameWithoutDashes = true, description = "max runs to verify", defaultValue = "5") int limit; - @CommandLine.Option(names = {"verbose"}, description = "show detailed mismatch info", defaultValue = "false") + + @Option(name = "verbose", acceptNameWithoutDashes = true, description = "show detailed mismatch info", hasValue = false, defaultValue = "false") boolean verbose; @Inject @@ -49,11 +58,19 @@ public class VerifyLegacy implements Callable { ViewServiceInterface viewService; @Override - @Transactional - public Integer call() throws Exception { + public CommandResult execute(H5mCommandInvocation invocation) throws InterruptedException { + try { + return doExecute(invocation); + } catch (Exception e) { + System.err.println("Error: " + e.getMessage()); + return CommandResult.FAILURE; + } + } + + CommandResult doExecute(H5mCommandInvocation invocation) throws Exception { if (testId == null) { - System.err.println("testId is required"); - return 1; + invocation.println("testId is required"); + return CommandResult.FAILURE; } Map props = new HashMap<>(); @@ -72,8 +89,8 @@ public Integer call() throws Exception { try (Connection legacyConn = legacyDs.getConnection()) { String testName = getTestName(legacyConn, testId); if (testName == null) { - System.err.println("Test not found: " + testId); - return 1; + invocation.println("Test not found: " + testId); + return CommandResult.FAILURE; } System.out.println("Verifying test: " + testName + " (id=" + testId + ")"); @@ -199,10 +216,10 @@ public Integer call() throws Exception { if (totalMismatches == 0 && totalMissing == 0) { System.out.println("\nRESULT: PASS"); - return 0; + return CommandResult.SUCCESS; } else { System.out.println("\nRESULT: DIFFERENCES FOUND"); - return 1; + return CommandResult.FAILURE; } } finally { legacyDs.close(); diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/ViewCmd.java b/src/main/java/io/hyperfoil/tools/h5m/cli/ViewCmd.java new file mode 100644 index 00000000..57ccab83 --- /dev/null +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/ViewCmd.java @@ -0,0 +1,25 @@ +package io.hyperfoil.tools.h5m.cli; + +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; + +@CommandDefinition( + name = "view", + description = "View management: create, show, update, and remove data views", + groupCommands = { + ViewListCmd.class, + ViewShowCmd.class, + ViewCreateCmd.class, + ViewRemoveCmd.class, + ViewUpdateCmd.class, + }, + generateHelp = true +) +public class ViewCmd implements Command { + @Override + public CommandResult execute(H5mCommandInvocation invocation) throws InterruptedException { + invocation.println("Use 'view '. Try 'view --help' for available subcommands."); + return CommandResult.SUCCESS; + } +} diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/ViewCreateCmd.java b/src/main/java/io/hyperfoil/tools/h5m/cli/ViewCreateCmd.java new file mode 100644 index 00000000..8c6d0929 --- /dev/null +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/ViewCreateCmd.java @@ -0,0 +1,45 @@ +package io.hyperfoil.tools.h5m.cli; + +import java.util.List; + +import jakarta.inject.Inject; + +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; +import org.aesh.command.option.Argument; +import org.aesh.command.option.Option; + +import io.hyperfoil.tools.h5m.api.View; +import io.hyperfoil.tools.h5m.api.svc.ViewServiceInterface; + +@CommandDefinition(name = "create", description = "Create a new named view for a folder", generateHelp = true) +public class ViewCreateCmd implements Command { + + @Inject + ViewServiceInterface viewService; + + @Argument(description = "view name", required = true) + String name; + + @Option(name = "to", acceptNameWithoutDashes = true, description = "target folder name") + String folderName; + + @Override + public CommandResult execute(H5mCommandInvocation invocation) throws InterruptedException { + if (folderName == null && invocation.hasFolderContext()) folderName = invocation.getFolderName(); + if (folderName == null) { + invocation.println("folder name is required (use --to)"); + return CommandResult.FAILURE; + } + + try { + View created = viewService.createView(folderName, new View(null, name, null, List.of())); + invocation.println("Created view '" + created.name() + "' (id=" + created.id() + ")"); + } catch (Exception e) { + invocation.println("Failed to create view: " + e.getMessage()); + return CommandResult.FAILURE; + } + return CommandResult.SUCCESS; + } +} diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/ViewListCmd.java b/src/main/java/io/hyperfoil/tools/h5m/cli/ViewListCmd.java new file mode 100644 index 00000000..12c114c7 --- /dev/null +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/ViewListCmd.java @@ -0,0 +1,63 @@ +package io.hyperfoil.tools.h5m.cli; + +import java.util.List; + +import jakarta.inject.Inject; + +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; +import org.aesh.command.option.Option; +import org.aesh.util.table.Table; + +import io.hyperfoil.tools.h5m.api.View; +import io.hyperfoil.tools.h5m.api.ViewComponent; +import io.hyperfoil.tools.h5m.api.svc.ViewServiceInterface; + +@CommandDefinition(name = "list", description = "List all views configured for a folder", generateHelp = true) +public class ViewListCmd implements Command { + + @Inject + ViewServiceInterface viewService; + + @Option(name = "from", acceptNameWithoutDashes = true, description = "folder name") + String folderName; + + @Override + public CommandResult execute(H5mCommandInvocation invocation) throws InterruptedException { + if (folderName == null && invocation.hasFolderContext()) folderName = invocation.getFolderName(); + if (folderName == null) { + invocation.println("folder name is required (use --from)"); + return CommandResult.FAILURE; + } + + List views; + try { + views = viewService.getViews(folderName); + } catch (Exception e) { + invocation.println("Failed to list views: " + e.getMessage()); + return CommandResult.FAILURE; + } + + if (views.isEmpty()) { + invocation.println("No views found for folder '" + folderName + "'"); + return CommandResult.SUCCESS; + } + + String output = Table.builder() + .maxWidth(120) + .column("name", v -> v.name()) + .column("columns", v -> v.components() != null ? v.components().size() : 0) + .column("components", v -> { + if (v.components() == null || v.components().isEmpty()) return ""; + return v.components().stream() + .map(ViewComponent::headerName) + .reduce((a, b) -> a + ", " + b) + .orElse(""); + }) + .build() + .render(views); + invocation.println(output); + return CommandResult.SUCCESS; + } +} diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/ViewRemoveCmd.java b/src/main/java/io/hyperfoil/tools/h5m/cli/ViewRemoveCmd.java new file mode 100644 index 00000000..6e3e4df2 --- /dev/null +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/ViewRemoveCmd.java @@ -0,0 +1,65 @@ +package io.hyperfoil.tools.h5m.cli; + +import java.util.List; + +import jakarta.inject.Inject; + +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; +import org.aesh.command.option.Argument; +import org.aesh.command.option.Option; + +import io.hyperfoil.tools.h5m.api.View; +import io.hyperfoil.tools.h5m.api.svc.ViewServiceInterface; + +@CommandDefinition(name = "remove", description = "Remove a view from a folder", generateHelp = true) +public class ViewRemoveCmd implements Command { + + @Inject + ViewServiceInterface viewService; + + @Argument(description = "view name", required = true) + String name; + + @Option(name = "from", acceptNameWithoutDashes = true, description = "folder name") + String folderName; + + @Override + public CommandResult execute(H5mCommandInvocation invocation) throws InterruptedException { + if (folderName == null && invocation.hasFolderContext()) folderName = invocation.getFolderName(); + if (folderName == null) { + invocation.println("folder name is required (use --from)"); + return CommandResult.FAILURE; + } + + List views; + try { + views = viewService.getViews(folderName); + } catch (Exception e) { + invocation.println("Failed to list views: " + e.getMessage()); + return CommandResult.FAILURE; + } + + View view = views.stream() + .filter(v -> v.name().equalsIgnoreCase(name)) + .findFirst() + .orElse(null); + if (view == null) { + invocation.println("View '" + name + "' not found"); + return CommandResult.FAILURE; + } + + try { + viewService.deleteView(view.id()); + invocation.println("Removed view '" + name + "'"); + } catch (IllegalArgumentException e) { + invocation.println(e.getMessage()); + return CommandResult.FAILURE; + } catch (Exception e) { + invocation.println("Failed to remove view: " + e.getMessage()); + return CommandResult.FAILURE; + } + return CommandResult.SUCCESS; + } +} diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/ViewShowCmd.java b/src/main/java/io/hyperfoil/tools/h5m/cli/ViewShowCmd.java new file mode 100644 index 00000000..34cba9cb --- /dev/null +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/ViewShowCmd.java @@ -0,0 +1,107 @@ +package io.hyperfoil.tools.h5m.cli; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Function; + +import jakarta.inject.Inject; + +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; +import org.aesh.command.option.Argument; +import org.aesh.command.option.Option; +import org.aesh.util.table.Table; + +import io.hyperfoil.tools.jjq.value.JqNumber; +import io.hyperfoil.tools.jjq.value.JqObject; +import io.hyperfoil.tools.jjq.value.JqString; +import io.hyperfoil.tools.jjq.value.JqValue; +import io.hyperfoil.tools.h5m.api.View; +import io.hyperfoil.tools.h5m.api.ViewComponent; +import io.hyperfoil.tools.h5m.api.svc.ViewServiceInterface; + +@CommandDefinition(name = "show", description = "Display data through a configured view as a table", generateHelp = true) +public class ViewShowCmd implements Command { + + @Inject + ViewServiceInterface viewService; + + @Argument(description = "view name (defaults to 'Default')") + String viewName; + + @Option(name = "from", acceptNameWithoutDashes = true, description = "folder name") + String folderName; + + @Option(name = "limit", acceptNameWithoutDashes = true, shortName = 'l', + description = "maximum number of rows to display", defaultValue = { "50" }) + int limit; + + @Override + public CommandResult execute(H5mCommandInvocation invocation) throws InterruptedException { + if (folderName == null && invocation.hasFolderContext()) folderName = invocation.getFolderName(); + if (folderName == null) { + invocation.println("folder name is required (use --from)"); + return CommandResult.FAILURE; + } + if (viewName == null || viewName.isEmpty()) { + viewName = "Default"; + } + + List views; + try { + views = viewService.getViews(folderName); + } catch (Exception e) { + invocation.println("Failed to list views: " + e.getMessage()); + return CommandResult.FAILURE; + } + + View view = views.stream() + .filter(v -> v.name().equalsIgnoreCase(viewName)) + .findFirst() + .orElse(null); + if (view == null) { + invocation.println("View '" + viewName + "' not found. Available views: " + + views.stream().map(View::name).reduce((a, b) -> a + ", " + b).orElse("none")); + return CommandResult.FAILURE; + } + + if (view.components() == null || view.components().isEmpty()) { + invocation.println("View '" + viewName + "' has no columns configured. Use 'view update " + viewName + " --add ' to add columns."); + return CommandResult.SUCCESS; + } + + List rows; + try { + rows = viewService.getViewData(folderName, view.id()); + } catch (Exception e) { + invocation.println("Failed to get view data: " + e.getMessage()); + return CommandResult.FAILURE; + } + + int totalCount = rows.size(); + if (limit > 0 && rows.size() > limit) { + rows = rows.subList(0, limit); + } + + List components = view.components(); + + // Build table columns from view components + Table.Builder tableBuilder = Table.builder().maxWidth(120); + for (ViewComponent comp : components) { + String header = comp.headerName(); + String nodeName = comp.nodeName(); + tableBuilder.column(header, row -> { + JqValue val = row.getField(nodeName); + if (val == null || val.isNull()) return ""; + if (val instanceof JqString s) return s.stringValue(); + if (val instanceof JqNumber n) return n.isIntegral() ? (Object) n.longValue() : n.doubleValue(); + return val.toJsonString(); + }); + } + + invocation.println("Count: " + totalCount + (limit > 0 ? " (showing " + rows.size() + ")" : "")); + invocation.println(tableBuilder.build().render(rows)); + return CommandResult.SUCCESS; + } +} diff --git a/src/main/java/io/hyperfoil/tools/h5m/cli/ViewUpdateCmd.java b/src/main/java/io/hyperfoil/tools/h5m/cli/ViewUpdateCmd.java new file mode 100644 index 00000000..2e6f0b85 --- /dev/null +++ b/src/main/java/io/hyperfoil/tools/h5m/cli/ViewUpdateCmd.java @@ -0,0 +1,205 @@ +package io.hyperfoil.tools.h5m.cli; + +import java.util.ArrayList; +import java.util.List; + +import jakarta.inject.Inject; + +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; +import org.aesh.command.option.Argument; +import org.aesh.command.option.Option; + +import io.hyperfoil.tools.h5m.api.Node; +import io.hyperfoil.tools.h5m.api.NodeGroup; +import io.hyperfoil.tools.h5m.api.View; +import io.hyperfoil.tools.h5m.api.ViewComponent; +import io.hyperfoil.tools.h5m.api.svc.NodeGroupServiceInterface; +import io.hyperfoil.tools.h5m.api.svc.NodeServiceInterface; +import io.hyperfoil.tools.h5m.api.svc.ViewServiceInterface; + +@CommandDefinition(name = "update", description = "Update a view: add/remove columns or reorder them", generateHelp = true) +public class ViewUpdateCmd implements Command { + + @Inject + ViewServiceInterface viewService; + + @Inject + NodeServiceInterface nodeService; + + @Inject + NodeGroupServiceInterface nodeGroupService; + + @Argument(description = "view name", required = true) + String viewName; + + @Option(name = "from", acceptNameWithoutDashes = true, description = "folder name") + String folderName; + + @Option(name = "add", acceptNameWithoutDashes = true, description = "node name or ID to add as a column", + completer = NodeNameCompleter.class) + String addNode; + + @Option(name = "remove", acceptNameWithoutDashes = true, description = "node name or header to remove from columns", + completer = NodeNameCompleter.class) + String removeNode; + + @Option(name = "reorder", acceptNameWithoutDashes = true, description = "node name to reorder", + completer = NodeNameCompleter.class) + String reorderNode; + + @Option(name = "position", acceptNameWithoutDashes = true, description = "absolute position for reorder (0-based)") + Integer position; + + @Option(name = "header", acceptNameWithoutDashes = true, description = "custom header name when adding (defaults to node name, truncated to 20 chars)") + String headerName; + + @Override + public CommandResult execute(H5mCommandInvocation invocation) throws InterruptedException { + if (folderName == null && invocation.hasFolderContext()) folderName = invocation.getFolderName(); + if (folderName == null) { + invocation.println("folder name is required (use --from)"); + return CommandResult.FAILURE; + } + + // Look up the view + List views; + try { + views = viewService.getViews(folderName); + } catch (Exception e) { + invocation.println("Failed to list views: " + e.getMessage()); + return CommandResult.FAILURE; + } + + View view = views.stream() + .filter(v -> v.name().equalsIgnoreCase(viewName)) + .findFirst() + .orElse(null); + if (view == null) { + invocation.println("View '" + viewName + "' not found"); + return CommandResult.FAILURE; + } + + List components = new ArrayList<>(view.components() != null ? view.components() : List.of()); + + if (addNode != null) { + return handleAdd(invocation, view, components); + } else if (removeNode != null) { + return handleRemove(invocation, view, components); + } else if (reorderNode != null) { + return handleReorder(invocation, view, components); + } else { + invocation.println("Specify --add, --remove, or --reorder"); + return CommandResult.FAILURE; + } + } + + private CommandResult handleAdd(H5mCommandInvocation invocation, View view, List components) { + NodeGroup group = nodeGroupService.byName(folderName); + if (group == null) { + invocation.println("Node group for folder '" + folderName + "' not found"); + return CommandResult.FAILURE; + } + + // Try to find the node by name + List found = nodeService.findNodeByFqdn(addNode, group.id()); + if (found.isEmpty()) { + invocation.println("Node '" + addNode + "' not found in folder '" + folderName + "'"); + return CommandResult.FAILURE; + } + if (found.size() > 1) { + invocation.println("'" + addNode + "' is ambiguous, matched multiple nodes:"); + for (Node n : found) { + invocation.println(" " + n.fqdn()); + } + return CommandResult.FAILURE; + } + + Node node = found.getFirst(); + String header = headerName != null ? headerName : node.name(); + if (header.length() > 20) { + header = header.substring(0, 20); + } + + int order = components.size(); + ViewComponent newComp = new ViewComponent(null, node.id(), node.name(), node.type().display(), header, order); + components.add(newComp); + + try { + View updated = new View(view.id(), view.name(), view.folderId(), components); + viewService.updateView(view.id(), updated); + invocation.println("Added column '" + header + "' (node: " + node.name() + ") to view '" + view.name() + "'"); + } catch (Exception e) { + invocation.println("Failed to update view: " + e.getMessage()); + return CommandResult.FAILURE; + } + return CommandResult.SUCCESS; + } + + private CommandResult handleRemove(H5mCommandInvocation invocation, View view, List components) { + ViewComponent toRemove = components.stream() + .filter(c -> c.nodeName().equalsIgnoreCase(removeNode) || c.headerName().equalsIgnoreCase(removeNode)) + .findFirst() + .orElse(null); + if (toRemove == null) { + invocation.println("Column '" + removeNode + "' not found in view '" + view.name() + "'"); + return CommandResult.FAILURE; + } + + components.remove(toRemove); + // Re-index the remaining components + List reindexed = new ArrayList<>(); + for (int i = 0; i < components.size(); i++) { + ViewComponent c = components.get(i); + reindexed.add(new ViewComponent(c.id(), c.nodeId(), c.nodeName(), c.nodeType(), c.headerName(), i)); + } + + try { + View updated = new View(view.id(), view.name(), view.folderId(), reindexed); + viewService.updateView(view.id(), updated); + invocation.println("Removed column '" + removeNode + "' from view '" + view.name() + "'"); + } catch (Exception e) { + invocation.println("Failed to update view: " + e.getMessage()); + return CommandResult.FAILURE; + } + return CommandResult.SUCCESS; + } + + private CommandResult handleReorder(H5mCommandInvocation invocation, View view, List components) { + if (position == null) { + invocation.println("--position is required when using --reorder"); + return CommandResult.FAILURE; + } + + ViewComponent toMove = components.stream() + .filter(c -> c.nodeName().equalsIgnoreCase(reorderNode) || c.headerName().equalsIgnoreCase(reorderNode)) + .findFirst() + .orElse(null); + if (toMove == null) { + invocation.println("Column '" + reorderNode + "' not found in view '" + view.name() + "'"); + return CommandResult.FAILURE; + } + + components.remove(toMove); + int pos = Math.max(0, Math.min(position, components.size())); + components.add(pos, toMove); + + // Re-index + List reindexed = new ArrayList<>(); + for (int i = 0; i < components.size(); i++) { + ViewComponent c = components.get(i); + reindexed.add(new ViewComponent(c.id(), c.nodeId(), c.nodeName(), c.nodeType(), c.headerName(), i)); + } + + try { + View updated = new View(view.id(), view.name(), view.folderId(), reindexed); + viewService.updateView(view.id(), updated); + invocation.println("Moved column '" + reorderNode + "' to position " + pos + " in view '" + view.name() + "'"); + } catch (Exception e) { + invocation.println("Failed to update view: " + e.getMessage()); + return CommandResult.FAILURE; + } + return CommandResult.SUCCESS; + } +} diff --git a/src/main/java/io/hyperfoil/tools/h5m/queue/WorkQueue.java b/src/main/java/io/hyperfoil/tools/h5m/queue/WorkQueue.java index 9f6090b3..f73790c5 100644 --- a/src/main/java/io/hyperfoil/tools/h5m/queue/WorkQueue.java +++ b/src/main/java/io/hyperfoil/tools/h5m/queue/WorkQueue.java @@ -2,7 +2,7 @@ import io.hyperfoil.tools.h5m.api.NodeType; import io.hyperfoil.tools.h5m.entity.work.Work; -import io.vertx.core.impl.ConcurrentHashSet; +import java.util.concurrent.ConcurrentHashMap; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -26,8 +26,8 @@ public class WorkQueue implements BlockingQueue { private static final Logger log = LoggerFactory.getLogger(WorkQueue.class); //TODO using counters blocks work on different values, change to set of active work //private Counters counters = new Counters<>(); - private Set activeWork = new ConcurrentHashSet<>(); - private Set pendingWork = new ConcurrentHashSet<>(); + private Set activeWork = ConcurrentHashMap.newKeySet(); + private Set pendingWork = ConcurrentHashMap.newKeySet(); private final AtomicInteger deferredCount = new AtomicInteger(0); private final ReentrantLock takeLock = new ReentrantLock(); diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index e58ff8b7..4f0e83eb 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -64,6 +64,12 @@ quarkus.smallrye-openapi.store-schema-directory=src/main/webui/ # quinoa quarkus.quinoa.enable-spa-routing=true +# aesh configuration +quarkus.aesh.prompt=[h5m]$ +quarkus.aesh.persist-history=true +quarkus.aesh.history-file=.h5m_history +quarkus.aesh.history-size=1000 + #this disables the INFO messages from the cli quarkus.log.category."io.quarkus".level=ERROR quarkus.log.category."org.hibernate".level=ERROR diff --git a/src/test/java/io/hyperfoil/tools/h5m/cli/H5mTest.java b/src/test/java/io/hyperfoil/tools/h5m/cli/H5mTest.java index 37853590..333a07f3 100644 --- a/src/test/java/io/hyperfoil/tools/h5m/cli/H5mTest.java +++ b/src/test/java/io/hyperfoil/tools/h5m/cli/H5mTest.java @@ -1,17 +1,22 @@ package io.hyperfoil.tools.h5m.cli; + import io.quarkus.test.junit.TestProfile; -import io.quarkus.test.junit.main.LaunchResult; import io.quarkus.test.junit.main.QuarkusMainLauncher; import io.quarkus.test.junit.main.QuarkusMainTest; +import io.quarkus.test.aesh.AeshLauncher; +import io.quarkus.test.aesh.AeshLauncherImpl; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInfo; import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; -import java.util.Arrays; +import java.time.Duration; +import java.util.ArrayList; import java.util.List; import java.util.Objects; @@ -20,14 +25,52 @@ @QuarkusMainTest @TestProfile(CliProfile.class) public class H5mTest { - public static List run(QuarkusMainLauncher launcher,String[]... args){ - return Arrays.stream(args).map(arg->{ - System.out.println("run: "+Arrays.toString(arg)); - return launcher.launch(arg); - }).toList(); + + private AeshLauncher aeshLauncher; + + private static final Duration CMD_TIMEOUT = Duration.ofSeconds(30); + + /** + * Convert a String[] of args into a single command string for the REPL. + * Arguments containing spaces, newlines, or special characters are quoted. + * Uses single quotes for arguments with double quotes (aesh doesn't handle + * backslash-escaped quotes inside double-quoted strings). + */ + private static String toCommand(String[] args) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < args.length; i++) { + if (i > 0) sb.append(' '); + String arg = args[i]; + boolean needsQuoting = arg.contains(" ") || arg.contains("\n") || arg.contains("\"") || arg.contains("{") || arg.contains("}") || arg.contains("|"); + if (!needsQuoting) { + sb.append(arg); + } else if (arg.contains("\"") && !arg.contains("'")) { + // Use single quotes to preserve double quotes literally + sb.append("'").append(arg).append("'"); + } else { + // Default: double-quote and escape internal double quotes + sb.append('"').append(arg.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n")).append('"'); + } + } + return sb.toString(); } + + public static List run(AeshLauncher launcher, String[]... args) { + List outputs = new ArrayList<>(); + for (String[] arg : args) { + String command = toCommand(arg); + long start = System.currentTimeMillis(); + String output = launcher.executeCommand(command, CMD_TIMEOUT); + long elapsed = System.currentTimeMillis() - start; + System.out.printf("run (%dms): %s%n", elapsed, command); + outputs.add(output); + } + return outputs; + } + @BeforeEach - public void dropDb(){ + public void setup(QuarkusMainLauncher launcher) { + ///tmp/h5m-test.db-shm, /tmp/h5m-test.db-wal, /tmp/h5m-test.db String path = CliProfile.TEST_DB_PATH; List.of("","-shm","-wal").forEach(suffix->{ File f = new File(path+suffix); @@ -35,157 +78,147 @@ public void dropDb(){ f.delete(); } }); + aeshLauncher = new AeshLauncherImpl(launcher); + aeshLauncher.launch(); + } + + @AfterEach + public void teardown() { + if (aeshLauncher != null) { + aeshLauncher.exit(); + } } //disabled so it doesn't fail a build //This test requires a running Horreum backup on port 6000 with username / password = horreum / horreum @Test @Disabled - public void loadLegacyTests(QuarkusMainLauncher launcher){ - LaunchResult result = null; - result = launcher.launch("load-legacy-tests","username=horreum","password=horreum","url=jdbc:postgresql://0.0.0.0:6000/horreum"); - System.out.println("exitCode="+result.exitCode()); - assertEquals(0,result.exitCode()); - + public void loadLegacyTests(){ + String output = aeshLauncher.executeCommand("legacy load-tests username=horreum password=horreum url=jdbc:postgresql://0.0.0.0:6000/horreum", CMD_TIMEOUT); + System.out.println("output="+output); + assertNotNull(output); } @Test @Disabled - public void loadLegacyRuns(QuarkusMainLauncher launcher){ - LaunchResult result = null; - result = launcher.launch("load-legacy-tests","testId=339","username=horreum","password=horreum","url=jdbc:postgresql://0.0.0.0:6000/horreum"); - assertEquals(0,result.exitCode()); - result = launcher.launch("load-legacy-runs","testId=339","limit=1","username=horreum","password=horreum","url=jdbc:postgresql://0.0.0.0:6000/horreum"); - System.out.println("exitCode="+result.exitCode()); - assertEquals(0,result.exitCode()); + public void loadLegacyRuns(){ + aeshLauncher.executeCommand("legacy load-tests testId=391 username=horreum password=horreum url=jdbc:postgresql://0.0.0.0:6000/horreum", CMD_TIMEOUT); + String output = aeshLauncher.executeCommand("legacy load-runs testId=391 username=horreum password=horreum url=jdbc:postgresql://0.0.0.0:6000/horreum", CMD_TIMEOUT); + System.out.println("output="+output); + assertNotNull(output); } @Test - public void list(QuarkusMainLauncher launcher) { - LaunchResult result = launcher.launch("list"); - assertEquals(0,result.exitCode(),result.getOutput()); + public void list() { + String output = aeshLauncher.executeCommand("folder list", CMD_TIMEOUT); + assertNotNull(output); } @Test - public void help(QuarkusMainLauncher launcher) { - LaunchResult result = launcher.launch("help"); - assertEquals(0,result.exitCode(),result.getOutput()); + public void help() { + String output = aeshLauncher.executeCommand("help", CMD_TIMEOUT); + assertNotNull(output); } @Test - public void add_folder(QuarkusMainLauncher launcher) { + public void add_folder() { String testName = StackWalker.getInstance() .walk(s -> s.skip(0).findFirst()) .get() .getMethodName(); - List results = run(launcher, - new String[]{"add","folder",testName}, - new String[]{"list","folders"} + List results = run(aeshLauncher, + new String[]{"folder","add",testName}, + new String[]{"folder","list"} ); - results.forEach(result->{ - assertEquals(0,result.exitCode(),result.getOutput()); - }); - LaunchResult result = results.getLast(); - assertTrue(result.getOutput().contains(testName),result.getOutput()); + String output = results.getLast(); + assertTrue(output.contains(testName),output); } @Test - public void list_folder(QuarkusMainLauncher launcher) { - List results = run(launcher, - new String[]{"add","folder","foo"}, - new String[]{"add","folder","bar"} + public void list_folder() { + List results = run(aeshLauncher, + new String[]{"folder","add","foo"}, + new String[]{"folder","add","bar"} ); - results.forEach(result->{ - assertEquals(0,result.exitCode(),result.getOutput()); - }); - LaunchResult result = results.getLast(); - for(List command : List.of(List.of("list","folder"),List.of("list","folders"))){ - result = launcher.launch(command.toArray(new String[0])); - assertEquals(0,result.exitCode(),result.getOutput()); - assertTrue(result.getOutput().contains("foo"),"expect to find foo folder:\n"+result.getOutput()); - assertTrue(result.getOutput().contains("bar"),"expect to find bar folder:\n"+result.getOutput()); + for(List command : List.of(List.of("folder","list"),List.of("folder","list"))){ + String output = aeshLauncher.executeCommand(String.join(" ", command), CMD_TIMEOUT); + assertTrue(output.contains("foo"),"expect to find foo folder:\n"+output); + assertTrue(output.contains("bar"),"expect to find bar folder:\n"+output); } } @Test - public void remove_folder(QuarkusMainLauncher launcher) { + public void remove_folder() { String testName = StackWalker.getInstance() .walk(s -> s.skip(0).findFirst()) .get() .getMethodName(); - List results = run(launcher, - new String[]{"add","folder",testName}, - new String[]{"remove","folder",testName}, - new String[]{"list","folders"} + List results = run(aeshLauncher, + new String[]{"folder","add",testName}, + new String[]{"folder","remove",testName}, + new String[]{"folder","list"} ); - results.forEach(result->{ - assertEquals(0,result.exitCode(),result.getOutput()); - }); - LaunchResult result = results.getLast(); - assertFalse(result.getOutput().contains(testName),"expect to not find foo folder: "+result.getOutput()); + String output = results.getLast(); + assertFalse(output.contains(testName),"expect to not find foo folder: "+output); } @Test - public void add_js_uses_other_nodes(QuarkusMainLauncher launcher) { + public void add_js_uses_other_nodes() { String testName = StackWalker.getInstance() .walk(s -> s.skip(0).findFirst()) .get() .getMethodName(); - List results = run(launcher, - new String[]{"add","folder",testName}, - new String[]{"add","jq","to",testName,"foo",".buz"}, - new String[]{"add","jq","to",testName,"bar",".bar"}, - new String[]{"add","jq","to",testName,"biz",".biz"}, - new String[]{"add","js","to",testName,"dataset","function* dataset({foo, bar, biz}){\nyield foo;\nyield bar;\nyield biz;\n}"}, - new String[]{"list",testName,"nodes"}, - new String[]{"list","nodes","from",testName} - ); - results.forEach(result->{ - assertEquals(0,result.exitCode(),result.getOutput()); - }); - + List results = run(aeshLauncher, + new String[]{"folder","add",testName}, + new String[]{"cd",testName}, + new String[]{"node","add","jq","foo",".buz"}, + new String[]{"node","add","jq","bar",".bar"}, + new String[]{"node","add","jq","biz",".biz"}, + new String[]{"node","add","js","dataset","function* dataset({foo, bar, biz}){\nyield foo;\nyield bar;\nyield biz;\n}"}, + new String[]{"node","list"}, + new String[]{"node","list"}, + new String[]{"cd",".."} + ); + // All commands should succeed without throwing } @Test - public void add_jq_list_node(QuarkusMainLauncher launcher) { + public void add_jq_list_node() { + // Uses explicit --to/--from (no cd) to verify that path works String testName = StackWalker.getInstance() .walk(s -> s.skip(0).findFirst()) .get() .getMethodName(); - List results = run(launcher, - new String[]{"add","folder",testName}, - new String[]{"add","jq","to",testName,"buz",".buz"}, - new String[]{"add","jq","to",testName,"bizzing","{buz}:.biz"}, - new String[]{"list",testName,"nodes"}, - new String[]{"list","nodes","from",testName} - ); - results.forEach(result->{ - assertEquals(0,result.exitCode(),result.getOutput()); - }); - LaunchResult result = results.getLast(); - assertTrue(result.getOutput().contains("biz"),"expect to find biz: "+result.getOutput()); + List results = run(aeshLauncher, + new String[]{"folder","add",testName}, + new String[]{"node","add","jq","--to",testName,"buz",".buz"}, + new String[]{"node","add","jq","--to",testName,"bizzing","{buz}:.biz"}, + new String[]{"node","list","--from",testName}, + new String[]{"node","list","--from",testName} + ); + String output = results.getLast(); + assertTrue(output.contains("biz"),"expect to find biz: "+output); } @Test - public void add_relativedifference_list_node(QuarkusMainLauncher launcher) { + public void add_relativedifference_list_node() { String testName = StackWalker.getInstance() .walk(s -> s.skip(0).findFirst()) .get() .getMethodName(); - List results = run(launcher, - new String[]{"add","folder",testName}, - new String[]{"add","jq","to",testName,"domainNode",".x"}, - new String[]{"add","jq","to",testName,"rangeNode",".y"}, - new String[]{"add","jq","to",testName,"fp1",".fp1"}, - new String[]{"add","jq","to",testName,"fp2",".fp2"}, - new String[]{"list",testName,"nodes"}, - new String[]{"add","relativedifference","rd1","to",testName,"range","rangeNode","domain","domainNode","fingerprint","fp1,fp2"}, - new String[]{"list",testName,"nodes"} - ); - results.forEach(result->{ - assertEquals(0,result.exitCode(),result.getOutput()); - }); - LaunchResult result = results.getLast(); - assertTrue(result.getOutput().contains("rd1"),"expect to find rd1: "+result.getOutput()); + List results = run(aeshLauncher, + new String[]{"folder","add",testName}, + new String[]{"cd",testName}, + new String[]{"node","add","jq","domainNode",".x"}, + new String[]{"node","add","jq","rangeNode",".y"}, + new String[]{"node","add","jq","fp1",".fp1"}, + new String[]{"node","add","jq","fp2",".fp2"}, + new String[]{"node","list"}, + new String[]{"node","add","relativedifference","rd1","--range","rangeNode","--domain","domainNode","--fingerprint","fp1,fp2"}, + new String[]{"node","list"}, + new String[]{"cd",".."} + ); + String output = results.get(results.size() - 2); + assertTrue(output.contains("rd1"),"expect to find rd1: "+output); } @Test - public void calculate_relativedifference_node(QuarkusMainLauncher launcher) throws IOException { + public void calculate_relativedifference_node() throws IOException { String testName = StackWalker.getInstance() .walk(s -> s.skip(0).findFirst()) .get() @@ -212,29 +245,28 @@ public void calculate_relativedifference_node(QuarkusMainLauncher launcher) thro } """ ); - List results = run(launcher, - new String[]{"add","folder",testName}, - new String[]{"add","jq","to",testName,"domainNode",".x"}, - new String[]{"add","jq","to",testName,"rangeNode",".y"}, - new String[]{"add","jq","to",testName,"fp1",".fp1"}, - new String[]{"list",testName,"nodes"}, - new String[]{"add","relativedifference","relativediff","to",testName,"range","rangeNode","domain","domainNode","fingerprint","fp1","window","1","minPrevious","1"}, - new String[]{"list",testName,"nodes"}, - new String[]{"upload",filePath01.toString(),"to",testName}, - new String[]{"upload",filePath02.toString(),"to",testName}, - new String[]{"upload",filePath03.toString(),"to",testName}, - new String[]{"list","value","from",testName} - ); - results.forEach(result->{ - assertEquals(0,result.exitCode(),result.getOutput()); - }); + List results = run(aeshLauncher, + new String[]{"folder","add",testName}, + new String[]{"cd",testName}, + new String[]{"node","add","jq","domainNode",".x"}, + new String[]{"node","add","jq","rangeNode",".y"}, + new String[]{"node","add","jq","fp1",".fp1"}, + new String[]{"node","list"}, + new String[]{"node","add","relativedifference","relativediff","--range","rangeNode","--domain","domainNode","--fingerprint","fp1","--window","1","--minPrevious","1"}, + new String[]{"node","list"}, + new String[]{"upload",filePath01.toString()}, + new String[]{"upload",filePath02.toString()}, + new String[]{"upload",filePath03.toString()}, + new String[]{"folder","values"}, + new String[]{"cd",".."} + ); - LaunchResult last = results.getLast(); - assertTrue(last.getOutput().contains("Count: 13"),"expect 13 values from test"); + String last = results.get(results.size() - 2); + assertTrue(last.contains("Count: 13"),"expect 13 values from test"); } @Disabled("There should be only changes detected for x = 2 and x = 12 but there are two other detected for x = 3 and x = 13") @Test - public void calculate_relativedifference_dataset_node(QuarkusMainLauncher launcher) throws IOException { + public void calculate_relativedifference_dataset_node() throws IOException { String testName = StackWalker.getInstance() .walk(s -> s.skip(0).findFirst()) .get() @@ -270,51 +302,49 @@ public void calculate_relativedifference_dataset_node(QuarkusMainLauncher launch } """ ); - List results = run(launcher, - new String[]{"add","folder",testName}, - new String[]{"add","jq","to",testName,"split",".each[]"}, - new String[]{"add","jq","to",testName,"domainNode","{split}:.x"}, - new String[]{"add","jq","to",testName,"rangeNode","{split}:.y"}, - new String[]{"add","jq","to",testName,"fp1","{split}:.fp1"}, - new String[]{"add","jq","to",testName,"fp2","{split}:.fp2"}, - new String[]{"list",testName,"nodes"}, - new String[]{"add","relativedifference","relativediff","to",testName,"range","rangeNode","domain","domainNode","by","split","fingerprint","fp1,fp2","window","1","minPrevious","1"}, - new String[]{"list",testName,"nodes"}, - new String[]{"upload",filePath01.toString(),"to",testName}, - new String[]{"upload",filePath02.toString(),"to",testName}, - new String[]{"upload",filePath03.toString(),"to",testName}, - new String[]{"list","value","from",testName} - ); - results.forEach(result->{ - assertEquals(0,result.exitCode(),result.getOutput()); - }); + List results = run(aeshLauncher, + new String[]{"folder","add",testName}, + new String[]{"cd",testName}, + new String[]{"node","add","jq","split",".each[]"}, + new String[]{"node","add","jq","domainNode","{split}:.x"}, + new String[]{"node","add","jq","rangeNode","{split}:.y"}, + new String[]{"node","add","jq","fp1","{split}:.fp1"}, + new String[]{"node","add","jq","fp2","{split}:.fp2"}, + new String[]{"node","list"}, + new String[]{"node","add","relativedifference","relativediff","--range","rangeNode","--domain","domainNode","--by","split","--fingerprint","fp1,fp2","--window","1","--minPrevious","1"}, + new String[]{"node","list"}, + new String[]{"upload",filePath01.toString()}, + new String[]{"upload",filePath02.toString()}, + new String[]{"upload",filePath03.toString()}, + new String[]{"folder","values"}, + new String[]{"cd",".."} + ); - LaunchResult last = results.getLast(); - assertTrue(last.getOutput().contains("Count: 38"),"expect 38 values from test"); + String last = results.get(results.size() - 2); + assertTrue(last.contains("Count: 38"),"expect 38 values from test"); } @Test - public void remove_node(QuarkusMainLauncher launcher) { + public void remove_node() { String testName = StackWalker.getInstance() .walk(s -> s.skip(0).findFirst()) .get() .getMethodName(); - List results = run(launcher, - new String[]{"add","folder",testName}, - new String[]{"add","jq","to",testName,"biz",".biz"}, - new String[]{"list",testName,"nodes"}, - new String[]{"remove","node","biz","from",testName}, - new String[]{"list",testName,"nodes"} - ); - results.forEach(result->{ - assertEquals(0,result.exitCode(),result.getOutput()); - }); - LaunchResult result = results.getLast(); - assertFalse(result.getOutput().contains("biz"),"expect to NOT find biz: "+result.getOutput()); + List results = run(aeshLauncher, + new String[]{"folder","add",testName}, + new String[]{"cd",testName}, + new String[]{"node","add","jq","biz",".biz"}, + new String[]{"node","list"}, + new String[]{"node","remove","biz"}, + new String[]{"node","list"}, + new String[]{"cd",".."} + ); + String output = results.get(results.size() - 2); + assertFalse(output.contains("biz"),"expect to NOT find biz: "+output); } @Test - public void upload_list_values(QuarkusMainLauncher launcher) throws IOException { + public void upload_list_values() throws IOException { String testName = StackWalker.getInstance() .walk(s -> s.skip(0).findFirst()) .get() @@ -332,28 +362,25 @@ public void upload_list_values(QuarkusMainLauncher launcher) throws IOException """ ); //filePath.toFile().deleteOnExit(); - List results = run(launcher, - new String[]{"add","folder",testName}, - new String[]{"add","jq","to",testName,"foo",".foo"}, - new String[]{"add","jq","to",testName,"bar","{foo}:.bar"}, - new String[]{"add","jq","to",testName,"biz","{bar}:.biz"}, - new String[]{"list",testName,"nodes",}, - new String[]{"upload",folder.toString(),"to",testName}, - new String[]{"list","value","from",testName} - ); - results.forEach(result->{ - assertEquals(0,result.exitCode(),result.getOutput()); - }); + // Uses explicit --to/--from (no cd) to verify that path works + List results = run(aeshLauncher, + new String[]{"folder","add",testName}, + new String[]{"node","add","jq","--to",testName,"foo",".foo"}, + new String[]{"node","add","jq","--to",testName,"bar","{foo}:.bar"}, + new String[]{"node","add","jq","--to",testName,"biz","{bar}:.biz"}, + new String[]{"node","list","--from",testName}, + new String[]{"upload",folder.toString(),"--to",testName}, + new String[]{"folder","values","--from",testName} + ); - LaunchResult result = results.getLast(); - assertTrue(result.getOutput().contains("Count: 3")); - // Intermediate nodes (foo, bar) have ephemeral=AUTO and their data is nullified - assertTrue(result.getOutput().contains(" null "),"intermediate values should be null:" +result.getOutput()); - // Only the leaf node (biz) retains its data - assertTrue(result.getOutput().contains(" buz "),"result should contain leaf value buz:" +result.getOutput()); + String output = results.getLast(); + assertTrue(output.contains("Count: 3")); + // Intermediate nodes (foo, bar) are ephemeral — their data is nullified after processing. + // Only the leaf node (biz) retains its data. + assertTrue(output.contains(" buz "),"result should contain leaf value buz:" +output); } @Test - public void upload_folder_list_values(QuarkusMainLauncher launcher) throws IOException { + public void upload_folder_list_values() throws IOException { String testName = StackWalker.getInstance() .walk(s -> s.skip(0).findFirst()) .get() @@ -382,29 +409,27 @@ public void upload_folder_list_values(QuarkusMainLauncher launcher) throws IOExc """ ); //filePath.toFile().deleteOnExit(); - List results = run(launcher, - new String[]{"add","folder",testName}, - new String[]{"add","jq","to",testName,"foo",".foo"}, - new String[]{"add","jq","to",testName,"bar","{foo}:.bar"}, - new String[]{"add","jq","to",testName,"biz","{bar}:.biz"}, - new String[]{"list",testName,"nodes",}, - new String[]{"upload",folder.toString(),"to",testName}, - new String[]{"list","value","from",testName} - ); - results.forEach(result->{ - assertEquals(0,result.exitCode(),result.getOutput()); - }); + List results = run(aeshLauncher, + new String[]{"folder","add",testName}, + new String[]{"cd",testName}, + new String[]{"node","add","jq","foo",".foo"}, + new String[]{"node","add","jq","bar","{foo}:.bar"}, + new String[]{"node","add","jq","biz","{bar}:.biz"}, + new String[]{"node","list"}, + new String[]{"upload",folder.toString()}, + new String[]{"folder","values"}, + new String[]{"cd",".."} + ); - LaunchResult result = results.getLast(); - assertTrue(result.getOutput().contains("Count: 6")); - // Intermediate nodes (foo, bar) have ephemeral=AUTO and their data is nullified - assertTrue(result.getOutput().contains(" null "),"intermediate values should be null:" +result.getOutput()); - // Only the leaf node (biz) retains its data - assertTrue(result.getOutput().contains(" buz "),"result should contain leaf value buz:" +result.getOutput()); - assertTrue(result.getOutput().contains(" bur "),"result should contain leaf value bur:" +result.getOutput()); + String output = results.get(results.size() - 2); + assertTrue(output.contains("Count: 6")); + // Intermediate nodes (foo, bar) are ephemeral — their data is nullified after processing. + // Only the leaf node (biz) retains its data. + assertTrue(output.contains(" buz "),"result should contain leaf value buz:" +output); + assertTrue(output.contains(" bur "),"result should contain leaf value bur:" +output); } @Test - public void upload_jsonata_list_values(QuarkusMainLauncher launcher) throws IOException { + public void upload_jsonata_list_values() throws IOException { String testName = StackWalker.getInstance() .walk(s -> s.skip(0).findFirst()) .get() @@ -422,28 +447,25 @@ public void upload_jsonata_list_values(QuarkusMainLauncher launcher) throws IOEx """ ); //filePath.toFile().deleteOnExit(); - List results = run(launcher, - new String[]{"add","folder",testName}, - new String[]{"add","jsonata","to",testName,"foo","foo"}, - new String[]{"add","jsonata","to",testName,"bar","{foo}:bar"}, - new String[]{"add","jsonata","to",testName,"biz","{bar}:biz"}, - new String[]{"list",testName,"nodes",}, - new String[]{"upload",folder.toString(),"to",testName}, - new String[]{"list","value","from",testName} - ); - results.forEach(result->{ - assertEquals(0,result.exitCode(),result.getOutput()); - }); + List results = run(aeshLauncher, + new String[]{"folder","add",testName}, + new String[]{"cd",testName}, + new String[]{"node","add","jsonata","foo","foo"}, + new String[]{"node","add","jsonata","bar","{foo}:bar"}, + new String[]{"node","add","jsonata","biz","{bar}:biz"}, + new String[]{"node","list"}, + new String[]{"upload",folder.toString()}, + new String[]{"folder","values"}, + new String[]{"cd",".."} + ); - LaunchResult result = results.getLast(); - assertTrue(result.getOutput().contains("Count: 3"),"expect 3 values\n"+result.getOutput()); - // Intermediate nodes (foo, bar) have ephemeral=AUTO and their data is nullified - assertTrue(result.getOutput().contains(" null "),"intermediate values should be null:" +result.getOutput()); - // Only the leaf node (biz) retains its data - assertTrue(result.getOutput().contains(" buz "),"result should contain leaf value buz:" +result.getOutput()); + String output = results.get(results.size() - 2); + assertTrue(output.contains("Count: 3"),"expect 3 values\n"+output); + // Intermediate nodes (foo, bar) are ephemeral — only leaf node (biz) retains data. + assertTrue(output.contains(" buz "),"result should contain leaf value buz:" +output); } @Test - public void upload_sqlpath_list_values(QuarkusMainLauncher launcher) throws IOException { + public void upload_sqlpath_list_values() throws IOException { String testName = StackWalker.getInstance() .walk(s -> s.skip(0).findFirst()) .get() @@ -461,28 +483,25 @@ public void upload_sqlpath_list_values(QuarkusMainLauncher launcher) throws IOEx """ ); //filePath.toFile().deleteOnExit(); - List results = run(launcher, - new String[]{"add","folder",testName}, - new String[]{"add","jq","to",testName,"foo",".foo"}, - new String[]{"add","jq","to",testName,"bar","{foo}:.bar"}, - new String[]{"add","jq","to",testName,"biz","{bar}:.biz"}, - new String[]{"list",testName,"nodes",}, - new String[]{"upload",folder.toString(),"to",testName}, - new String[]{"list","value","from",testName} - ); - results.forEach(result->{ - assertEquals(0,result.exitCode(),result.getOutput()); - }); + List results = run(aeshLauncher, + new String[]{"folder","add",testName}, + new String[]{"cd",testName}, + new String[]{"node","add","sqlpath","foo","$.foo"}, + new String[]{"node","add","sqlpath","bar","{foo}:$.bar"}, + new String[]{"node","add","sqlpath","biz","{bar}:$.biz"}, + new String[]{"node","list"}, + new String[]{"upload",folder.toString()}, + new String[]{"folder","values"}, + new String[]{"cd",".."} + ); - LaunchResult result = results.getLast(); - assertTrue(result.getOutput().contains("Count: 3"),"expect 3 values\n"+result.getOutput()); - // Intermediate nodes (foo, bar) have ephemeral=AUTO and their data is nullified - assertTrue(result.getOutput().contains(" null "),"intermediate values should be null:" +result.getOutput()); - // Only the leaf node (biz) retains its data - assertTrue(result.getOutput().contains(" buz "),"result should contain leaf value buz:" +result.getOutput()); + String output = results.get(results.size() - 2); + assertTrue(output.contains("Count: 3"),"expect 3 values\n"+output); + // Intermediate nodes (foo, bar) are ephemeral — only leaf node (biz) retains data. + assertTrue(output.contains(" buz "),"result should contain leaf value buz:" +output); } @Test - public void upload_list_values_by_node(QuarkusMainLauncher launcher) throws IOException { + public void upload_list_values_by_node() throws IOException { String testName = StackWalker.getInstance() .walk(s -> s.skip(0).findFirst()) .get() @@ -508,25 +527,24 @@ public void upload_list_values_by_node(QuarkusMainLauncher launcher) throws IOEx """ ); //filePath.toFile().deleteOnExit(); - List results = run(launcher, - new String[]{"add","folder",testName}, - new String[]{"add","jq","to",testName,"foo",".foo[]"},//this should act like a dataset - new String[]{"add","jq","to",testName,"name","{foo}:.name"}, - new String[]{"add","jq","to",testName,"bar","{foo}:.bar"}, - new String[]{"add","jq","to",testName,"biz","{bar}:.biz[] + \"-it\""},//this should also split into a dataset - new String[]{"list",testName,"nodes"}, - new String[]{"upload",folder.toString(),"to",testName}, - new String[]{"list","value","from",testName,"by","foo"} - ); - results.forEach(result->{ - assertEquals(0,result.exitCode(),result.getOutput()); - }); + List results = run(aeshLauncher, + new String[]{"folder","add",testName}, + new String[]{"cd",testName}, + new String[]{"node","add","jq","foo",".foo[]"},//this should act like a dataset + new String[]{"node","add","jq","name","{foo}:.name"}, + new String[]{"node","add","jq","bar","{foo}:.bar"}, + new String[]{"node","add","jq","biz","{bar}:.biz[] + \"-it\""},//this should also split into a dataset + new String[]{"node","list"}, + new String[]{"upload",folder.toString()}, + new String[]{"folder","values","--by","foo"}, + new String[]{"cd",".."} + ); - LaunchResult last = results.getLast(); - assertTrue(last.getOutput().contains("Count: 2"),"expect to find 2 results by foo"); + String last = results.get(results.size() - 2); + assertTrue(last.contains("Count: 2"),"expect to find 2 results by foo"); } @Test - public void upload_jq_multi_input(QuarkusMainLauncher launcher) throws IOException { + public void upload_jq_multi_input() throws IOException { String testName = StackWalker.getInstance() .walk(s -> s.skip(0).findFirst()) .get() @@ -542,26 +560,24 @@ public void upload_jq_multi_input(QuarkusMainLauncher launcher) throws IOExcepti } """ ); - List results = run(launcher, - new String[]{"add","folder",testName}, - new String[]{"add","jq","to",testName,"foo",".foo[]"}, - new String[]{"add","jq","to",testName,"cpu","{foo}:.cpu"}, - new String[]{"add","jq","to",testName,"mem","{foo}:.mem"}, - new String[]{"add","jq","to",testName,"fingerprint","{mem,cpu}:."}, - new String[]{"list",testName,"nodes"}, - new String[]{"upload",folder.toString(),"to",testName}, - new String[]{"list","value","from",testName} + List results = run(aeshLauncher, + new String[]{"folder","add",testName}, + new String[]{"cd",testName}, + new String[]{"node","add","jq","foo",".foo[]"}, + new String[]{"node","add","jq","cpu","{foo}:.cpu"}, + new String[]{"node","add","jq","mem","{foo}:.mem"}, + new String[]{"node","add","fingerprint","{mem,cpu}:."}, + new String[]{"node","list"}, + new String[]{"upload",folder.toString()}, + new String[]{"folder","values"}, + new String[]{"cd",".."} ); - results.forEach(result->{ - assertEquals(0,result.exitCode(),result.getOutput()); - }); - LaunchResult result = results.getLast(); - assertTrue(result.getOutput().contains("Count: 8")); + String output = results.get(results.size() - 2); + assertTrue(output.contains("Count: 8")); } @Test - @Disabled("Intermediate node values are nullified by ephemeral AUTO logic — needs CLI ephemeral command to mark nodes as KEEP") - public void upload_js_multi_input(QuarkusMainLauncher launcher) throws IOException { + public void upload_js_multi_input() throws IOException { String testName = StackWalker.getInstance() .walk(s -> s.skip(0).findFirst()) .get() @@ -577,26 +593,59 @@ public void upload_js_multi_input(QuarkusMainLauncher launcher) throws IOExcepti } """ ); - List results = run(launcher, - new String[]{"add","folder",testName}, - new String[]{"add","jq","to",testName,"foo",".foo[]"}, - new String[]{"add","jq","to",testName,"cpu","{foo}:.cpu"}, - new String[]{"add","jq","to",testName,"mem","{foo}:.mem"}, - new String[]{"add","js","to",testName,"fingerprint","({mem,cpu})=>({'fromMem':mem,'fromCpu':cpu})"}, - new String[]{"list",testName,"nodes"}, - new String[]{"upload",folder.toString(),"to",testName}, - new String[]{"list","value","from",testName} + List results = run(aeshLauncher, + new String[]{"folder","add",testName}, + new String[]{"cd",testName}, + new String[]{"node","add","jq","foo",".foo[]"}, + new String[]{"node","add","jq","cpu","{foo}:.cpu"}, + new String[]{"node","add","jq","mem","{foo}:.mem"}, + new String[]{"node","add","fingerprint","{mem,cpu}:."}, + new String[]{"node","list"}, + new String[]{"upload",folder.toString()}, + new String[]{"folder","values"}, + new String[]{"cd",".."} ); - results.forEach(result->{ - assertEquals(0,result.exitCode(),result.getOutput()); - }); - LaunchResult result = results.getLast(); - assertTrue(result.getOutput().contains("Count: 8"),"expect to find 8 values\n"+result.getOutput()); - assertFalse(result.getOutput().contains("null")||result.getOutput().contains("NULL"),"list values should not contain null\n"+result.getOutput()); + String output = results.get(results.size() - 2); + assertTrue(output.contains("Count: 8"),"expect to find 8 values\n"+output); } @Test - public void calculate_fixedthreshold_node(QuarkusMainLauncher launcher) throws IOException { + public void recalculate_jq_multi_input() throws IOException { + String testName = StackWalker.getInstance() + .walk(s -> s.skip(0).findFirst()) + .get() + .getMethodName(); + Path folder = Files.createTempDirectory("h5m"); + Path filePath = Files.writeString(Files.createTempFile(folder,"h5m",".json").toAbsolutePath(), + """ + { + "foo":[ + { "mem": "1gb", "cpu": 2}, + { "mem": "2gb", "cpu": 4} + ] + } + """ + ); + List results = run(aeshLauncher, + new String[]{"folder","add",testName}, + new String[]{"cd",testName}, + new String[]{"node","add","jq","foo",".foo[]"}, + new String[]{"node","add","jq","cpu","{foo}:.cpu"}, + new String[]{"node","add","jq","mem","{foo}:.mem"}, + new String[]{"node","add","fingerprint","{mem,cpu}:."}, + new String[]{"node","list"}, + new String[]{"upload",folder.toString()}, + new String[]{"folder","values"}, + new String[]{"folder","recalculate"}, + new String[]{"folder","values"}, + new String[]{"cd",".."} + + ); + String output = results.get(results.size() - 2); + assertTrue(output.contains("Count: 8")); + } + @Test + public void calculate_fixedthreshold_node() throws IOException { String testName = StackWalker.getInstance() .walk(s -> s.skip(0).findFirst()) .get() @@ -626,27 +675,34 @@ public void calculate_fixedthreshold_node(QuarkusMainLauncher launcher) throws I } """ ); - List results = run(launcher, - new String[]{"add", "folder", testName}, - new String[]{"add", "jq", "to", testName, "rangeNode", ".y"}, - new String[]{"add", "jq", "to", testName, "fp1", ".fp1"}, - new String[]{"list", testName, "nodes"}, - new String[]{"add", "fixedthreshold", "ftNode", "to", testName, "range", "rangeNode", "fingerprint", "fp1", "min", "10", "max", "100"}, - new String[]{"list", testName, "nodes"}, - new String[]{"upload", folder.toString(), "to", testName}, - new String[]{"list", "value", "from", testName} + List results = run(aeshLauncher, + new String[]{"folder", "add", testName}, + new String[]{"cd", testName}, + new String[]{"node", "add", "jq", "rangeNode", ".y"}, + new String[]{"node", "add", "jq", "fp1", ".fp1"}, + new String[]{"node", "list"}, + new String[]{"node", "add", "fixedthreshold", "ftNode", "--range", "rangeNode", "--fingerprint", "fp1", "--min", "10", "--max", "100"}, + new String[]{"node", "list"}, + new String[]{"upload", folder.toString()}, + new String[]{"folder", "values"}, + new String[]{"cd", ".."} ); - results.forEach(result -> { - assertEquals(0, result.exitCode(), result.getOutput()); - }); - LaunchResult last = results.getLast(); + // Upload output should contain the processing ID(s) and detection summary + String uploadOutput = results.get(7); // upload command is at index 7 + assertTrue(uploadOutput.contains("processing id:"), "upload should show processing id\n" + uploadOutput); + assertTrue(uploadOutput.contains("Processing complete."), "upload should show completion\n" + uploadOutput); + assertTrue(uploadOutput.contains("2 changes detected"), "upload should show 2 changes\n" + uploadOutput); + assertTrue(uploadOutput.contains("FIXED_THRESHOLD"), "upload should show detection type\n" + uploadOutput); + assertTrue(uploadOutput.contains("fingerprint="), "upload should show fingerprint\n" + uploadOutput); + + String last = results.get(results.size() - 2); // 3 rangeNode values + 3 fp1 values + 3 _fp-ftNode values + 2 fixedthreshold violations = 11 - assertTrue(last.getOutput().contains("Count: 11"), "expect 11 values from test\n" + last.getOutput()); + assertTrue(last.contains("Count: 11"), "expect 11 values from test\n" + last); } @Test - public void list_values_as_table(QuarkusMainLauncher launcher) throws IOException { + public void list_values_as_table() throws IOException { String testName = StackWalker.getInstance() .walk(s -> s.skip(0).findFirst()) .get() @@ -664,31 +720,30 @@ public void list_values_as_table(QuarkusMainLauncher launcher) throws IOExceptio } """ ); - List results = run(launcher, - new String[]{"add","folder",testName}, - new String[]{"add","jq","to",testName,"str",".string"}, - new String[]{"add","jq","to",testName,"version",".version"}, - new String[]{"add","jq","to",testName,"double",".double"}, - new String[]{"add","jq","to",testName,"integer",".integer"}, - new String[]{"add","jq","to",testName,"array",".array"}, - new String[]{"add","jq","to",testName,"obj",".object"}, - new String[]{"upload",folder.toString(),"to",testName}, - new String[]{"list","value","from",testName,"as","table"} + List results = run(aeshLauncher, + new String[]{"folder","add",testName}, + new String[]{"cd",testName}, + new String[]{"node","add","jq","str",".string"}, + new String[]{"node","add","jq","version",".version"}, + new String[]{"node","add","jq","double",".double"}, + new String[]{"node","add","jq","integer",".integer"}, + new String[]{"node","add","jq","array",".array"}, + new String[]{"node","add","jq","obj",".object"}, + new String[]{"upload",folder.toString()}, + new String[]{"folder","values","--as","table"}, + new String[]{"cd",".."} ); - results.forEach(result->{ - assertEquals(0,result.exitCode(),result.getOutput()); - }); - LaunchResult result = results.getLast(); - assertTrue(result.getOutput().contains("Count: 6"),"expect to extract 6 values"); - assertTrue(result.getOutput().contains("│ 1.33"),"double should be truncated\n"+result.getOutput()); - assertFalse(result.getOutput().contains("│ 1.333"),"double should be truncated\n"+result.getOutput()); - assertFalse(result.getOutput().contains("\"example\""),"strings should not be quoted\n"+result.getOutput()); + String output = results.get(results.size() - 2); + assertTrue(output.contains("Count: 6"),"expect to extract 6 values"); + assertTrue(output.contains("│ 1.33"),"double should be truncated\n"+output); + assertFalse(output.contains("│ 1.333"),"double should be truncated\n"+output); + assertFalse(output.contains("\"example\""),"strings should not be quoted\n"+output); } @Test - public void list_values_as_table_group_by(QuarkusMainLauncher launcher) throws IOException { + public void list_values_as_table_group_by() throws IOException { String testName = StackWalker.getInstance() .walk(s -> s.skip(0).findFirst()) .get() @@ -707,28 +762,27 @@ public void list_values_as_table_group_by(QuarkusMainLauncher launcher) throws I } """ ); - List results = run(launcher, - new String[]{"add","folder",testName}, - new String[]{"add","jq","to",testName,"foo",".foo[]"}, - new String[]{"add","jq","to",testName,"str","{foo}:.string"}, - new String[]{"add","jq","to",testName,"version","{foo}:.version"}, - new String[]{"add","jq","to",testName,"double","{foo}:.double"}, - new String[]{"add","jq","to",testName,"integer","{foo}:.integer"}, - new String[]{"add","jq","to",testName,"array","{foo}:.array"}, - new String[]{"add","jq","to",testName,"obj","{foo}:.object"}, - new String[]{"list","nodes","from",testName}, - new String[]{"upload",folder.toString(),"to",testName}, - new String[]{"list","value","from",testName,"by","foo","as","table"} + List results = run(aeshLauncher, + new String[]{"folder","add",testName}, + new String[]{"cd",testName}, + new String[]{"node","add","jq","foo",".foo[]"}, + new String[]{"node","add","jq","str","{foo}:.string"}, + new String[]{"node","add","jq","version","{foo}:.version"}, + new String[]{"node","add","jq","double","{foo}:.double"}, + new String[]{"node","add","jq","integer","{foo}:.integer"}, + new String[]{"node","add","jq","array","{foo}:.array"}, + new String[]{"node","add","jq","obj","{foo}:.object"}, + new String[]{"node","list"}, + new String[]{"upload",folder.toString()}, + new String[]{"folder","values","--by","foo","--as","table"}, + new String[]{"cd",".."} ); - results.forEach(result->{ - assertEquals(0,result.exitCode(),result.getOutput()); - }); - LaunchResult result = results.getLast(); - assertTrue(result.getOutput().contains("Count: 1"),"expect one entry in the table"); - assertFalse(result.getOutput().contains("1.333"),"double should be truncated"); - assertFalse(result.getOutput().contains("\"example\""),"strings should not be quoted"); + String output = results.get(results.size() - 2); + assertTrue(output.contains("Count: 1"),"expect one entry in the table"); + assertFalse(output.contains("1.333"),"double should be truncated"); + assertFalse(output.contains("\"example\""),"strings should not be quoted"); } private Path createFixedThresholdSplitData() throws IOException { @@ -767,26 +821,26 @@ private Path createFixedThresholdSplitData() throws IOException { } @Test - public void calculate_fixedthreshold_with_multiple_parent_values(QuarkusMainLauncher launcher) throws IOException { + public void calculate_fixedthreshold_with_multiple_parent_values() throws IOException { String testName = "calculate_fixedthreshold_with_multiple_parent_values"; Path folder = createFixedThresholdSplitData(); - List results = run(launcher, - new String[]{"add", "folder", testName}, - new String[]{"add", "jq", "to", testName, "itemSplit", ".items[]"}, - new String[]{"add", "jq", "to", testName, "itemName", "{itemSplit}:.x"}, - new String[]{"add", "jq", "to", testName, "rangeNode", "{itemSplit}:.y"}, - new String[]{"add", "jq", "to", testName, "categoryFp", "{itemSplit}:.fp1"}, - new String[]{"add", "fixedthreshold", "ftNode", "to", testName, - "range", "rangeNode", "by", "itemSplit", "fingerprint", "categoryFp", "min", "10", "max", "100"}, - new String[]{"upload", folder.toString(), "to", testName}, - new String[]{"list", "value", "from", testName} + List results = run(aeshLauncher, + new String[]{"folder", "add", testName}, + new String[]{"cd", testName}, + new String[]{"node", "add", "jq", "itemSplit", ".items[]"}, + new String[]{"node", "add", "jq", "itemName", "{itemSplit}:.x"}, + new String[]{"node", "add", "jq", "rangeNode", "{itemSplit}:.y"}, + new String[]{"node", "add", "jq", "categoryFp", "{itemSplit}:.fp1"}, + new String[]{"node", "add", "fixedthreshold", "ftNode", + "--range", "rangeNode", "--by", "itemSplit", "--fingerprint", "categoryFp", "--min", "10", "--max", "100"}, + new String[]{"upload", folder.toString()}, + new String[]{"folder", "values"}, + new String[]{"cd", ".."} ); - results.forEach(result -> assertEquals(0, result.exitCode(), result.getOutput())); - - LaunchResult last = results.getLast(); - String output = last.getOutput(); + String last = results.get(results.size() - 2); + String output = last; assertTrue(output.contains("Count: 33"), "Expected 33 total values\n" + output); @@ -802,26 +856,26 @@ public void calculate_fixedthreshold_with_multiple_parent_values(QuarkusMainLaun } @Test - public void calculate_fixedthreshold_with_multiple_parent_values_with_by_split(QuarkusMainLauncher launcher) throws IOException { + public void calculate_fixedthreshold_with_multiple_parent_values_with_by_split() throws IOException { String testName = "calculate_fixedthreshold_with_multiple_parent_values_with_by_split"; Path folder = createFixedThresholdSplitData(); - List results = run(launcher, - new String[]{"add", "folder", testName}, - new String[]{"add", "jq", "to", testName, "itemSplit", ".items[]"}, - new String[]{"add", "jq", "to", testName, "itemName", "{itemSplit}:.x"}, - new String[]{"add", "jq", "to", testName, "rangeNode", "{itemSplit}:.y"}, - new String[]{"add", "jq", "to", testName, "categoryFp", "{itemSplit}:.fp1"}, - new String[]{"add", "fixedthreshold", "ftNode", "to", testName, - "range", "rangeNode", "by", "itemSplit", "fingerprint", "categoryFp", "min", "10", "max", "100"}, - new String[]{"upload", folder.toString(), "to", testName}, - new String[]{"list", "value", "from", testName, "by", "itemSplit"} + List results = run(aeshLauncher, + new String[]{"folder", "add", testName}, + new String[]{"cd", testName}, + new String[]{"node", "add", "jq", "itemSplit", ".items[]"}, + new String[]{"node", "add", "jq", "itemName", "{itemSplit}:.x"}, + new String[]{"node", "add", "jq", "rangeNode", "{itemSplit}:.y"}, + new String[]{"node", "add", "jq", "categoryFp", "{itemSplit}:.fp1"}, + new String[]{"node", "add", "fixedthreshold", "ftNode", + "--range", "rangeNode", "--by", "itemSplit", "--fingerprint", "categoryFp", "--min", "10", "--max", "100"}, + new String[]{"upload", folder.toString()}, + new String[]{"folder", "values", "--by", "itemSplit"}, + new String[]{"cd", ".."} ); - results.forEach(result -> assertEquals(0, result.exitCode(), result.getOutput())); - - LaunchResult last = results.getLast(); - String output = last.getOutput(); + String last = results.get(results.size() - 2); + String output = last; // With 'by itemSplit', violations are parented under itemSplit values. // Listing by itemSplit merges descendants into grouped rows, so violation @@ -838,7 +892,7 @@ private String qvssPath(String filename) { } @Test - public void fixedthreshold_qvss_throughput(QuarkusMainLauncher launcher) { + public void fixedthreshold_qvss_throughput() { String testName = "fixedthreshold_qvss_throughput"; // Throughput values (quarkus3-jvm avThroughput): @@ -846,34 +900,32 @@ public void fixedthreshold_qvss_throughput(QuarkusMainLauncher launcher) { // 26594: 29482 (ok), 26598: 29715 (ok), 27279: 29490 (ok), 27897: 29576 (ok) // 84315: 88777 (above) // Threshold: min=10000, max=35000 - List results = run(launcher, - new String[]{"add", "folder", testName}, - new String[]{"add", "jq", "to", testName, "throughput", ".results.\"quarkus3-jvm\".load.avThroughput"}, - new String[]{"add", "jq", "to", testName, "version", ".config.QUARKUS_VERSION"}, - new String[]{"add", "fixedthreshold", "ftNode", "to", testName, - "range", "throughput", - "fingerprint", "version", - "min", "10000", - "max", "35000"}, - new String[]{"list", testName, "nodes"}, - new String[]{"upload", qvssPath("27405.json"), "to", testName}, - new String[]{"upload", qvssPath("27406.json"), "to", testName}, - new String[]{"upload", qvssPath("27271.json"), "to", testName}, - new String[]{"upload", qvssPath("27272.json"), "to", testName}, - new String[]{"upload", qvssPath("26594.json"), "to", testName}, - new String[]{"upload", qvssPath("26598.json"), "to", testName}, - new String[]{"upload", qvssPath("27279.json"), "to", testName}, - new String[]{"upload", qvssPath("27897.json"), "to", testName}, - new String[]{"upload", qvssPath("84315.json"), "to", testName}, - new String[]{"list", "value", "from", testName} - ); - - results.forEach(result -> { - assertEquals(0, result.exitCode(), result.getOutput()); - }); + List results = run(aeshLauncher, + new String[]{"folder", "add", testName}, + new String[]{"cd", testName}, + new String[]{"node", "add", "jq", "throughput", ".results.\"quarkus3-jvm\".load.avThroughput"}, + new String[]{"node", "add", "jq", "version", ".config.QUARKUS_VERSION"}, + new String[]{"node", "add", "fixedthreshold", "ftNode", + "--range", "throughput", + "--fingerprint", "version", + "--min", "10000", + "--max", "35000"}, + new String[]{"node", "list"}, + new String[]{"upload", qvssPath("27405.json")}, + new String[]{"upload", qvssPath("27406.json")}, + new String[]{"upload", qvssPath("27271.json")}, + new String[]{"upload", qvssPath("27272.json")}, + new String[]{"upload", qvssPath("26594.json")}, + new String[]{"upload", qvssPath("26598.json")}, + new String[]{"upload", qvssPath("27279.json")}, + new String[]{"upload", qvssPath("27897.json")}, + new String[]{"upload", qvssPath("84315.json")}, + new String[]{"folder", "values"}, + new String[]{"cd", ".."} + ); - LaunchResult last = results.getLast(); - String output = last.getOutput(); + String last = results.get(results.size() - 2); + String output = last; // 4 below (2203, 2206, 8778, 9223) + 1 above (88777) = 5 violations assertTrue(output.contains("below"), "should detect below-threshold violations\n" + output); @@ -881,7 +933,7 @@ public void fixedthreshold_qvss_throughput(QuarkusMainLauncher launcher) { } @Test - public void relativedifference_qvss_throughput_regression(QuarkusMainLauncher launcher) { + public void relativedifference_qvss_throughput_regression() { String testName = "relativedifference_qvss_throughput_regression"; // 9 files from Quarkus 3.7.x, chronological order, shared fingerprint "3.7": @@ -895,37 +947,35 @@ public void relativedifference_qvss_throughput_regression(QuarkusMainLauncher la // 27405: 3.7.4 tp=2203 (2024-02-22) — severe regression (~93% drop) // 27406: 3.7.4 tp=2206 (2024-02-22) — still low // Fingerprint: major.minor version extracted via split/join → "3.7" - List results = run(launcher, - new String[]{"add", "folder", testName}, - new String[]{"add", "jq", "to", testName, "throughput", ".results.\"quarkus3-jvm\".load.avThroughput"}, - new String[]{"add", "jq", "to", testName, "majorMinor", ".config.QUARKUS_VERSION | split(\".\") | .[0:2] | join(\".\")"}, - new String[]{"add", "jq", "to", testName, "startTime", ".timing.start"}, - new String[]{"add", "relativedifference", "rdNode", "to", testName, - "range", "throughput", - "domain", "startTime", - "fingerprint", "majorMinor", - "window", "1", - "minPrevious", "3", - "threshold", "0.2"}, - new String[]{"list", testName, "nodes"}, - new String[]{"upload", qvssPath("26594.json"), "to", testName}, - new String[]{"upload", qvssPath("26598.json"), "to", testName}, - new String[]{"upload", qvssPath("26599.json"), "to", testName}, - new String[]{"upload", qvssPath("26776.json"), "to", testName}, - new String[]{"upload", qvssPath("27271.json"), "to", testName}, - new String[]{"upload", qvssPath("27272.json"), "to", testName}, - new String[]{"upload", qvssPath("27279.json"), "to", testName}, - new String[]{"upload", qvssPath("27405.json"), "to", testName}, - new String[]{"upload", qvssPath("27406.json"), "to", testName}, - new String[]{"list", "value", "from", testName} - ); - - results.forEach(result -> { - assertEquals(0, result.exitCode(), result.getOutput()); - }); + List results = run(aeshLauncher, + new String[]{"folder", "add", testName}, + new String[]{"cd", testName}, + new String[]{"node", "add", "jq", "throughput", ".results.\"quarkus3-jvm\".load.avThroughput"}, + new String[]{"node", "add", "jq", "majorMinor", ".config.QUARKUS_VERSION | split(\".\") | .[0:2] | join(\".\")"}, + new String[]{"node", "add", "jq", "startTime", ".timing.start"}, + new String[]{"node", "add", "relativedifference", "rdNode", + "--range", "throughput", + "--domain", "startTime", + "--fingerprint", "majorMinor", + "--window", "1", + "--minPrevious", "3", + "--threshold", "0.2"}, + new String[]{"node", "list"}, + new String[]{"upload", qvssPath("26594.json")}, + new String[]{"upload", qvssPath("26598.json")}, + new String[]{"upload", qvssPath("26599.json")}, + new String[]{"upload", qvssPath("26776.json")}, + new String[]{"upload", qvssPath("27271.json")}, + new String[]{"upload", qvssPath("27272.json")}, + new String[]{"upload", qvssPath("27279.json")}, + new String[]{"upload", qvssPath("27405.json")}, + new String[]{"upload", qvssPath("27406.json")}, + new String[]{"folder", "values"}, + new String[]{"cd", ".."} + ); - LaunchResult last = results.getLast(); - String output = last.getOutput(); + String last = results.get(results.size() - 2); + String output = last; // Detections expected when enough history builds up: // The 29583→8778 drop (~70%) and 29490→2203 drop (~93%) should trigger detection @@ -933,7 +983,7 @@ public void relativedifference_qvss_throughput_regression(QuarkusMainLauncher la } @Test - public void fixedthreshold_qvss_split_by_framework(QuarkusMainLauncher launcher) { + public void fixedthreshold_qvss_split_by_framework() { String testName = "fixedthreshold_qvss_split_by_framework"; // 6 files with both quarkus-jvm and spring-jvm results: @@ -945,32 +995,30 @@ public void fixedthreshold_qvss_split_by_framework(QuarkusMainLauncher launcher) // 17333: quarkus=28772, spring=9700 // Split on .results | to_entries[], fingerprint on framework key // Threshold min=15000: all spring-jvm values violate, no quarkus-jvm values violate - List results = run(launcher, - new String[]{"add", "folder", testName}, - new String[]{"add", "jq", "to", testName, "framework", ".results | to_entries[]"}, - new String[]{"add", "jq", "to", testName, "throughput", "{framework}:.value.load.avThroughput"}, - new String[]{"add", "jq", "to", testName, "fwName", "{framework}:.key"}, - new String[]{"add", "fixedthreshold", "ftNode", "to", testName, - "range", "throughput", - "by", "framework", - "fingerprint", "fwName", - "min", "15000"}, - new String[]{"list", testName, "nodes"}, - new String[]{"upload", qvssPath("7691.json"), "to", testName}, - new String[]{"upload", qvssPath("7750.json"), "to", testName}, - new String[]{"upload", qvssPath("6313.json"), "to", testName}, - new String[]{"upload", qvssPath("6314.json"), "to", testName}, - new String[]{"upload", qvssPath("16328.json"), "to", testName}, - new String[]{"upload", qvssPath("17333.json"), "to", testName}, - new String[]{"list", "value", "from", testName} - ); - - results.forEach(result -> { - assertEquals(0, result.exitCode(), result.getOutput()); - }); + List results = run(aeshLauncher, + new String[]{"folder", "add", testName}, + new String[]{"cd", testName}, + new String[]{"node", "add", "jq", "framework", ".results | to_entries[]"}, + new String[]{"node", "add", "jq", "throughput", "{framework}:.value.load.avThroughput"}, + new String[]{"node", "add", "jq", "fwName", "{framework}:.key"}, + new String[]{"node", "add", "fixedthreshold", "ftNode", + "--range", "throughput", + "--by", "framework", + "--fingerprint", "fwName", + "--min", "15000"}, + new String[]{"node", "list"}, + new String[]{"upload", qvssPath("7691.json")}, + new String[]{"upload", qvssPath("7750.json")}, + new String[]{"upload", qvssPath("6313.json")}, + new String[]{"upload", qvssPath("6314.json")}, + new String[]{"upload", qvssPath("16328.json")}, + new String[]{"upload", qvssPath("17333.json")}, + new String[]{"folder", "values", "--limit", "200"}, + new String[]{"cd", ".."} + ); - LaunchResult last = results.getLast(); - String output = last.getOutput(); + String last = results.get(results.size() - 2); + String output = last; // All spring-jvm values (~9400-12200) are below min=15000 assertTrue(output.contains("below"), "should detect spring below threshold\n" + output); @@ -984,7 +1032,7 @@ public void fixedthreshold_qvss_split_by_framework(QuarkusMainLauncher launcher) } @Test - public void relativedifference_not_recalculate_old_changes(QuarkusMainLauncher launcher) throws IOException { + public void relativedifference_not_recalculate_old_changes() throws IOException { String testName = StackWalker.getInstance() .walk(s -> s.skip(0).findFirst()) .get() @@ -1021,29 +1069,27 @@ public void relativedifference_not_recalculate_old_changes(QuarkusMainLauncher l """ ); - List results = run(launcher, - new String[]{"add","folder",testName}, - new String[]{"add","jq","to",testName,"split",".Item[]"}, - new String[]{"add","jq","to",testName,"domainNode","{split}:.x"}, - new String[]{"add","jq","to",testName,"rangeNode","{split}:.y"}, - new String[]{"add","jq","to",testName,"fp","{split}:.fp"}, - new String[]{"list",testName,"nodes"}, - new String[]{"add","relativedifference","relativediff","to",testName,"range","rangeNode","domain","domainNode","by","split","fingerprint","fp","window","1","minPrevious","1"}, - new String[]{"upload",filePath01.toString(),"to",testName}, - new String[]{"upload",filePath02.toString(),"to",testName}, - new String[]{"list","value","from",testName}, - new String[]{"upload",filePath03.toString(),"to",testName}, - new String[]{"list","value","from",testName} + List results = run(aeshLauncher, + new String[]{"folder","add",testName}, + new String[]{"cd",testName}, + new String[]{"node","add","jq","split",".Item[]"}, + new String[]{"node","add","jq","domainNode","{split}:.x"}, + new String[]{"node","add","jq","rangeNode","{split}:.y"}, + new String[]{"node","add","jq","fp","{split}:.fp"}, + new String[]{"node","list"}, + new String[]{"node","add","relativedifference","relativediff","--range","rangeNode","--domain","domainNode","--by","split","--fingerprint","fp","--window","1","--minPrevious","1"}, + new String[]{"upload",filePath01.toString()}, + new String[]{"upload",filePath02.toString()}, + new String[]{"folder","values"}, + new String[]{"upload",filePath03.toString()}, + new String[]{"folder","values"}, + new String[]{"cd",".."} ); - results.forEach(result->{ - assertEquals(0,result.exitCode(),result.getOutput()); - }); - - LaunchResult afterUpload2 = results.get(results.size() - 3); - String output2 = afterUpload2.getOutput(); - assertTrue(afterUpload2.getOutput().contains("Count: 11"), - "After upload 2, expect 11 values from test (1 changes total)\n" + afterUpload2.getOutput()); + String afterUpload2 = results.get(results.size() - 4); + String output2 = afterUpload2; + assertTrue(afterUpload2.contains("Count: 11"), + "After upload 2, expect 11 values from test (1 changes total)\n" + afterUpload2); assertTrue(output2.contains("\"domainvalue\":3"), "Change should be detected for domain x=4\n" + output2); @@ -1054,10 +1100,9 @@ public void relativedifference_not_recalculate_old_changes(QuarkusMainLauncher l assertTrue(output2.contains("\"ratio\":-47.61904761904761"), "Should contain calculated ratio -47.61904761904761\n" + output2); - LaunchResult result = results.getLast(); - String output3 = result.getOutput(); - assertTrue(result.getOutput().contains("Count: 17"), - "After upload 3, expect 17 values from test (2 changes total)\n" + result.getOutput()); + String output3 = results.get(results.size() - 2); + assertTrue(output3.contains("Count: 17"), + "After upload 3, expect 17 values from test (2 changes total)\n" + output3); assertTrue(output3.contains("\"domainvalue\":2"), "Change should be detected for domain x=2\n" + output3); @@ -1071,7 +1116,7 @@ public void relativedifference_not_recalculate_old_changes(QuarkusMainLauncher l } @Test - public void relativedifference_skip_minPrevious(QuarkusMainLauncher launcher) throws IOException { + public void relativedifference_skip_minPrevious() throws IOException { String testName = StackWalker.getInstance() .walk(s -> s.skip(0).findFirst()) .get() @@ -1118,52 +1163,47 @@ public void relativedifference_skip_minPrevious(QuarkusMainLauncher launcher) th """ ); - List results = run(launcher, - new String[]{"add","folder",testName}, - new String[]{"add","jq","to",testName,"split",".Item[]"}, - new String[]{"add","jq","to",testName,"domainNode","{split}:.x"}, - new String[]{"add","jq","to",testName,"rangeNode","{split}:.y"}, - new String[]{"add","jq","to",testName,"fp","{split}:.fp"}, - new String[]{"add","relativedifference","relativediff","to",testName,"range","rangeNode","domain","domainNode","by","split","fingerprint","fp","window","1","minPrevious","2"}, - new String[]{"upload",filePath01.toString(),"to",testName}, - new String[]{"list","value","from",testName}, - new String[]{"upload",filePath02.toString(),"to",testName}, - new String[]{"list","value","from",testName}, - new String[]{"upload",filePath03.toString(),"to",testName}, - new String[]{"list","value","from",testName}, - new String[]{"upload",filePath04.toString(),"to",testName}, - new String[]{"list","value","from",testName} + List results = run(aeshLauncher, + new String[]{"folder","add",testName}, + new String[]{"cd",testName}, + new String[]{"node","add","jq","split",".Item[]"}, + new String[]{"node","add","jq","domainNode","{split}:.x"}, + new String[]{"node","add","jq","rangeNode","{split}:.y"}, + new String[]{"node","add","jq","fp","{split}:.fp"}, + new String[]{"node","add","relativedifference","relativediff","--range","rangeNode","--domain","domainNode","--by","split","--fingerprint","fp","--window","1","--minPrevious","2"}, + new String[]{"upload",filePath01.toString()}, + new String[]{"folder","values"}, + new String[]{"upload",filePath02.toString()}, + new String[]{"folder","values"}, + new String[]{"upload",filePath03.toString()}, + new String[]{"folder","values"}, + new String[]{"upload",filePath04.toString()}, + new String[]{"folder","values"}, + new String[]{"cd",".."} ); - results.forEach(result->{ - assertEquals(0,result.exitCode(),result.getOutput()); - }); - - LaunchResult Upload1 = results.get(results.size() - 7); - String output1 = Upload1.getOutput(); - assertTrue(Upload1.getOutput().contains("Count: 5"), - "After upload 1, expect 5 values\n" + Upload1.getOutput()); + String output1 = results.get(results.size() - 8); + assertTrue(output1.contains("Count: 5"), + "After upload 1, expect 5 values\n" + output1); int changeCount1 = output1.split("\"ratio\":", -1).length - 1; assertEquals(0, changeCount1, "Upload 1: x=4 with only 1 sample should produce 0 changes (need minPrevious=2)"); - LaunchResult Upload2 = results.get(results.size() - 5); - String output2 = Upload2.getOutput(); - assertTrue(Upload2.getOutput().contains("Count: 10"), - "After upload 2, expect 10 values\n" + Upload2.getOutput()); + String output2 = results.get(results.size() - 6); + assertTrue(output2.contains("Count: 10"), + "After upload 2, expect 10 values\n" + output2); int changeCount2 = output2.split("\"ratio\":", -1).length - 1; assertEquals(0, changeCount2, "Upload 1: x=3 with only 1 sample should produce 0 changes (need minPrevious=2)"); - LaunchResult Upload3 = results.get(results.size() - 3); - String output3 = Upload3.getOutput(); + String output3 = results.get(results.size() - 4); int changeCount3 = output3.split("\"ratio\":", -1).length - 1; assertEquals(1, changeCount3, "Upload 1: x=2 with only 1 sample should produce 0 changes (need minPrevious=2)"); - assertTrue(Upload3.getOutput().contains("Count: 16"), - "After upload 3, expect 16 values (expect 1 change here since it violates threshold value)\n" + Upload3.getOutput()); + assertTrue(output3.contains("Count: 16"), + "After upload 3, expect 16 values (expect 1 change here since it violates threshold value)\n" + output3); assertTrue(output3.contains("\"domainvalue\":4"), "Change should be detected for domain x=4\n" + output3); @@ -1175,13 +1215,12 @@ public void relativedifference_skip_minPrevious(QuarkusMainLauncher launcher) th "Should contain calculated ratio -42.85714285714286\n" + output3); - LaunchResult Upload4 = results.getLast(); - String output4 = Upload4.getOutput(); + String output4 = results.get(results.size() - 2); int changeCount4 = output4.split("\"ratio\":", -1).length - 1; assertEquals(2, changeCount4, "Upload 1: x=1 should have 2 changes\n" + output4); - assertTrue(Upload4.getOutput().contains("Count: 22"), - "After upload 4, expect 22 values (expect 1 change here since it violates threshold value. With minPrevious=2 total changes=2)\n" + Upload4.getOutput()); + assertTrue(output4.contains("Count: 22"), + "After upload 4, expect 22 values (expect 1 change here since it violates threshold value. With minPrevious=2 total changes=2)\n" + output4); assertTrue(output4.contains("\"domainvalue\":3"), "Change should be detected for domain x=3\n" + output4); @@ -1195,7 +1234,7 @@ public void relativedifference_skip_minPrevious(QuarkusMainLauncher launcher) th } @Test - public void relativedifference_Unordered_uploads(QuarkusMainLauncher launcher) throws IOException { + public void relativedifference_Unordered_uploads() throws IOException { String testName = StackWalker.getInstance() .walk(s -> s.skip(0).findFirst()) .get() @@ -1242,35 +1281,32 @@ public void relativedifference_Unordered_uploads(QuarkusMainLauncher launcher) t """ ); - List results = run(launcher, - new String[]{"add","folder",testName}, - new String[]{"add","jq","to",testName,"split",".Item[]"}, - new String[]{"add","jq","to",testName,"domainNode","{split}:.x"}, - new String[]{"add","jq","to",testName,"rangeNode","{split}:.y"}, - new String[]{"add","jq","to",testName,"fp","{split}:.fp"}, - new String[]{"list",testName,"nodes"}, - new String[]{"add","relativedifference","relativediff","to",testName,"range","rangeNode","domain","domainNode","by","split","fingerprint","fp","window","1","minPrevious","1"}, - new String[]{"upload",filePath01.toString(),"to",testName}, - new String[]{"upload",filePath02.toString(),"to",testName}, - new String[]{"list","value","from",testName}, - new String[]{"upload",filePath03.toString(),"to",testName}, - new String[]{"list","value","from",testName}, - new String[]{"upload",filePath04.toString(),"to",testName}, - new String[]{"list","value","from",testName} + List results = run(aeshLauncher, + new String[]{"folder","add",testName}, + new String[]{"cd",testName}, + new String[]{"node","add","jq","split",".Item[]"}, + new String[]{"node","add","jq","domainNode","{split}:.x"}, + new String[]{"node","add","jq","rangeNode","{split}:.y"}, + new String[]{"node","add","jq","fp","{split}:.fp"}, + new String[]{"node","list"}, + new String[]{"node","add","relativedifference","relativediff","--range","rangeNode","--domain","domainNode","--by","split","--fingerprint","fp","--window","1","--minPrevious","1"}, + new String[]{"upload",filePath01.toString()}, + new String[]{"upload",filePath02.toString()}, + new String[]{"folder","values"}, + new String[]{"upload",filePath03.toString()}, + new String[]{"folder","values"}, + new String[]{"upload",filePath04.toString()}, + new String[]{"folder","values"}, + new String[]{"cd",".."} ); - results.forEach(result->{ - assertEquals(0,result.exitCode(),result.getOutput()); - }); - LaunchResult Upload1 = results.get(results.size() - 7); - String output1 = Upload1.getOutput(); + String output1 = results.get(results.size() - 8); int changeCount1 = output1.split("\"ratio\":", -1).length - 1; assertEquals(0, changeCount1, "Upload 1: x=4 with only 1 sample should produce 0 changes (need minPrevious=2)"); - LaunchResult afterUpload2 = results.get(results.size() - 5); - String output2 = afterUpload2.getOutput(); + String output2 = results.get(results.size() - 6); assertTrue(output2.contains("Count: 11"), "After upload 2, expect 11 values (1 change detected for x=4)\n" + output2); @@ -1284,8 +1320,7 @@ public void relativedifference_Unordered_uploads(QuarkusMainLauncher launcher) t assertTrue(output2.contains("\"ratio\":-47.61904761904761"), "Should contain calculated ratio -47.61904761904761\n" + output2); - LaunchResult afterUpload3 = results.get(results.size() - 3); - String output3 = afterUpload3.getOutput(); + String output3 = results.get(results.size() - 4); assertTrue(output3.contains("Count: 16"), "After upload 3, expect 16 values (1 changes total)\n" + output3); @@ -1298,8 +1333,7 @@ public void relativedifference_Unordered_uploads(QuarkusMainLauncher launcher) t assertTrue(output3.contains("\"ratio\":47.61904761904763"), "Should contain calculated ratio 47.61904761904763\n" + output3); - LaunchResult afterUpload4 = results.getLast(); - String output4 = afterUpload4.getOutput(); + String output4 = results.get(results.size() - 2); assertTrue(output4.contains("Count: 22"), "After upload 4, expect 22 values (2 changes total)\n" + output4); @@ -1315,7 +1349,7 @@ public void relativedifference_Unordered_uploads(QuarkusMainLauncher launcher) t } @Test - public void calculate_stddev_anomaly_node(QuarkusMainLauncher launcher) throws IOException { + public void calculate_stddev_anomaly_node() throws IOException { String testName = StackWalker.getInstance() .walk(s -> s.skip(0).findFirst()) .get() @@ -1343,35 +1377,31 @@ public void calculate_stddev_anomaly_node(QuarkusMainLauncher launcher) throws I // Build commands: setup nodes, then upload each file individually in order List commands = new java.util.ArrayList<>(); - commands.add(new String[]{"add", "folder", testName}); - commands.add(new String[]{"add", "jq", "to", testName, "domainNode", ".x"}); - commands.add(new String[]{"add", "jq", "to", testName, "rangeNode", ".y"}); - commands.add(new String[]{"add", "jq", "to", testName, "fp1", ".fp1"}); - commands.add(new String[]{"list", testName, "nodes"}); - commands.add(new String[]{"add", "stddev", "sdNode", "to", testName, + commands.add(new String[]{"folder", "add", testName}); + commands.add(new String[]{"cd", testName}); + commands.add(new String[]{"node", "add", "jq", "domainNode", ".x"}); + commands.add(new String[]{"node", "add", "jq", "rangeNode", ".y"}); + commands.add(new String[]{"node", "add", "jq", "fp1", ".fp1"}); + commands.add(new String[]{"node", "list"}); + commands.add(new String[]{"node", "add", "stddev", "sdNode", "range", "rangeNode", "domain", "domainNode", - "fingerprint", "fp1", + "--fingerprint", "fp1", "windowSize", "5", "deviations", "3", "minDataPoints", "3", "direction", "BOTH"}); - commands.add(new String[]{"list", testName, "nodes"}); + commands.add(new String[]{"node", "list"}); for (Path f : uploadFiles) { - commands.add(new String[]{"upload", f.toString(), "to", testName}); + commands.add(new String[]{"upload", f.toString()}); } - commands.add(new String[]{"list", "value", "from", testName}); + commands.add(new String[]{"folder", "values"}); + commands.add(new String[]{"cd", ".."}); - List results = run(launcher, commands.toArray(new String[0][])); + List results = run(aeshLauncher, commands.toArray(new String[0][])); + String output = results.get(results.size() - 2); - results.forEach(result -> { - assertEquals(0, result.exitCode(), result.getOutput()); - }); - - LaunchResult last = results.getLast(); - String output = last.getOutput(); - - // The node list is the second "list nodes" command (index 6, after add stddev) - LaunchResult nodeList = results.get(6); - assertTrue(nodeList.getOutput().contains("sdNode"), "Node list should contain sdNode\n" + nodeList.getOutput()); + // The node list is the second "list nodes" command (index 7, after add stddev) + String nodeList = results.get(7); + assertTrue(nodeList.contains("sdNode"), "Node list should contain sdNode\n" + nodeList); // After uploading 5 stable + 1 anomaly, the anomaly should be detected // Output should contain stddev detection fields @@ -1379,7 +1409,149 @@ public void calculate_stddev_anomaly_node(QuarkusMainLauncher launcher) throws I assertTrue(output.contains("\"stddev\""), "Detection should contain stddev\n" + output); assertTrue(output.contains("\"deviations\""), "Detection should contain deviations\n" + output); assertTrue(output.contains("\"direction\""), "Detection should contain direction\n" + output); - assertTrue(output.contains("above"), "200 should be detected as above the threshold\n" + output); + assertTrue(output.contains("above"), "200 should be detected as above the threshold\n" + output); + } + + // --- Folder context (cd) tests --- + // These tests simulate how a user would actually use the CLI: + // cd into a folder, then operate without specifying --to/--from explicitly. + + @Test + public void cd_add_nodes_upload_list_values() throws IOException { + String testName = "cd_add_nodes_upload_list_values"; + Path folder = Files.createTempDirectory("h5m"); + Files.writeString(Files.createTempFile(folder, "h5m", ".json").toAbsolutePath(), + """ + { "cpu": 42, "mem": "8gb" } + """ + ); + // Create folder, cd into it, then add nodes and upload without --to/--from + List results = run(aeshLauncher, + new String[]{"folder", "add", testName}, + new String[]{"cd", testName}, + new String[]{"node", "add", "jq", "cpu", ".cpu"}, + new String[]{"node", "add", "jq", "mem", ".mem"}, + new String[]{"node", "list"}, + new String[]{"upload", folder.toString()}, + new String[]{"folder", "values"} + ); + + String nodeList = results.get(4); + assertTrue(nodeList.contains("cpu"), "node list should show cpu node\n" + nodeList); + assertTrue(nodeList.contains("mem"), "node list should show mem node\n" + nodeList); + + String values = results.getLast(); + assertTrue(values.contains("Count: 2"), "expect 2 values after upload\n" + values); + } + + @Test + public void cd_add_nodes_remove_node() { + String testName = "cd_add_nodes_remove_node"; + List results = run(aeshLauncher, + new String[]{"folder", "add", testName}, + new String[]{"cd", testName}, + new String[]{"node", "add", "jq", "foo", ".foo"}, + new String[]{"node", "add", "jq", "bar", ".bar"}, + new String[]{"node", "list"}, + new String[]{"node", "remove", "bar"}, + new String[]{"node", "list"} + ); + + String beforeRemove = results.get(4); + assertTrue(beforeRemove.contains("bar"), "node list should show bar before remove\n" + beforeRemove); + + String afterRemove = results.getLast(); + assertTrue(afterRemove.contains("foo"), "node list should still show foo\n" + afterRemove); + assertFalse(afterRemove.contains("bar"), "node list should not show bar after remove\n" + afterRemove); + } + + @Test + public void cd_fixedthreshold_with_upload() throws IOException { + String testName = "cd_fixedthreshold_with_upload"; + Path folder = Files.createTempDirectory("h5m"); + Files.writeString(Files.createTempFile(folder, "h5m", ".json").toAbsolutePath(), + """ + { "y": 5.0, "fp1": "alpha" } + """ + ); + Files.writeString(Files.createTempFile(folder, "h5m", ".json").toAbsolutePath(), + """ + { "y": 50.0, "fp1": "alpha" } + """ + ); + Files.writeString(Files.createTempFile(folder, "h5m", ".json").toAbsolutePath(), + """ + { "y": 150.0, "fp1": "alpha" } + """ + ); + + // cd into folder, set up nodes and detection, upload — all without --to/--from + List results = run(aeshLauncher, + new String[]{"folder", "add", testName}, + new String[]{"cd", testName}, + new String[]{"node", "add", "jq", "rangeNode", ".y"}, + new String[]{"node", "add", "jq", "fp1", ".fp1"}, + new String[]{"node", "add", "fixedthreshold", "ftNode", "--range", "rangeNode", "--fingerprint", "fp1", "--min", "10", "--max", "100"}, + new String[]{"upload", folder.toString()}, + new String[]{"folder", "values"} + ); + + String values = results.getLast(); + // 3 rangeNode + 3 fp1 + 3 _fp-ftNode + 2 violations = 11 + assertTrue(values.contains("Count: 11"), "expect 11 values\n" + values); + } + + @Test + public void cd_then_cd_back() { + // Verify cd .. clears the folder context + List results = run(aeshLauncher, + new String[]{"folder", "add", "myFolder"}, + new String[]{"cd", "myFolder"}, + new String[]{"node", "add", "jq", "foo", ".foo"}, + new String[]{"node", "list"}, + new String[]{"cd", ".."}, + new String[]{"node", "list"} + ); + + String inFolder = results.get(3); + assertTrue(inFolder.contains("foo"), "node list in folder should show foo\n" + inFolder); + + String outOfFolder = results.getLast(); + // After cd .., node list without --from should fail or show error + assertTrue(outOfFolder.contains("group name is required") || outOfFolder.contains("not found"), + "node list without context should require --from\n" + outOfFolder); + } + + @Test + public void cd_structure_and_recalculate() throws IOException { + String testName = "cd_structure_and_recalculate"; + Path folder = Files.createTempDirectory("h5m"); + Files.writeString(Files.createTempFile(folder, "h5m", ".json").toAbsolutePath(), + """ + { "cpu": 4, "mem": "16gb" } + """ + ); + + List results = run(aeshLauncher, + new String[]{"folder", "add", testName}, + new String[]{"cd", testName}, + new String[]{"node", "add", "jq", "cpu", ".cpu"}, + new String[]{"node", "add", "jq", "mem", ".mem"}, + new String[]{"upload", folder.toString()}, + new String[]{"folder", "values"}, + new String[]{"folder", "structure"}, + new String[]{"folder", "recalculate"}, + new String[]{"folder", "values"} + ); + + String values1 = results.get(5); + assertTrue(values1.contains("Count: 2"), "expect 2 values after upload\n" + values1); + + String structure = results.get(6); + assertTrue(structure.contains("cpu"), "structure should show cpu node\n" + structure); + + String values2 = results.getLast(); + assertTrue(values2.contains("Count: 2"), "expect 2 values after recalculate\n" + values2); } } diff --git a/src/test/java/io/hyperfoil/tools/h5m/cli/LoadLegacyTestsTest.java b/src/test/java/io/hyperfoil/tools/h5m/cli/LoadLegacyTestsTest.java index 81ed5578..90b4457b 100644 --- a/src/test/java/io/hyperfoil/tools/h5m/cli/LoadLegacyTestsTest.java +++ b/src/test/java/io/hyperfoil/tools/h5m/cli/LoadLegacyTestsTest.java @@ -18,7 +18,6 @@ import io.hyperfoil.tools.h5m.svc.FolderService; import io.hyperfoil.tools.h5m.svc.NodeService; import io.hyperfoil.tools.yaup.HashedSets; -import io.hyperfoil.tools.yaup.HashedSets; import io.quarkus.test.junit.QuarkusTest; import jakarta.enterprise.inject.spi.CDI; import jakarta.inject.Inject; diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties index 250e5423..143d3dbb 100644 --- a/src/test/resources/application-test.properties +++ b/src/test/resources/application-test.properties @@ -18,6 +18,9 @@ h5m.worker.maxPoolSize=5 # Disable Quinoa in tests (web UI tests run via vitest independently) quarkus.quinoa=false +# Enable exit command for AeshLauncher REPL tests +quarkus.aesh.add-exit-command=true + # Tests run in local mode - no auth h5m.security.enabled=false quarkus.oidc.tenant-enabled=false