diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/DispatchResponse.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/DispatchResponse.java
index b362f9bb64..ffd7519bac 100644
--- a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/DispatchResponse.java
+++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/DispatchResponse.java
@@ -3,16 +3,6 @@
import lombok.Builder;
import lombok.Data;
-/**
- * Result of a fire-and-forget RMM dispatch over core NATS — running a command,
- * cancelling an in-flight execution, or running a saved script. Carries the
- * server-minted {@code executionId} the dashboard uses to correlate the agent's
- * asynchronous result.
- *
- *
Shared by all dispatch mutations ({@code runCommand}, {@code cancelExecution},
- * {@code runScript}): they are structurally identical, so a single type avoids
- * three duplicate one-field DTOs.
- */
@Data
@Builder
public class DispatchResponse {
diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/script/ScriptFilterOption.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/script/ScriptFilterOption.java
index 73b5b89c77..954f677dee 100644
--- a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/script/ScriptFilterOption.java
+++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/script/ScriptFilterOption.java
@@ -5,11 +5,6 @@
import lombok.Data;
import lombok.NoArgsConstructor;
-/**
- * One option for a scripts-list filter dropdown: the raw {@code value} to filter by,
- * a human {@code label} to show, and the {@code count} of matching scripts. Mirrors
- * {@code DeviceFilterOption}.
- */
@Data
@Builder
@NoArgsConstructor
diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/CreateSoftwareBundleInput.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/CreateSoftwareBundleInput.java
new file mode 100644
index 0000000000..8f72f6cebb
--- /dev/null
+++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/CreateSoftwareBundleInput.java
@@ -0,0 +1,31 @@
+package com.openframe.api.dto.rmm.software;
+
+import com.openframe.data.document.rmm.software.SoftwareAction;
+import com.openframe.data.document.rmm.software.SoftwareBundleMode;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotEmpty;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Pattern;
+import lombok.Data;
+
+import java.time.Instant;
+import java.util.List;
+
+@Data
+public class CreateSoftwareBundleInput {
+
+ @NotNull(message = "action must not be null")
+ private SoftwareAction action;
+
+ @NotNull(message = "mode must not be null")
+ private SoftwareBundleMode mode;
+
+ @NotEmpty(message = "machineIds must not be empty")
+ private List<@Pattern(regexp = "^[A-Za-z0-9_-]+$",
+ message = "each machineId must be a single subject-safe token (A-Za-z0-9_-)") String> machineIds;
+
+ @Valid
+ private List packages;
+
+ private Instant startAt;
+}
diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareActionDeviceFilterInput.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareActionDeviceFilterInput.java
new file mode 100644
index 0000000000..1d01d56f6e
--- /dev/null
+++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareActionDeviceFilterInput.java
@@ -0,0 +1,13 @@
+package com.openframe.api.dto.rmm.software;
+
+import com.openframe.data.document.rmm.software.SoftwareActionStatus;
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+public class SoftwareActionDeviceFilterInput {
+
+ private List statuses;
+ private List organizationIds;
+}
diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareActionDeviceResponse.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareActionDeviceResponse.java
new file mode 100644
index 0000000000..f293337679
--- /dev/null
+++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareActionDeviceResponse.java
@@ -0,0 +1,28 @@
+package com.openframe.api.dto.rmm.software;
+
+import com.openframe.data.document.rmm.software.SoftwareActionStatus;
+import lombok.Builder;
+import lombok.Data;
+
+import java.time.Instant;
+
+@Data
+@Builder
+public class SoftwareActionDeviceResponse {
+
+ private String machineId;
+ private String hostname;
+ private String organizationId;
+ private String organizationName;
+ private SoftwareActionStatus status;
+
+ private Integer exitCode;
+ private String stdout;
+ private Boolean stdoutTruncated;
+ private String stderr;
+ private Boolean stderrTruncated;
+ private String error;
+
+ private Instant dispatchedAt;
+ private Instant finishedAt;
+}
diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareActionFilterInput.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareActionFilterInput.java
new file mode 100644
index 0000000000..f178dea157
--- /dev/null
+++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareActionFilterInput.java
@@ -0,0 +1,16 @@
+package com.openframe.api.dto.rmm.software;
+
+import com.openframe.data.document.packagesearch.PackageManagerType;
+import com.openframe.data.document.rmm.software.SoftwareAction;
+import com.openframe.data.document.rmm.software.SoftwareActionStatus;
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+public class SoftwareActionFilterInput {
+
+ private List statuses;
+ private List actions;
+ private List engines;
+}
diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareActionFilters.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareActionFilters.java
new file mode 100644
index 0000000000..8917f3e6e1
--- /dev/null
+++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareActionFilters.java
@@ -0,0 +1,21 @@
+package com.openframe.api.dto.rmm.software;
+
+import com.openframe.api.dto.rmm.script.ScriptFilterOption;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class SoftwareActionFilters {
+
+ private List statuses;
+ private List actions;
+ private List engines;
+ private Integer filteredCount;
+}
diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareActionId.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareActionId.java
new file mode 100644
index 0000000000..1ddbaf032a
--- /dev/null
+++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareActionId.java
@@ -0,0 +1,43 @@
+package com.openframe.api.dto.rmm.software;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+
+public record SoftwareActionId(String executionId, String bundleId, String scheduleId) {
+
+ private static final String SEP = ":"; // absent in UUIDs / Mongo ObjectIds, and not regex-special
+
+ public String encode() {
+ String raw = nullToEmpty(executionId) + SEP + nullToEmpty(bundleId) + SEP + nullToEmpty(scheduleId);
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(raw.getBytes(StandardCharsets.UTF_8));
+ }
+
+ public static SoftwareActionId of(String executionId, String bundleId, String scheduleId) {
+ return new SoftwareActionId(executionId, bundleId, scheduleId);
+ }
+
+ /** Decodes a token; falls back to treating a non-token value as a bare executionId (backward-safe). */
+ public static SoftwareActionId decode(String actionId) {
+ if (actionId == null || actionId.isBlank()) {
+ return new SoftwareActionId(null, null, null);
+ }
+ try {
+ String raw = new String(Base64.getUrlDecoder().decode(actionId), StandardCharsets.UTF_8);
+ String[] parts = raw.split(SEP, -1);
+ if (parts.length == 3) {
+ return new SoftwareActionId(emptyToNull(parts[0]), emptyToNull(parts[1]), emptyToNull(parts[2]));
+ }
+ } catch (IllegalArgumentException ignored) {
+ // not a token — treat as a raw executionId
+ }
+ return new SoftwareActionId(actionId, null, null);
+ }
+
+ private static String nullToEmpty(String s) {
+ return s == null ? "" : s;
+ }
+
+ private static String emptyToNull(String s) {
+ return s == null || s.isEmpty() ? null : s;
+ }
+}
diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareActionResponse.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareActionResponse.java
new file mode 100644
index 0000000000..1442efa0ea
--- /dev/null
+++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareActionResponse.java
@@ -0,0 +1,33 @@
+package com.openframe.api.dto.rmm.software;
+
+import com.openframe.data.document.packagesearch.PackageManagerType;
+import com.openframe.data.document.rmm.software.SoftwareAction;
+import com.openframe.data.document.rmm.software.SoftwareActionStatus;
+import lombok.Builder;
+import lombok.Data;
+
+import java.time.Instant;
+
+@Data
+@Builder
+public class SoftwareActionResponse {
+
+ private String id;
+ private String executionId;
+
+ private String software;
+ private SoftwareAction action;
+ private PackageManagerType engine;
+ private SoftwareActionStatus status;
+
+ private int totalMachineCount;
+ private int respondedMachineCount;
+
+ private Instant scheduledAt;
+ private Instant dispatchedAt;
+ private Instant finishedAt;
+ private String initiatedBy;
+
+ private String bundleId;
+ private String scheduleId;
+}
diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareBundleResponse.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareBundleResponse.java
new file mode 100644
index 0000000000..8a2bd2669a
--- /dev/null
+++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareBundleResponse.java
@@ -0,0 +1,30 @@
+package com.openframe.api.dto.rmm.software;
+
+import com.openframe.data.document.rmm.software.SoftwareAction;
+import com.openframe.data.document.rmm.software.SoftwareBundleMode;
+import com.openframe.data.document.rmm.software.SoftwareBundlePackage;
+import com.openframe.data.document.rmm.software.SoftwareBundleStatus;
+import lombok.Builder;
+import lombok.Data;
+
+import java.time.Instant;
+import java.util.List;
+
+@Data
+@Builder
+public class SoftwareBundleResponse {
+
+ private String id;
+ private SoftwareAction action;
+ private SoftwareBundleMode mode;
+ private SoftwareBundleStatus status;
+ private List machineIds;
+ private List packages;
+ private Instant startAt;
+ private String scheduleId;
+ private String createdBy;
+ private Instant createdAt;
+ private Instant updatedAt;
+ private Instant completedAt;
+ private List executionIds;
+}
diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareCveSeverity.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareCveSeverity.java
new file mode 100644
index 0000000000..b577a8fa07
--- /dev/null
+++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareCveSeverity.java
@@ -0,0 +1,9 @@
+package com.openframe.api.dto.rmm.software;
+
+public enum SoftwareCveSeverity {
+ CRITICAL,
+ HIGH,
+ MEDIUM,
+ LOW,
+ NONE
+}
diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareFilterInput.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareFilterInput.java
new file mode 100644
index 0000000000..12973114a1
--- /dev/null
+++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareFilterInput.java
@@ -0,0 +1,17 @@
+package com.openframe.api.dto.rmm.software;
+
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+public class SoftwareFilterInput {
+
+ private List sources;
+
+ private List versionStatuses;
+
+ private SoftwareCveSeverity minSeverity;
+
+ private List deviceTagIds;
+}
diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareFilterOption.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareFilterOption.java
new file mode 100644
index 0000000000..f0690c033e
--- /dev/null
+++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareFilterOption.java
@@ -0,0 +1,13 @@
+package com.openframe.api.dto.rmm.software;
+
+import lombok.Builder;
+import lombok.Data;
+
+@Data
+@Builder
+public class SoftwareFilterOption {
+
+ private String value;
+ private String label;
+ private int count;
+}
diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareFilters.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareFilters.java
new file mode 100644
index 0000000000..672f6e6743
--- /dev/null
+++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareFilters.java
@@ -0,0 +1,23 @@
+package com.openframe.api.dto.rmm.software;
+
+import lombok.Builder;
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+@Builder
+public class SoftwareFilters {
+
+ private List sources;
+ private List versionStatuses;
+ private List severities;
+
+ public static SoftwareFilters empty() {
+ return SoftwareFilters.builder()
+ .sources(List.of())
+ .versionStatuses(List.of())
+ .severities(List.of())
+ .build();
+ }
+}
diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareOnDeviceResponse.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareOnDeviceResponse.java
new file mode 100644
index 0000000000..948ee056c6
--- /dev/null
+++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareOnDeviceResponse.java
@@ -0,0 +1,14 @@
+package com.openframe.api.dto.rmm.software;
+
+import com.openframe.data.document.device.Machine;
+import lombok.Builder;
+import lombok.Data;
+
+@Data
+@Builder
+public class SoftwareOnDeviceResponse {
+
+ private Machine device;
+ private String softwareVersion;
+ private SoftwareOnDeviceStatus status;
+}
diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareOnDeviceStatus.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareOnDeviceStatus.java
new file mode 100644
index 0000000000..60cfdebe0a
--- /dev/null
+++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareOnDeviceStatus.java
@@ -0,0 +1,9 @@
+package com.openframe.api.dto.rmm.software;
+
+public enum SoftwareOnDeviceStatus {
+ UP_TO_DATE,
+ OUTDATED,
+ SCHEDULED_UPDATE,
+ UNINSTALLING,
+ SCHEDULED_UNINSTALL
+}
diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareResponse.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareResponse.java
new file mode 100644
index 0000000000..f6fdce1ba6
--- /dev/null
+++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareResponse.java
@@ -0,0 +1,24 @@
+package com.openframe.api.dto.rmm.software;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class SoftwareResponse {
+ private String id;
+ private String name;
+ private String publisher;
+ private SoftwareSource source;
+ private String currentVersion;
+ private String latestVersion;
+ private SoftwareVersionStatus versionStatus;
+ private Integer olderVersionsCount;
+ private Integer devicesCount;
+ private SoftwareVulnerabilitySummaryResponse vulnerabilitySummary;
+ private Boolean cpeMatched;
+}
diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareScheduleResponse.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareScheduleResponse.java
index 4b069c86a5..7b53544682 100644
--- a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareScheduleResponse.java
+++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareScheduleResponse.java
@@ -1,5 +1,6 @@
package com.openframe.api.dto.rmm.software;
+import com.openframe.data.document.rmm.schedule.ScheduleDeviceCriteria;
import com.openframe.data.document.rmm.schedule.ScheduleDeviceSelectionMode;
import com.openframe.data.document.rmm.schedule.ScheduleOfflineBehavior;
import com.openframe.data.document.rmm.schedule.ScheduleScriptTrigger;
@@ -23,6 +24,7 @@ public class SoftwareScheduleResponse {
private SoftwareAction action;
private List packages;
private ScheduleDeviceSelectionMode selectionMode;
+ private ScheduleDeviceCriteria deviceCriteria;
private ScheduleScriptTrigger trigger;
private ScheduleTimeReference timeReference;
private ScheduleOfflineBehavior offlineBehavior;
diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareSource.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareSource.java
new file mode 100644
index 0000000000..6cfa47fc29
--- /dev/null
+++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareSource.java
@@ -0,0 +1,8 @@
+package com.openframe.api.dto.rmm.software;
+
+public enum SoftwareSource {
+ WINGET,
+ CHOCOLATEY,
+ BREW,
+ UNMANAGED
+}
diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareVersionStatus.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareVersionStatus.java
new file mode 100644
index 0000000000..b7ed5a3f98
--- /dev/null
+++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareVersionStatus.java
@@ -0,0 +1,7 @@
+package com.openframe.api.dto.rmm.software;
+
+public enum SoftwareVersionStatus {
+ UP_TO_DATE,
+ OUTDATED,
+ UNKNOWN
+}
diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareVulnerabilityResponse.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareVulnerabilityResponse.java
new file mode 100644
index 0000000000..d295a6ec96
--- /dev/null
+++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareVulnerabilityResponse.java
@@ -0,0 +1,20 @@
+package com.openframe.api.dto.rmm.software;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.Instant;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class SoftwareVulnerabilityResponse {
+ private String cveId;
+ private SoftwareCveSeverity severity;
+ private Double cvssScore;
+ private String affectedVersion;
+ private Instant publishedAt;
+}
diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareVulnerabilitySummaryResponse.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareVulnerabilitySummaryResponse.java
new file mode 100644
index 0000000000..dc6352b210
--- /dev/null
+++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareVulnerabilitySummaryResponse.java
@@ -0,0 +1,15 @@
+package com.openframe.api.dto.rmm.software;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class SoftwareVulnerabilitySummaryResponse {
+ private SoftwareCveSeverity highestSeverity;
+ private int cveCount;
+}
diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/UpdateSoftwareBundleInput.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/UpdateSoftwareBundleInput.java
new file mode 100644
index 0000000000..bb3033c995
--- /dev/null
+++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/UpdateSoftwareBundleInput.java
@@ -0,0 +1,31 @@
+package com.openframe.api.dto.rmm.software;
+
+import com.openframe.data.document.rmm.software.SoftwareBundleMode;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotEmpty;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Pattern;
+import lombok.Data;
+
+import java.time.Instant;
+import java.util.List;
+
+@Data
+public class UpdateSoftwareBundleInput {
+
+ @NotBlank(message = "id must not be blank")
+ private String id;
+
+ @NotNull(message = "mode must not be null")
+ private SoftwareBundleMode mode;
+
+ @NotEmpty(message = "machineIds must not be empty")
+ private List<@Pattern(regexp = "^[A-Za-z0-9_-]+$",
+ message = "each machineId must be a single subject-safe token (A-Za-z0-9_-)") String> machineIds;
+
+ @Valid
+ private List packages;
+
+ private Instant startAt;
+}
diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/vulnerability/AffectedSoftwareResponse.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/vulnerability/AffectedSoftwareResponse.java
new file mode 100644
index 0000000000..b0cd70fdd5
--- /dev/null
+++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/vulnerability/AffectedSoftwareResponse.java
@@ -0,0 +1,26 @@
+package com.openframe.api.dto.rmm.vulnerability;
+
+import com.openframe.api.dto.rmm.software.SoftwareSource;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class AffectedSoftwareResponse {
+
+ private String id;
+
+ private String name;
+
+ private SoftwareSource source;
+
+ private String version;
+
+ private Integer devicesCount;
+
+ private String resolvedInVersion;
+}
diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/vulnerability/VulnerabilityFilterInput.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/vulnerability/VulnerabilityFilterInput.java
new file mode 100644
index 0000000000..af35d3570f
--- /dev/null
+++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/vulnerability/VulnerabilityFilterInput.java
@@ -0,0 +1,12 @@
+package com.openframe.api.dto.rmm.vulnerability;
+
+import com.openframe.api.dto.rmm.software.SoftwareCveSeverity;
+import lombok.Data;
+
+@Data
+public class VulnerabilityFilterInput {
+
+ private SoftwareCveSeverity minSeverity;
+
+ private Boolean exploited;
+}
diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/vulnerability/VulnerabilityResponse.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/vulnerability/VulnerabilityResponse.java
new file mode 100644
index 0000000000..1f63e694e3
--- /dev/null
+++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/vulnerability/VulnerabilityResponse.java
@@ -0,0 +1,39 @@
+package com.openframe.api.dto.rmm.vulnerability;
+
+import com.openframe.api.dto.rmm.software.SoftwareCveSeverity;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.Instant;
+import java.util.List;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class VulnerabilityResponse {
+
+ private String cveId;
+
+ private SoftwareCveSeverity severity;
+
+ private Double cvssScore;
+
+ private Double epssProbability;
+
+ private Boolean cisaKnownExploit;
+
+ private Instant publishedAt;
+
+ private String detailsLink;
+
+ private Integer devicesCount;
+
+ private String description;
+
+ private String resolvedInVersion;
+
+ private List affectedSoftware;
+}
diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/shared/PageResult.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/shared/PageResult.java
new file mode 100644
index 0000000000..c96a0c2e0d
--- /dev/null
+++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/shared/PageResult.java
@@ -0,0 +1,10 @@
+package com.openframe.api.dto.shared;
+
+import java.util.List;
+
+public record PageResult(List items, boolean hasNext, boolean hasPrevious, int filteredCount, int page) {
+
+ public static PageResult empty(int page) {
+ return new PageResult<>(List.of(), false, false, 0, page);
+ }
+}
diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/fleet/FleetClientProvider.java b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/fleet/FleetClientProvider.java
new file mode 100644
index 0000000000..d63e757a62
--- /dev/null
+++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/fleet/FleetClientProvider.java
@@ -0,0 +1,76 @@
+package com.openframe.api.service.rmm.fleet;
+
+import com.openframe.data.document.tool.IntegratedTool;
+import com.openframe.data.document.tool.IntegratedToolId;
+import com.openframe.data.document.tool.ToolApiKey;
+import com.openframe.data.document.tool.ToolCredentials;
+import com.openframe.data.document.tool.ToolUrl;
+import com.openframe.data.document.tool.ToolUrlType;
+import com.openframe.data.repository.tool.IntegratedToolRepository;
+import com.openframe.data.service.TenantIdProvider;
+import com.openframe.sdk.fleetmdm.FleetMdmClient;
+import com.openframe.sdk.fleetmdm.exception.FleetMdmException;
+import lombok.RequiredArgsConstructor;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+import java.util.List;
+
+import static org.springframework.util.CollectionUtils.isEmpty;
+import static org.springframework.util.StringUtils.hasText;
+
+@Component
+@ConditionalOnProperty(name = "openframe.rmm.software.enabled", havingValue = "true")
+@RequiredArgsConstructor
+public class FleetClientProvider {
+
+ private static final String PORT_SEPARATOR = ":";
+
+ private final IntegratedToolRepository integratedToolRepository;
+ private final TenantIdProvider tenantIdProvider;
+
+ public T call(FleetSdkCall call, String action) {
+ try {
+ return call.execute(client());
+ } catch (IOException e) {
+ throw new FleetMdmException("Failed to " + action, e);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new FleetMdmException("Interrupted while " + action, e);
+ }
+ }
+
+ @FunctionalInterface
+ public interface FleetSdkCall {
+ T execute(FleetMdmClient client) throws IOException, InterruptedException;
+ }
+
+ private FleetMdmClient client() {
+ String key = IntegratedToolId.FLEET_SERVER_ID.getValue();
+ IntegratedTool tool = integratedToolRepository.findByKey(key)
+ .orElseThrow(() -> new IllegalStateException("Fleet MDM tool not configured: " + key));
+ return new FleetMdmClient(resolveApiUrl(tool), resolveApiToken(tool), tenantIdProvider.getTenantId());
+ }
+
+ private static String resolveApiUrl(IntegratedTool tool) {
+ List urls = tool.getToolUrls();
+ if (isEmpty(urls)) {
+ throw new IllegalStateException("Fleet MDM tool has no configured URLs");
+ }
+ ToolUrl api = urls.stream()
+ .filter(u -> u.getType() == ToolUrlType.API)
+ .findFirst()
+ .orElseThrow(() -> new IllegalStateException("Fleet MDM tool has no API URL"));
+ return hasText(api.getPort()) ? api.getUrl() + PORT_SEPARATOR + api.getPort() : api.getUrl();
+ }
+
+ private static String resolveApiToken(IntegratedTool tool) {
+ ToolCredentials credentials = tool.getCredentials();
+ ToolApiKey apiKey = credentials == null ? null : credentials.getApiKey();
+ if (apiKey == null || !hasText(apiKey.getKey())) {
+ throw new IllegalStateException("Fleet MDM tool has no API token configured");
+ }
+ return apiKey.getKey();
+ }
+}
diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/fleet/FleetHostMachineResolver.java b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/fleet/FleetHostMachineResolver.java
new file mode 100644
index 0000000000..af9430736f
--- /dev/null
+++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/fleet/FleetHostMachineResolver.java
@@ -0,0 +1,70 @@
+package com.openframe.api.service.rmm.fleet;
+
+import com.openframe.data.document.device.Machine;
+import com.openframe.data.repository.device.MachineRepository;
+import com.openframe.sdk.fleetmdm.model.Host;
+import lombok.RequiredArgsConstructor;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.stereotype.Component;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+import static org.apache.commons.lang3.StringUtils.isNotBlank;
+
+@Component
+@ConditionalOnProperty(name = "openframe.rmm.software.enabled", havingValue = "true")
+@RequiredArgsConstructor
+public class FleetHostMachineResolver {
+
+ private final MachineRepository machineRepository;
+
+ public Map resolve(String tenantId, List hosts) {
+ if (hosts == null || hosts.isEmpty()) {
+ return Map.of();
+ }
+ Set osUuids = values(hosts, Host::getUuid);
+ Set serials = values(hosts, Host::getHardwareSerial);
+ Set hostnames = values(hosts, Host::getHostname);
+
+ Map byOsUuid = index(machineRepository.findByTenantIdAndOsUuidIn(tenantId, osUuids), Machine::getOsUuid);
+ Map bySerial = index(machineRepository.findByTenantIdAndSerialNumberIn(tenantId, serials), Machine::getSerialNumber);
+ Map byHostname = index(machineRepository.findByTenantIdAndHostnameIn(tenantId, hostnames), Machine::getHostname);
+
+ Map resolved = new LinkedHashMap<>();
+ for (Host host : hosts) {
+ if (host.getId() == null || resolved.containsKey(host.getId())) {
+ continue;
+ }
+ Machine machine = lookup(byOsUuid, host.getUuid());
+ if (machine == null) {
+ machine = lookup(bySerial, host.getHardwareSerial());
+ }
+ if (machine == null) {
+ machine = lookup(byHostname, host.getHostname());
+ }
+ if (machine != null) {
+ resolved.put(host.getId(), machine);
+ }
+ }
+ return resolved;
+ }
+
+ private static Set values(List hosts, Function key) {
+ return hosts.stream().map(key).filter(v -> isNotBlank(v)).collect(Collectors.toSet());
+ }
+
+ private static Map index(List machines, Function key) {
+ return machines.stream()
+ .filter(m -> isNotBlank(key.apply(m)))
+ .collect(Collectors.toMap(key, Function.identity(), (a, b) -> a));
+ }
+
+ private static Machine lookup(Map byKey, String key) {
+ return isNotBlank(key) ? byKey.get(key) : null;
+ }
+}
diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/fleet/FleetSoftwareCategory.java b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/fleet/FleetSoftwareCategory.java
new file mode 100644
index 0000000000..1adfa60c96
--- /dev/null
+++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/fleet/FleetSoftwareCategory.java
@@ -0,0 +1,35 @@
+package com.openframe.api.service.rmm.fleet;
+
+import com.openframe.api.dto.rmm.software.SoftwareSource;
+
+import java.util.Set;
+
+public enum FleetSoftwareCategory {
+
+ CHOCOLATEY(SoftwareSource.CHOCOLATEY, "chocolatey_packages"),
+ HOMEBREW(SoftwareSource.BREW, "homebrew_packages"),
+ OTHER(SoftwareSource.UNMANAGED);
+
+ private final SoftwareSource source;
+ private final Set fleetSources;
+
+ FleetSoftwareCategory(SoftwareSource source, String... fleetSources) {
+ this.source = source;
+ this.fleetSources = Set.of(fleetSources);
+ }
+
+ public SoftwareSource source() {
+ return source;
+ }
+
+ public static FleetSoftwareCategory of(String fleetSource) {
+ if (fleetSource != null) {
+ for (FleetSoftwareCategory c : values()) {
+ if (c.fleetSources.contains(fleetSource)) {
+ return c;
+ }
+ }
+ }
+ return OTHER;
+ }
+}
diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/FleetSoftwareMapper.java b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/FleetSoftwareMapper.java
new file mode 100644
index 0000000000..a22e070ec1
--- /dev/null
+++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/FleetSoftwareMapper.java
@@ -0,0 +1,65 @@
+package com.openframe.api.service.rmm.software;
+
+import com.openframe.api.dto.rmm.software.SoftwareResponse;
+import com.openframe.api.dto.rmm.software.SoftwareVulnerabilitySummaryResponse;
+import com.openframe.api.service.rmm.fleet.FleetSoftwareCategory;
+import com.openframe.sdk.fleetmdm.model.SoftwareTitle;
+import com.openframe.sdk.fleetmdm.model.SoftwareTitleVersion;
+
+import java.util.Comparator;
+import java.util.List;
+import java.util.Objects;
+
+final class FleetSoftwareMapper {
+
+ private FleetSoftwareMapper() {
+ }
+
+ static SoftwareResponse toResponse(SoftwareTitle title) {
+ if (title == null) {
+ return null;
+ }
+ List versions = title.getVersions();
+ SoftwareTitleVersion current = pickCurrentVersion(versions);
+ return SoftwareResponse.builder()
+ .id(Objects.toString(title.getId(), null))
+ .name(title.getName())
+ .source(FleetSoftwareCategory.of(title.getSource()).source())
+ .currentVersion(current == null ? null : current.getVersion())
+ .olderVersionsCount(olderVersionsCount(versions))
+ .devicesCount(title.getHostsCount())
+ .vulnerabilitySummary(rollUpVulnerabilities(versions))
+ .build();
+ }
+
+ private static SoftwareTitleVersion pickCurrentVersion(List versions) {
+ if (versions == null || versions.isEmpty()) {
+ return null;
+ }
+ return versions.stream()
+ .max(Comparator.comparingInt(v -> v.getHostsCount() == null ? 0 : v.getHostsCount()))
+ .orElse(versions.get(0));
+ }
+
+ private static Integer olderVersionsCount(List versions) {
+ if (versions == null || versions.isEmpty()) {
+ return 0;
+ }
+ return versions.size() - 1;
+ }
+
+ private static SoftwareVulnerabilitySummaryResponse rollUpVulnerabilities(List versions) {
+ if (versions == null || versions.isEmpty()) {
+ return null;
+ }
+ int total = versions.stream()
+ .mapToInt(v -> v.getVulnerabilities() == null ? 0 : v.getVulnerabilities().size())
+ .sum();
+ if (total == 0) {
+ return null;
+ }
+ return SoftwareVulnerabilitySummaryResponse.builder()
+ .cveCount(total)
+ .build();
+ }
+}
diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/FleetVulnerabilityMapper.java b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/FleetVulnerabilityMapper.java
new file mode 100644
index 0000000000..8690774d8b
--- /dev/null
+++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/FleetVulnerabilityMapper.java
@@ -0,0 +1,51 @@
+package com.openframe.api.service.rmm.software;
+
+import com.openframe.api.dto.rmm.software.SoftwareCveSeverity;
+import com.openframe.api.dto.rmm.software.SoftwareVulnerabilityResponse;
+import com.openframe.sdk.fleetmdm.model.Vulnerability;
+
+import java.time.Instant;
+import java.time.format.DateTimeParseException;
+
+final class FleetVulnerabilityMapper {
+
+ private FleetVulnerabilityMapper() {
+ }
+
+ static SoftwareVulnerabilityResponse toResponse(String cveId, Vulnerability enrichment, String affectedVersion) {
+ if (cveId == null) {
+ return null;
+ }
+ Double cvss = enrichment == null ? null : enrichment.getCvssScore();
+ String published = enrichment == null ? null : enrichment.getCvePublished();
+ return SoftwareVulnerabilityResponse.builder()
+ .cveId(cveId)
+ .severity(bucketSeverity(cvss))
+ .cvssScore(cvss)
+ .affectedVersion(affectedVersion)
+ .publishedAt(parseInstant(published))
+ .build();
+ }
+
+ private static SoftwareCveSeverity bucketSeverity(Double cvss) {
+ if (cvss == null) {
+ return null;
+ }
+ if (cvss >= 9.0) return SoftwareCveSeverity.CRITICAL;
+ if (cvss >= 7.0) return SoftwareCveSeverity.HIGH;
+ if (cvss >= 4.0) return SoftwareCveSeverity.MEDIUM;
+ if (cvss > 0.0) return SoftwareCveSeverity.LOW;
+ return SoftwareCveSeverity.NONE;
+ }
+
+ private static Instant parseInstant(String iso) {
+ if (iso == null || iso.isBlank()) {
+ return null;
+ }
+ try {
+ return Instant.parse(iso);
+ } catch (DateTimeParseException e) {
+ return null;
+ }
+ }
+}
diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareActionDetailService.java b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareActionDetailService.java
new file mode 100644
index 0000000000..1d333edaed
--- /dev/null
+++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareActionDetailService.java
@@ -0,0 +1,198 @@
+package com.openframe.api.service.rmm.software;
+
+import com.openframe.api.dto.rmm.software.SoftwareActionDeviceFilterInput;
+import com.openframe.api.dto.rmm.software.SoftwareActionDeviceResponse;
+import com.openframe.data.document.device.Machine;
+import com.openframe.data.document.organization.Organization;
+import com.openframe.data.document.rmm.schedule.DeviceOnlineDispatchStatus;
+import com.openframe.data.document.rmm.schedule.SoftwareScheduleMachineAssigned;
+import com.openframe.data.document.rmm.script.ExecutionStatus;
+import com.openframe.data.document.rmm.script.ScriptExecution;
+import com.openframe.data.document.rmm.software.SoftwareActionStatus;
+import com.openframe.data.repository.device.MachineRepository;
+import com.openframe.data.repository.organization.OrganizationRepository;
+import com.openframe.data.repository.rmm.ScriptExecutionRepository;
+import com.openframe.data.repository.rmm.SoftwareBundleOnlineDispatchRepository;
+import com.openframe.data.repository.rmm.SoftwareScheduleMachineAssignedRepository;
+import com.openframe.data.repository.rmm.SoftwareScheduleOnlineDispatchRepository;
+import com.openframe.data.service.TenantIdProvider;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.stereotype.Service;
+
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+
+import static org.springframework.util.StringUtils.hasText;
+
+/**
+ * The device drill-down for one Software Action: every target device with its result. Rows are layered so the
+ * most concrete state wins — the schedule's assigned machines seed a SCHEDULED baseline (a not-yet-fired
+ * schedule has no leaves/sentinels), reconnect sentinels overlay it (EXPIRED → FAILED), and execution leaves
+ * overlay everything with the real outcome and output for "Show Result". Each device is enriched with its
+ * hostname and customer (organization) and can be filtered by status / customer / search.
+ */
+@Slf4j
+@Service
+@ConditionalOnProperty(name = "openframe.rmm.software.enabled", havingValue = "true")
+@RequiredArgsConstructor
+public class SoftwareActionDetailService {
+
+ private final ScriptExecutionRepository scriptExecutionRepository;
+ private final SoftwareBundleOnlineDispatchRepository bundleOnlineDispatchRepository;
+ private final SoftwareScheduleOnlineDispatchRepository scheduleOnlineDispatchRepository;
+ private final SoftwareScheduleMachineAssignedRepository scheduleMachineAssignedRepository;
+ private final MachineRepository machineRepository;
+ private final OrganizationRepository organizationRepository;
+ private final TenantIdProvider tenantIdProvider;
+
+ public List devices(String executionId, String bundleId, String scheduleId,
+ SoftwareActionDeviceFilterInput filter, String search) {
+ String tenantId = tenantIdProvider.getTenantId();
+ Map byMachine = new LinkedHashMap<>();
+
+ // 1. Baseline: a schedule's full target set as SCHEDULED (not-yet-fired schedules have no leaves).
+ if (hasText(scheduleId)) {
+ for (SoftwareScheduleMachineAssigned a : scheduleMachineAssignedRepository
+ .findByTenantIdAndSoftwareScheduleId(tenantId, scheduleId)) {
+ byMachine.putIfAbsent(a.getMachineId(), pending(a.getMachineId(), SoftwareActionStatus.SCHEDULED, null));
+ }
+ }
+
+ // 2. Overlay reconnect sentinels (offline devices armed at fire time) — EXPIRED overrides the baseline.
+ addPending(byMachine, tenantId, bundleId, scheduleId);
+
+ // 3. Overlay execution leaves — the real outcome wins over any pending state.
+ if (hasText(executionId)) {
+ for (ScriptExecution leaf : scriptExecutionRepository.findByTenantIdAndExecutionId(tenantId, executionId)) {
+ byMachine.put(leaf.getMachineId(), fromLeaf(leaf));
+ }
+ }
+
+ enrichWithCustomer(tenantId, byMachine);
+
+ return byMachine.values().stream()
+ .filter(row -> matchesStatus(row, filter))
+ .filter(row -> matchesCustomer(row, filter))
+ .filter(row -> matchesSearch(row, search))
+ .toList();
+ }
+
+ private void addPending(Map byMachine, String tenantId,
+ String bundleId, String scheduleId) {
+ if (hasText(bundleId)) {
+ bundleOnlineDispatchRepository.findByTenantIdAndBundleId(tenantId, bundleId).forEach(s -> {
+ if (s.getStatus() == DeviceOnlineDispatchStatus.NEW) {
+ byMachine.put(s.getMachineId(), pending(s.getMachineId(), SoftwareActionStatus.SCHEDULED, null));
+ }
+ // DISPATCHED without a leaf = the package's OS did not apply to this device → not shown.
+ });
+ } else if (hasText(scheduleId)) {
+ scheduleOnlineDispatchRepository.findByTenantIdAndScheduleId(tenantId, scheduleId).forEach(s -> {
+ if (s.getStatus() == DeviceOnlineDispatchStatus.NEW) {
+ byMachine.put(s.getMachineId(), pending(s.getMachineId(), SoftwareActionStatus.SCHEDULED, null));
+ } else if (s.getStatus() == DeviceOnlineDispatchStatus.EXPIRED) {
+ byMachine.put(s.getMachineId(), pending(s.getMachineId(), SoftwareActionStatus.FAILED,
+ "Device did not reconnect within the retry window"));
+ }
+ });
+ }
+ }
+
+ /** Batch-loads each device's hostname and customer (organization) in two queries, not per row. */
+ private void enrichWithCustomer(String tenantId, Map byMachine) {
+ if (byMachine.isEmpty()) {
+ return;
+ }
+ Map machines = new HashMap<>();
+ machineRepository.findByTenantIdAndMachineIdIn(tenantId, byMachine.keySet())
+ .forEach(m -> machines.put(m.getMachineId(), m));
+
+ Set organizationIds = new HashSet<>();
+ machines.values().forEach(m -> {
+ if (m.getOrganizationId() != null) {
+ organizationIds.add(m.getOrganizationId());
+ }
+ });
+ Map organizationNames = new HashMap<>();
+ if (!organizationIds.isEmpty()) {
+ organizationRepository.findByOrganizationIdIn(organizationIds)
+ .forEach(o -> organizationNames.put(o.getOrganizationId(), o.getName()));
+ }
+
+ byMachine.forEach((machineId, row) -> {
+ Machine m = machines.get(machineId);
+ if (m != null) {
+ row.setHostname(m.getHostname());
+ row.setOrganizationId(m.getOrganizationId());
+ row.setOrganizationName(organizationNames.get(m.getOrganizationId()));
+ }
+ });
+ }
+
+ private static boolean matchesStatus(SoftwareActionDeviceResponse row, SoftwareActionDeviceFilterInput filter) {
+ if (filter == null || filter.getStatuses() == null || filter.getStatuses().isEmpty()) {
+ return true;
+ }
+ return filter.getStatuses().contains(row.getStatus());
+ }
+
+ private static boolean matchesCustomer(SoftwareActionDeviceResponse row, SoftwareActionDeviceFilterInput filter) {
+ if (filter == null || filter.getOrganizationIds() == null || filter.getOrganizationIds().isEmpty()) {
+ return true;
+ }
+ return row.getOrganizationId() != null && filter.getOrganizationIds().contains(row.getOrganizationId());
+ }
+
+ private static boolean matchesSearch(SoftwareActionDeviceResponse row, String search) {
+ if (!hasText(search)) {
+ return true;
+ }
+ String needle = search.trim().toLowerCase(Locale.ROOT);
+ return contains(row.getHostname(), needle) || contains(row.getMachineId(), needle);
+ }
+
+ private static boolean contains(String value, String needle) {
+ return value != null && value.toLowerCase(Locale.ROOT).contains(needle);
+ }
+
+ private static SoftwareActionDeviceResponse fromLeaf(ScriptExecution leaf) {
+ return SoftwareActionDeviceResponse.builder()
+ .machineId(leaf.getMachineId())
+ .status(deviceStatus(leaf.getStatus()))
+ .exitCode(leaf.getExitCode())
+ .stdout(leaf.getStdout())
+ .stdoutTruncated(leaf.getStdoutTruncated())
+ .stderr(leaf.getStderr())
+ .stderrTruncated(leaf.getStderrTruncated())
+ .error(leaf.getError())
+ .dispatchedAt(leaf.getDispatchedAt())
+ .finishedAt(leaf.getFinishedAt())
+ .build();
+ }
+
+ private static SoftwareActionDeviceResponse pending(String machineId, SoftwareActionStatus status, String error) {
+ return SoftwareActionDeviceResponse.builder()
+ .machineId(machineId)
+ .status(status)
+ .error(error)
+ .build();
+ }
+
+ private static SoftwareActionStatus deviceStatus(ExecutionStatus status) {
+ if (status == null) {
+ return SoftwareActionStatus.IN_PROGRESS;
+ }
+ return switch (status) {
+ case SUCCESS -> SoftwareActionStatus.COMPLETED;
+ case FAILED -> SoftwareActionStatus.FAILED;
+ case QUEUED, RUNNING -> SoftwareActionStatus.IN_PROGRESS;
+ };
+ }
+}
diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareActionService.java b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareActionService.java
new file mode 100644
index 0000000000..dedfd7fcc6
--- /dev/null
+++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareActionService.java
@@ -0,0 +1,236 @@
+package com.openframe.api.service.rmm.software;
+
+import com.openframe.api.dto.rmm.software.SoftwareActionFilterInput;
+import com.openframe.api.dto.rmm.software.SoftwareActionFilters;
+import com.openframe.api.dto.rmm.software.SoftwareActionId;
+import com.openframe.api.dto.rmm.software.SoftwareActionResponse;
+import com.openframe.api.dto.shared.PageResult;
+import com.openframe.api.mapper.ScriptFilterOptionMapper;
+import com.openframe.api.dto.shared.SortDirection;
+import com.openframe.api.dto.shared.SortInput;
+import com.openframe.data.document.rmm.filter.SoftwareActionQueryFilter;
+import com.openframe.data.document.rmm.schedule.SoftwareSchedule;
+import com.openframe.data.document.rmm.schedule.SoftwareSchedulePackage;
+import com.openframe.data.document.rmm.script.ScriptStatus;
+import com.openframe.data.document.rmm.software.SoftwareActionStatus;
+import com.openframe.data.document.rmm.software.SoftwareActionSummary;
+import com.openframe.data.document.rmm.software.SoftwareExecutionId;
+import com.openframe.data.repository.rmm.SoftwareActionAggregationRepository;
+import com.openframe.data.repository.rmm.SoftwareScheduleMachineAssignedRepository;
+import com.openframe.data.repository.rmm.SoftwareScheduleRepository;
+import com.openframe.data.service.TenantIdProvider;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.data.domain.Sort;
+import org.springframework.stereotype.Service;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Optional;
+
+@Slf4j
+@Service
+@ConditionalOnProperty(name = "openframe.rmm.software.enabled", havingValue = "true")
+@RequiredArgsConstructor
+public class SoftwareActionService {
+
+ private static final int DEFAULT_PER_PAGE = 25;
+
+ private static final Map SORT_FIELD = Map.of(
+ "status", "status",
+ "software", "packageName",
+ "processedDevices", "totalMachineCount",
+ "devices", "totalMachineCount",
+ "dispatchedAt", "dispatchedAt");
+
+ private static final String FIELD_STATUS = "status";
+ private static final String FIELD_ACTION = "action";
+ private static final String FIELD_ENGINE = "packageManager";
+
+ private final SoftwareActionAggregationRepository aggregationRepository;
+ private final SoftwareScheduleRepository scheduleRepository;
+ private final SoftwareScheduleMachineAssignedRepository assignedRepository;
+ private final ScriptFilterOptionMapper optionMapper;
+ private final TenantIdProvider tenantIdProvider;
+
+ public PageResult list(SoftwareActionFilterInput filter, String search,
+ SortInput sort, int page, Integer perPage) {
+ String tenantId = tenantIdProvider.getTenantId();
+ int size = perPage != null && perPage > 0 ? perPage : DEFAULT_PER_PAGE;
+ SoftwareActionQueryFilter queryFilter = toQueryFilter(filter);
+ String sortField = resolveSortField(sort);
+ Sort.Direction direction = resolveDirection(sort);
+
+ List scheduled = includeScheduled(filter)
+ ? scheduledRows(tenantId, filter, search)
+ : List.of();
+ long executedTotal = aggregationRepository.count(tenantId, queryFilter, search);
+ long total = scheduled.size() + executedTotal;
+
+ int offset = Math.max(0, page) * size;
+ List items = new ArrayList<>(size);
+ if (offset < scheduled.size()) {
+ int to = Math.min(scheduled.size(), offset + size);
+ items.addAll(scheduled.subList(offset, to));
+ int remaining = size - items.size();
+ if (remaining > 0) {
+ items.addAll(mapExecuted(aggregationRepository.findPage(tenantId, queryFilter, search, sortField, direction, 0, remaining)));
+ }
+ } else {
+ int executedSkip = offset - scheduled.size();
+ items.addAll(mapExecuted(aggregationRepository.findPage(tenantId, queryFilter, search, sortField, direction, executedSkip, size)));
+ }
+
+ boolean hasNext = (long) offset + items.size() < total;
+ boolean hasPrev = page > 0;
+ return new PageResult<>(items, hasNext, hasPrev, (int) total, page);
+ }
+
+ public SoftwareActionFilters filters(SoftwareActionFilterInput filter, String search) {
+ String tenantId = tenantIdProvider.getTenantId();
+ SoftwareActionQueryFilter queryFilter = toQueryFilter(filter);
+
+ Map statuses = new LinkedHashMap<>(aggregationRepository.facet(tenantId, queryFilter, search, FIELD_STATUS));
+ Map actions = new LinkedHashMap<>(aggregationRepository.facet(tenantId, queryFilter, search, FIELD_ACTION));
+ Map engines = new LinkedHashMap<>(aggregationRepository.facet(tenantId, queryFilter, search, FIELD_ENGINE));
+
+ List scheduled = includeScheduled(filter)
+ ? scheduledRows(tenantId, filter, search)
+ : List.of();
+ for (SoftwareActionResponse row : scheduled) {
+ bump(statuses, row.getStatus() != null ? row.getStatus().name() : null);
+ bump(actions, row.getAction() != null ? row.getAction().name() : null);
+ bump(engines, row.getEngine() != null ? row.getEngine().name() : null);
+ }
+
+ long filteredCount = scheduled.size() + aggregationRepository.count(tenantId, queryFilter, search);
+
+ return SoftwareActionFilters.builder()
+ .statuses(optionMapper.selfLabeled(statuses))
+ .actions(optionMapper.selfLabeled(actions))
+ .engines(optionMapper.selfLabeled(engines))
+ .filteredCount((int) filteredCount)
+ .build();
+ }
+
+ private static void bump(Map counts, String key) {
+ if (key != null) {
+ counts.merge(key, 1, Integer::sum);
+ }
+ }
+
+ public Optional findById(String actionId) {
+ String executionId = SoftwareActionId.decode(actionId).executionId();
+ if (executionId == null) {
+ return Optional.empty();
+ }
+ return aggregationRepository.findByExecutionId(tenantIdProvider.getTenantId(), executionId)
+ .map(SoftwareActionService::toResponse);
+ }
+
+ private List scheduledRows(String tenantId, SoftwareActionFilterInput filter, String search) {
+ List upcoming = scheduleRepository
+ .findByTenantIdAndStatusAndNextRunAtGreaterThanOrderByNextRunAtAsc(tenantId, ScriptStatus.ACTIVE, Instant.now());
+ List rows = new ArrayList<>();
+ for (SoftwareSchedule schedule : upcoming) {
+ if (schedule.getPackages() == null) {
+ continue;
+ }
+ int deviceCount = (int) assignedRepository.countByTenantIdAndSoftwareScheduleId(tenantId, schedule.getId());
+ for (SoftwareSchedulePackage pkg : schedule.getPackages()) {
+ if (!scheduledMatchesFilter(filter, search, schedule, pkg)) {
+ continue;
+ }
+ String executionId = SoftwareExecutionId.forSchedule(schedule.getId(), pkg.getPackageManager(), pkg.getPackageName());
+ rows.add(SoftwareActionResponse.builder()
+ .id(SoftwareActionId.of(executionId, null, schedule.getId()).encode())
+ .executionId(executionId)
+ .software(pkg.getPackageName())
+ .action(schedule.getAction())
+ .engine(pkg.getPackageManager())
+ .status(SoftwareActionStatus.SCHEDULED)
+ .totalMachineCount(deviceCount)
+ .respondedMachineCount(0)
+ .scheduledAt(schedule.getNextRunAt())
+ .initiatedBy(schedule.getCreatedBy())
+ .scheduleId(schedule.getId())
+ .build());
+ }
+ }
+ return rows;
+ }
+
+ private static boolean scheduledMatchesFilter(SoftwareActionFilterInput filter, String search,
+ SoftwareSchedule schedule, SoftwareSchedulePackage pkg) {
+ if (filter != null) {
+ if (filter.getActions() != null && !filter.getActions().isEmpty()
+ && !filter.getActions().contains(schedule.getAction())) {
+ return false;
+ }
+ if (filter.getEngines() != null && !filter.getEngines().isEmpty()
+ && !filter.getEngines().contains(pkg.getPackageManager())) {
+ return false;
+ }
+ }
+ if (search != null && !search.isBlank()) {
+ String needle = search.trim().toLowerCase(Locale.ROOT);
+ return pkg.getPackageName() != null && pkg.getPackageName().toLowerCase(Locale.ROOT).contains(needle);
+ }
+ return true;
+ }
+
+ private static boolean includeScheduled(SoftwareActionFilterInput filter) {
+ return filter == null || filter.getStatuses() == null || filter.getStatuses().isEmpty()
+ || filter.getStatuses().contains(SoftwareActionStatus.SCHEDULED);
+ }
+
+ private String resolveSortField(SortInput sort) {
+ if (sort == null || sort.getField() == null) {
+ return aggregationRepository.getDefaultSortField();
+ }
+ String mapped = SORT_FIELD.get(sort.getField());
+ return mapped != null && aggregationRepository.isSortableField(mapped)
+ ? mapped : aggregationRepository.getDefaultSortField();
+ }
+
+ private static Sort.Direction resolveDirection(SortInput sort) {
+ return sort != null && sort.getDirection() == SortDirection.ASC ? Sort.Direction.ASC : Sort.Direction.DESC;
+ }
+
+ private static SoftwareActionQueryFilter toQueryFilter(SoftwareActionFilterInput input) {
+ if (input == null) {
+ return null;
+ }
+ return SoftwareActionQueryFilter.builder()
+ .statuses(input.getStatuses())
+ .actions(input.getActions())
+ .engines(input.getEngines())
+ .build();
+ }
+
+ private static List mapExecuted(List summaries) {
+ return summaries.stream().map(SoftwareActionService::toResponse).toList();
+ }
+
+ private static SoftwareActionResponse toResponse(SoftwareActionSummary s) {
+ return SoftwareActionResponse.builder()
+ .id(SoftwareActionId.of(s.getExecutionId(), s.getBundleId(), s.getScheduleId()).encode())
+ .executionId(s.getExecutionId())
+ .software(s.getPackageName())
+ .action(s.getAction())
+ .engine(s.getPackageManager())
+ .status(s.getStatus())
+ .totalMachineCount(s.getTotalMachineCount())
+ .respondedMachineCount(s.getRespondedMachineCount())
+ .dispatchedAt(s.getDispatchedAt())
+ .initiatedBy(s.getInitiatedBy())
+ .bundleId(s.getBundleId())
+ .scheduleId(s.getScheduleId())
+ .build();
+ }
+}
diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareBundleService.java b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareBundleService.java
new file mode 100644
index 0000000000..c8610bd784
--- /dev/null
+++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareBundleService.java
@@ -0,0 +1,243 @@
+package com.openframe.api.service.rmm.software;
+
+import com.openframe.api.dto.rmm.software.CreateSoftwareBundleInput;
+import com.openframe.api.dto.rmm.software.CreateSoftwareScheduleInput;
+import com.openframe.api.dto.rmm.software.SoftwareBundleResponse;
+import com.openframe.api.dto.rmm.software.SoftwarePackageInput;
+import com.openframe.api.dto.rmm.software.SoftwareSchedulePackageInput;
+import com.openframe.api.dto.rmm.software.SoftwareScheduleResponse;
+import com.openframe.api.dto.rmm.software.UpdateSoftwareBundleInput;
+import com.openframe.core.exception.BadRequestException;
+import com.openframe.core.exception.NotFoundException;
+import com.openframe.data.document.rmm.schedule.DeviceOnlineDispatchStatus;
+import com.openframe.data.document.rmm.schedule.ScheduleOfflineBehavior;
+import com.openframe.data.document.rmm.schedule.ScheduleTimeReference;
+import com.openframe.data.document.rmm.software.SoftwareBundle;
+import com.openframe.data.document.rmm.software.SoftwareBundleMode;
+import com.openframe.data.document.rmm.software.SoftwareBundleOnlineDispatch;
+import com.openframe.data.document.rmm.software.SoftwareBundlePackage;
+import com.openframe.data.document.rmm.software.SoftwareBundleStatus;
+import com.openframe.data.repository.rmm.SoftwareBundleOnlineDispatchRepository;
+import com.openframe.data.repository.rmm.SoftwareBundleRepository;
+import com.openframe.data.service.TenantIdProvider;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.stereotype.Service;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Optional;
+
+@Slf4j
+@Service
+@ConditionalOnProperty(name = "openframe.rmm.software.enabled", havingValue = "true")
+@RequiredArgsConstructor
+public class SoftwareBundleService {
+
+ private final SoftwareBundleRepository bundleRepository;
+ private final SoftwareBundleOnlineDispatchRepository onlineDispatchRepository;
+ private final SoftwareScheduleService softwareScheduleService;
+ private final TenantIdProvider tenantIdProvider;
+
+ @Value("${openframe.rmm.software.bundle.pending-ttl}")
+ private Duration pendingTtl;
+
+ @Value("${openframe.rmm.software.bundle.schedule.reconnect-window-seconds}")
+ private long scheduleReconnectWindowSeconds;
+
+ public SoftwareBundleResponse create(CreateSoftwareBundleInput input, String createdBy) {
+ String tenantId = tenantIdProvider.getTenantId();
+ Instant now = Instant.now();
+ SoftwareBundle entity = SoftwareBundle.builder()
+ .tenantId(tenantId)
+ .action(input.getAction())
+ .mode(input.getMode())
+ .status(SoftwareBundleStatus.PENDING)
+ .machineIds(List.copyOf(input.getMachineIds()))
+ .packages(toDomainPackages(input.getPackages()))
+ .startAt(input.getStartAt())
+ .createdBy(createdBy)
+ .createdAt(now)
+ .updatedAt(now)
+ .expireAt(now.plus(pendingTtl))
+ .build();
+ SoftwareBundle saved = bundleRepository.save(entity);
+ log.info("Created software bundle id={} action={} devices={} packages={} tenantId={}",
+ saved.getId(), saved.getAction(), saved.getMachineIds().size(),
+ saved.getPackages().size(), tenantId);
+ return toResponse(saved);
+ }
+
+ public SoftwareBundleResponse update(UpdateSoftwareBundleInput input, String actor) {
+ SoftwareBundle entity = loadPendingOrThrow(input.getId());
+ entity.setMode(input.getMode());
+ entity.setMachineIds(List.copyOf(input.getMachineIds()));
+ entity.setPackages(toDomainPackages(input.getPackages()));
+ entity.setStartAt(input.getStartAt());
+ Instant now = Instant.now();
+ entity.setUpdatedAt(now);
+ entity.setExpireAt(now.plus(pendingTtl));
+ SoftwareBundle saved = bundleRepository.save(entity);
+ log.debug("Updated software bundle id={} devices={} packages={} actor={}",
+ saved.getId(), saved.getMachineIds().size(), saved.getPackages().size(), actor);
+ return toResponse(saved);
+ }
+
+ public boolean delete(String id, String actor) {
+ SoftwareBundle entity = loadPendingOrThrow(id);
+ bundleRepository.delete(entity);
+ log.info("Deleted PENDING software bundle id={} actor={}", id, actor);
+ return true;
+ }
+
+ public Optional findById(String id) {
+ return bundleRepository.findByTenantIdAndId(tenantIdProvider.getTenantId(), id).map(SoftwareBundleService::toResponse);
+ }
+
+ public List list(SoftwareBundleStatus status) {
+ String tenantId = tenantIdProvider.getTenantId();
+ List bundles = status == null
+ ? bundleRepository.findByTenantIdOrderByIdDesc(tenantId)
+ : bundleRepository.findByTenantIdAndStatusOrderByIdDesc(tenantId, status);
+ return bundles.stream().map(SoftwareBundleService::toResponse).toList();
+ }
+
+ public SoftwareBundleResponse run(String id, String actor) {
+ SoftwareBundle entity = loadPendingOrThrow(id);
+ if (entity.getPackages() == null || entity.getPackages().isEmpty()) {
+ throw new BadRequestException("Cannot run software bundle " + id + ": no packages selected");
+ }
+ if (entity.getMachineIds() == null || entity.getMachineIds().isEmpty()) {
+ throw new BadRequestException("Cannot run software bundle " + id + ": no devices selected");
+ }
+
+ Instant now = Instant.now();
+ if (entity.getMode() == SoftwareBundleMode.SCHEDULED) {
+ runScheduled(entity, actor);
+ } else {
+ armOnlineDispatch(entity, now);
+ }
+
+ entity.setStatus(SoftwareBundleStatus.COMPLETED);
+ entity.setCompletedAt(now);
+ entity.setUpdatedAt(now);
+ entity.setExpireAt(null); // completed bundles are history — never reaped
+ bundleRepository.save(entity);
+
+ log.info("Ran software bundle id={} mode={} action={} devices={} actor={}",
+ id, entity.getMode(), entity.getAction(), entity.getMachineIds().size(), actor);
+ return toResponse(entity);
+ }
+
+ private void runScheduled(SoftwareBundle bundle, String actor) {
+ if (bundle.getStartAt() == null) {
+ throw new BadRequestException("Cannot run SCHEDULED software bundle " + bundle.getId()
+ + ": startAt is required");
+ }
+ CreateSoftwareScheduleInput input = new CreateSoftwareScheduleInput();
+ input.setName(scheduleName(bundle));
+ input.setAction(bundle.getAction());
+ input.setPackages(toSchedulePackages(bundle.getPackages()));
+ input.setTimeReference(ScheduleTimeReference.SERVER);
+ input.setOfflineBehavior(ScheduleOfflineBehavior.RETRY_ON_RECONNECT);
+ input.setReconnectWindowSeconds(scheduleReconnectWindowSeconds);
+ input.setStartAt(bundle.getStartAt());
+ input.setMachineIds(bundle.getMachineIds());
+
+ SoftwareScheduleResponse schedule = softwareScheduleService.create(input, actor);
+ bundle.setScheduleId(schedule.getId());
+ log.info("SCHEDULED software bundle id={} → created schedule id={} startAt={} reconnectWindowSeconds={}",
+ bundle.getId(), schedule.getId(), bundle.getStartAt(), scheduleReconnectWindowSeconds);
+ }
+
+ private static String scheduleName(SoftwareBundle bundle) {
+ String first = bundle.getPackages().get(0).getPackageName();
+ int extra = bundle.getPackages().size() - 1;
+ String label = extra > 0 ? first + " +" + extra + " more" : first;
+ return bundle.getAction() + " " + label + " [" + bundle.getId() + "]";
+ }
+
+ private static List toSchedulePackages(List packages) {
+ return packages.stream()
+ .map(p -> {
+ SoftwareSchedulePackageInput i = new SoftwareSchedulePackageInput();
+ i.setPackageManager(p.getPackageManager());
+ i.setPackageName(p.getPackageName());
+ i.setBrewPackageType(p.getBrewPackageType());
+ return i;
+ })
+ .toList();
+ }
+
+ private void armOnlineDispatch(SoftwareBundle bundle, Instant now) {
+ for (String machineId : new LinkedHashSet<>(bundle.getMachineIds())) {
+ boolean alreadyArmed = onlineDispatchRepository
+ .findByTenantIdAndMachineIdAndBundleId(bundle.getTenantId(), machineId, bundle.getId())
+ .isPresent();
+ if (alreadyArmed) {
+ continue;
+ }
+ try {
+ onlineDispatchRepository.save(SoftwareBundleOnlineDispatch.builder()
+ .tenantId(bundle.getTenantId())
+ .machineId(machineId)
+ .bundleId(bundle.getId())
+ .firstSeenAt(now)
+ .status(DeviceOnlineDispatchStatus.NEW)
+ .build());
+ } catch (DuplicateKeyException raced) {
+ log.debug("Bundle online sentinel already armed bundleId={} machineId={}", bundle.getId(), machineId);
+ }
+ }
+ }
+
+ private SoftwareBundle loadOrThrow(String id) {
+ return bundleRepository.findByTenantIdAndId(tenantIdProvider.getTenantId(), id)
+ .orElseThrow(() -> new NotFoundException("Software bundle not found: " + id));
+ }
+
+ private SoftwareBundle loadPendingOrThrow(String id) {
+ SoftwareBundle entity = loadOrThrow(id);
+ if (entity.getStatus() != SoftwareBundleStatus.PENDING) {
+ throw new BadRequestException("Software bundle " + id + " is " + entity.getStatus()
+ + " and can no longer be modified or run");
+ }
+ return entity;
+ }
+
+ private static List toDomainPackages(List packages) {
+ if (packages == null) {
+ return List.of();
+ }
+ return packages.stream()
+ .map(p -> SoftwareBundlePackage.builder()
+ .packageManager(p.getPackageManager())
+ .packageName(p.getPackageName())
+ .brewPackageType(p.getBrewPackageType())
+ .build())
+ .toList();
+ }
+
+ private static SoftwareBundleResponse toResponse(SoftwareBundle b) {
+ return SoftwareBundleResponse.builder()
+ .id(b.getId())
+ .action(b.getAction())
+ .mode(b.getMode())
+ .status(b.getStatus())
+ .machineIds(b.getMachineIds())
+ .packages(b.getPackages())
+ .startAt(b.getStartAt())
+ .scheduleId(b.getScheduleId())
+ .createdBy(b.getCreatedBy())
+ .createdAt(b.getCreatedAt())
+ .updatedAt(b.getUpdatedAt())
+ .completedAt(b.getCompletedAt())
+ .executionIds(b.getExecutionIds())
+ .build();
+ }
+}
diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareInstallUpdateManagementService.java b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareInstallUpdateManagementService.java
index c848db1a13..3fcb8eeb70 100644
--- a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareInstallUpdateManagementService.java
+++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareInstallUpdateManagementService.java
@@ -6,8 +6,10 @@
import com.openframe.api.dto.rmm.software.SoftwarePackageInput;
import com.openframe.api.service.rmm.script.ScriptService;
import com.openframe.data.document.rmm.script.ExecutionSource;
+import com.openframe.data.document.rmm.script.OsType;
import com.openframe.data.document.rmm.software.SoftwareAction;
import com.openframe.data.document.rmm.software.SoftwareScriptCode;
+import com.openframe.data.service.rmm.MachinePlatformResolver;
import com.openframe.data.service.rmm.software.PackageManagerHandler;
import com.openframe.data.service.rmm.software.PackageManagerRegistry;
import lombok.RequiredArgsConstructor;
@@ -29,6 +31,7 @@ public class SoftwareInstallUpdateManagementService {
private final PackageManagerRegistry packageManagerRegistry;
private final ScriptService scriptService;
private final SoftwareDispatchService softwareDispatchService;
+ private final MachinePlatformResolver machinePlatformResolver;
public List install(SoftwareManagementInput input, String initiatedBy, ExecutionSource source) {
return dispatch(SoftwareAction.INSTALL, input, initiatedBy, source);
@@ -41,6 +44,7 @@ public List update(SoftwareManagementInput input, String
private List dispatch(SoftwareAction action, SoftwareManagementInput input,
String initiatedBy, ExecutionSource source) {
List machineIds = input.getMachineIds();
+ Map osTypes = machinePlatformResolver.osTypesByMachineId(machineIds);
Map scriptCache = new EnumMap<>(SoftwareScriptCode.class);
List results = new ArrayList<>(input.getPackages().size());
@@ -48,9 +52,17 @@ private List dispatch(SoftwareAction action, SoftwareMan
PackageManagerHandler handler = packageManagerRegistry.handlerFor(pkg.getPackageManager());
SoftwareScriptCode code = handler.scriptCode(action);
ScriptResponse script = scriptCache.computeIfAbsent(code, scriptService::getSoftwareScript);
- List args = handler.buildArgs(pkg.getPackageName(), pkg.getBrewPackageType());
- String executionId = softwareDispatchService.dispatch(script, machineIds, args, initiatedBy, source,
+ List targets = machinePlatformResolver.compatible(machineIds, osTypes, script.getSupportedPlatforms());
+ if (targets.isEmpty()) {
+ log.warn("Software {} skipped package {}/{}: no OS-compatible device among {} target(s) (supports {})",
+ action, pkg.getPackageManager(), pkg.getPackageName(), machineIds.size(),
+ script.getSupportedPlatforms());
+ continue;
+ }
+
+ List args = handler.buildArgs(pkg.getPackageName(), pkg.getBrewPackageType());
+ String executionId = softwareDispatchService.dispatch(script, targets, args, initiatedBy, source,
pkg.getPackageManager(), pkg.getPackageName(), action);
results.add(SoftwareDispatchResult.builder()
@@ -60,8 +72,8 @@ private List dispatch(SoftwareAction action, SoftwareMan
.build());
}
- log.info("Software {} dispatched: packages={} machines={} initiatedBy={} source={}",
- action, input.getPackages().size(), machineIds.size(), initiatedBy, source);
+ log.info("Software {} dispatched: packages={}/{} machines={} initiatedBy={} source={}",
+ action, results.size(), input.getPackages().size(), machineIds.size(), initiatedBy, source);
return results;
}
}
diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareInventoryService.java b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareInventoryService.java
new file mode 100644
index 0000000000..20324c91ae
--- /dev/null
+++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareInventoryService.java
@@ -0,0 +1,296 @@
+package com.openframe.api.service.rmm.software;
+
+import com.openframe.api.dto.rmm.software.SoftwareFilterOption;
+import com.openframe.api.dto.rmm.software.SoftwareFilters;
+import com.openframe.api.dto.rmm.software.SoftwareOnDeviceResponse;
+import com.openframe.api.dto.rmm.software.SoftwareOnDeviceStatus;
+import com.openframe.api.dto.rmm.software.SoftwareResponse;
+import com.openframe.api.dto.rmm.software.SoftwareVulnerabilityResponse;
+import com.openframe.api.dto.shared.PageResult;
+import com.openframe.api.service.rmm.fleet.FleetClientProvider;
+import com.openframe.api.service.rmm.fleet.FleetHostMachineResolver;
+import com.openframe.data.document.device.Machine;
+import com.openframe.data.service.TenantIdProvider;
+import com.openframe.sdk.fleetmdm.model.Host;
+import com.openframe.sdk.fleetmdm.model.HostSearchRequest;
+import com.openframe.sdk.fleetmdm.model.SoftwareTitle;
+import com.openframe.sdk.fleetmdm.model.SoftwareTitleRequest;
+import com.openframe.sdk.fleetmdm.model.SoftwareTitleVersion;
+import com.openframe.sdk.fleetmdm.model.SoftwareTitlesResponse;
+import com.openframe.sdk.fleetmdm.model.Vulnerability;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+import static org.springframework.util.StringUtils.hasText;
+
+@Slf4j
+@Service
+@ConditionalOnProperty(name = "openframe.rmm.software.enabled", havingValue = "true")
+@RequiredArgsConstructor
+public class SoftwareInventoryService {
+
+ private static final int DEVICES_PER_VERSION_LIMIT = 500;
+
+ private final FleetClientProvider fleet;
+ private final FleetHostMachineResolver hostMachineResolver;
+ private final TenantIdProvider tenantIdProvider;
+
+ public Optional findById(String softwareId) {
+ return parseNumericId(softwareId)
+ .map(id -> fleet.call(client -> client.getSoftwareTitle(id), "get Fleet software title id=" + id))
+ .map(FleetSoftwareMapper::toResponse);
+ }
+
+ public PageResult listSoftware(String search, int page, Integer perPage,
+ String orderKey, String orderDirection, Boolean vulnerable) {
+ SoftwareTitleRequest request = SoftwareTitleRequest.builder()
+ .page(page).perPage(perPage).query(search)
+ .orderKey(orderKey).orderDirection(orderDirection)
+ .vulnerable(vulnerable)
+ .build();
+ SoftwareTitlesResponse response = fleet.call(
+ client -> client.listSoftwareTitles(request),
+ "list Fleet software titles");
+ List items = response.getSoftwareTitles() == null
+ ? List.of()
+ : response.getSoftwareTitles().stream()
+ .map(FleetSoftwareMapper::toResponse)
+ .filter(Objects::nonNull)
+ .toList();
+ boolean hasNext = response.getMeta() != null
+ && Boolean.TRUE.equals(response.getMeta().getHasNextResults());
+ boolean hasPrev = response.getMeta() != null
+ && Boolean.TRUE.equals(response.getMeta().getHasPreviousResults());
+ int total = response.getCount() == null ? items.size() : response.getCount();
+ return new PageResult<>(items, hasNext, hasPrev, total, page);
+ }
+
+ public PageResult listVulnerabilitiesForSoftware(
+ String softwareId, String search, int page, Integer perPage,
+ String sortField, boolean sortAsc) {
+ Optional parsed = parseNumericId(softwareId);
+ if (parsed.isEmpty()) {
+ return PageResult.empty(page);
+ }
+ SoftwareTitle title = fleet.call(
+ client -> client.getSoftwareTitle(parsed.get()),
+ "get Fleet software title id=" + parsed.get());
+ if (title == null || title.getVersions() == null || title.getVersions().isEmpty()) {
+ return PageResult.empty(page);
+ }
+ List pairs = collectVersionCves(title.getVersions());
+ if (pairs.isEmpty()) {
+ return PageResult.empty(page);
+ }
+ Map enrichment = enrichCves(uniqueCves(pairs));
+ List all = pairs.stream()
+ .map(p -> FleetVulnerabilityMapper.toResponse(p.cve(), enrichment.get(p.cve()), p.version()))
+ .filter(Objects::nonNull)
+ .filter(v -> matchesSearch(v, search))
+ .sorted(comparator(sortField, sortAsc))
+ .toList();
+ return paginate(all, page, perPage);
+ }
+
+ /**
+ * Devices that have a given software title installed — backs the software detail "Devices" tab.
+ * Fleet exposes the installed version only per software version, so we fan out one host
+ * query per version of the title, tag each returned host with that version, correlate the Fleet
+ * host to an OpenFrame {@link Machine}, and derive the status from the version vs the title's latest.
+ * Hosts not enrolled in OpenFrame (no matching Machine) are dropped. Paginated in memory.
+ */
+ public PageResult listDevicesForSoftware(String softwareId, String search,
+ int page, Integer perPage) {
+ Optional titleId = parseNumericId(softwareId);
+ if (titleId.isEmpty()) {
+ return PageResult.empty(page);
+ }
+ SoftwareTitle title = fleet.call(
+ client -> client.getSoftwareTitle(titleId.get()),
+ "get Fleet software title id=" + titleId.get());
+ if (title == null || title.getVersions() == null || title.getVersions().isEmpty()) {
+ return PageResult.empty(page);
+ }
+ String latestVersion = FleetSoftwareMapper.toResponse(title).getLatestVersion();
+
+ List hostVersions = new ArrayList<>();
+ for (SoftwareTitleVersion version : title.getVersions()) {
+ if (version.getId() == null) {
+ continue;
+ }
+ HostSearchRequest request = new HostSearchRequest();
+ request.setSoftwareVersionId(version.getId());
+ request.setPerPage(DEVICES_PER_VERSION_LIMIT);
+ List hosts = fleet.call(
+ client -> client.searchHosts(request),
+ "list Fleet hosts for software_version_id=" + version.getId());
+ hosts.forEach(host -> hostVersions.add(new HostVersion(host, version.getVersion())));
+ }
+
+ Map machinesByHostId = hostMachineResolver.resolve(
+ tenantIdProvider.getTenantId(), hostVersions.stream().map(HostVersion::host).toList());
+
+ List all = hostVersions.stream()
+ .map(hv -> toDeviceResponse(hv, machinesByHostId, latestVersion))
+ .filter(Objects::nonNull)
+ .filter(row -> matchesDeviceSearch(row, search))
+ .toList();
+ return paginateList(all, page, perPage);
+ }
+
+ private static SoftwareOnDeviceResponse toDeviceResponse(HostVersion hv, Map machinesByHostId,
+ String latestVersion) {
+ Machine device = hv.host().getId() == null ? null : machinesByHostId.get(hv.host().getId());
+ if (device == null) {
+ return null;
+ }
+ SoftwareOnDeviceStatus status = latestVersion != null && latestVersion.equals(hv.version())
+ ? SoftwareOnDeviceStatus.UP_TO_DATE : SoftwareOnDeviceStatus.OUTDATED;
+ return SoftwareOnDeviceResponse.builder()
+ .device(device)
+ .softwareVersion(hv.version())
+ .status(status)
+ .build();
+ }
+
+ private static boolean matchesDeviceSearch(SoftwareOnDeviceResponse row, String search) {
+ if (!hasText(search)) {
+ return true;
+ }
+ String needle = search.toLowerCase(Locale.ROOT);
+ Machine d = row.getDevice();
+ return (d.getHostname() != null && d.getHostname().toLowerCase(Locale.ROOT).contains(needle))
+ || (row.getSoftwareVersion() != null && row.getSoftwareVersion().toLowerCase(Locale.ROOT).contains(needle));
+ }
+
+ private static PageResult paginateList(List all, int page, Integer perPage) {
+ int size = perPage != null && perPage > 0 ? perPage : all.size();
+ int from = Math.max(0, page * size);
+ int to = size == 0 ? 0 : Math.min(all.size(), from + size);
+ List slice = from >= to ? List.of() : List.copyOf(all.subList(from, to));
+ return new PageResult<>(slice, to < all.size(), from > 0, all.size(), page);
+ }
+
+ private record HostVersion(Host host, String version) {
+ }
+
+ /** Per-title scan cap when computing filter facet counts (one Fleet page). */
+ private static final int FILTERS_SCAN_LIMIT = 1000;
+
+ /**
+ * Faceted filter-option counts for the software list — Source / Version-status / Severity dropdowns.
+ * Fleet has no facet endpoint, so we scan the (searched) software titles and tally each dimension.
+ * Bounded to {@link #FILTERS_SCAN_LIMIT} titles.
+ */
+ public SoftwareFilters getSoftwareFilters(String search) {
+ List titles = listSoftware(search, 0, FILTERS_SCAN_LIMIT, null, null, null).items();
+ return SoftwareFilters.builder()
+ .sources(facet(titles, SoftwareResponse::getSource))
+ .versionStatuses(facet(titles, SoftwareResponse::getVersionStatus))
+ .severities(facet(titles, row -> row.getVulnerabilitySummary() == null
+ ? null : row.getVulnerabilitySummary().getHighestSeverity()))
+ .build();
+ }
+
+ private static > List facet(List titles,
+ Function dimension) {
+ Map counts = titles.stream()
+ .map(dimension)
+ .filter(Objects::nonNull)
+ .collect(Collectors.groupingBy(Function.identity(), Collectors.counting()));
+ return counts.entrySet().stream()
+ .sorted(Comparator.comparingInt(e -> e.getKey().ordinal()))
+ .map(e -> SoftwareFilterOption.builder()
+ .value(e.getKey().name())
+ .label(humanize(e.getKey().name()))
+ .count(e.getValue().intValue())
+ .build())
+ .toList();
+ }
+
+ private static String humanize(String enumName) {
+ String lower = enumName.toLowerCase(Locale.ROOT).replace('_', ' ');
+ return lower.isEmpty() ? lower : Character.toUpperCase(lower.charAt(0)) + lower.substring(1);
+ }
+
+ private static Optional parseNumericId(String softwareId) {
+ if (!hasText(softwareId)) {
+ return Optional.empty();
+ }
+ try {
+ return Optional.of(Long.parseLong(softwareId));
+ } catch (NumberFormatException e) {
+ log.debug("softwareId={} is not numeric — Fleet ids are numeric, returning empty", softwareId);
+ return Optional.empty();
+ }
+ }
+
+ private static List collectVersionCves(List versions) {
+ return versions.stream()
+ .filter(v -> v.getVulnerabilities() != null)
+ .flatMap(v -> v.getVulnerabilities().stream()
+ .filter(cve -> hasText(cve))
+ .map(cve -> new VersionCve(v.getVersion(), cve)))
+ .toList();
+ }
+
+ private static Set uniqueCves(List pairs) {
+ return pairs.stream().map(VersionCve::cve).collect(Collectors.toSet());
+ }
+
+ private Map enrichCves(Set cves) {
+ return cves.parallelStream().collect(Collectors.toConcurrentMap(
+ cve -> cve,
+ cve -> Optional.ofNullable(fleet.call(
+ client -> client.getVulnerability(cve),
+ "get Fleet vulnerability " + cve)).orElse(null)));
+ }
+
+ private static boolean matchesSearch(SoftwareVulnerabilityResponse row, String search) {
+ if (!hasText(search)) {
+ return true;
+ }
+ String needle = search.toLowerCase(Locale.ROOT);
+ return row.getCveId() != null && row.getCveId().toLowerCase(Locale.ROOT).contains(needle);
+ }
+
+ private static Comparator comparator(String field, boolean ascending) {
+ Comparator base = switch (field == null ? "" : field) {
+ case "severity", "cvssScore" -> Comparator.comparing(
+ SoftwareVulnerabilityResponse::getCvssScore,
+ Comparator.nullsLast(Comparator.naturalOrder()));
+ case "publishedAt", "published" -> Comparator.comparing(
+ SoftwareVulnerabilityResponse::getPublishedAt,
+ Comparator.nullsLast(Comparator.naturalOrder()));
+ default -> Comparator.comparing(
+ SoftwareVulnerabilityResponse::getCveId,
+ Comparator.nullsLast(Comparator.naturalOrder()));
+ };
+ return ascending ? base : base.reversed();
+ }
+
+ private static PageResult paginate(List all,
+ int page, Integer perPage) {
+ int size = perPage != null && perPage > 0 ? perPage : all.size();
+ int from = Math.max(0, page * size);
+ int to = Math.min(all.size(), from + size);
+ List slice = from >= to ? List.of() : List.copyOf(all.subList(from, to));
+ return new PageResult<>(slice, to < all.size(), from > 0, all.size(), page);
+ }
+
+ private record VersionCve(String version, String cve) {
+ }
+}
diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareScheduleService.java b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareScheduleService.java
index 951245d9b3..6bc7f28007 100644
--- a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareScheduleService.java
+++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareScheduleService.java
@@ -8,6 +8,8 @@
import com.openframe.core.exception.BadRequestException;
import com.openframe.core.exception.ConflictException;
import com.openframe.core.exception.NotFoundException;
+import com.openframe.data.document.rmm.schedule.ScheduleDeviceCriteria;
+import com.openframe.data.document.rmm.schedule.ScheduleDeviceSelectionMode;
import com.openframe.data.document.rmm.schedule.ScheduleScriptTrigger;
import com.openframe.data.document.rmm.schedule.ScheduleTimeReference;
import com.openframe.data.document.rmm.schedule.SoftwareSchedule;
@@ -152,22 +154,22 @@ private SoftwareScheduleResponse transitionTo(String id, ScriptStatus target) {
return toResponse(entity);
}
- /** Replace the full assigned device set (PUT — backs "Edit Devices"). */
public SoftwareScheduleResponse setDevices(String scheduleId, List machineIds, String actor) {
String tenantId = tenantIdProvider.getTenantId();
SoftwareSchedule entity = loadVisibleOrThrow(tenantId, scheduleId);
+ ensureSpecificMode(entity);
replaceDevices(tenantId, scheduleId, machineIds, actor);
return toResponse(entity);
}
- /** Incrementally assign devices (idempotent — already-assigned ids are skipped). */
public void addDevices(String scheduleId, List machineIds, String actor) {
String tenantId = tenantIdProvider.getTenantId();
- loadVisibleOrThrow(tenantId, scheduleId);
+ SoftwareSchedule entity = loadVisibleOrThrow(tenantId, scheduleId);
if (machineIds == null || machineIds.isEmpty()) {
return;
}
- Set existing = new HashSet<>(getMachineIds(scheduleId));
+ ensureSpecificMode(entity);
+ Set existing = new HashSet<>(assignedMachineIds(tenantId, scheduleId));
List rows = machineIds.stream().distinct()
.filter(id -> !existing.contains(id))
.map(machineId -> SoftwareScheduleMachineAssigned.builder()
@@ -187,13 +189,36 @@ public void removeDevices(String scheduleId, List machineIds, String act
assignedRepository.deleteByTenantIdAndSoftwareScheduleIdAndMachineIdIn(tenantId, scheduleId, machineIds);
}
+ public SoftwareScheduleResponse setDeviceCriteria(String scheduleId, ScheduleDeviceCriteria criteria, String actor) {
+ String tenantId = tenantIdProvider.getTenantId();
+ SoftwareSchedule entity = loadVisibleOrThrow(tenantId, scheduleId);
+ entity.setSelectionMode(ScheduleDeviceSelectionMode.CRITERIA);
+ entity.setDeviceCriteria(criteria);
+ SoftwareSchedule saved = scheduleRepository.save(entity);
+ log.info("Applied device criteria to software schedule id={} tenantId={} by user={}: {}",
+ scheduleId, tenantId, actor, criteria);
+ return toResponse(saved);
+ }
+
public List getMachineIds(String scheduleId) {
- return targetResolver.resolveMachineIds(tenantIdProvider.getTenantId(), scheduleId);
+ return targetResolver.resolveMachineIds(loadVisibleOrThrow(tenantIdProvider.getTenantId(), scheduleId));
}
public int deviceCount(String scheduleId) {
- return (int) assignedRepository
- .countByTenantIdAndSoftwareScheduleId(tenantIdProvider.getTenantId(), scheduleId);
+ return getMachineIds(scheduleId).size();
+ }
+
+ private List assignedMachineIds(String tenantId, String scheduleId) {
+ return assignedRepository.findByTenantIdAndSoftwareScheduleId(tenantId, scheduleId).stream()
+ .map(SoftwareScheduleMachineAssigned::getMachineId).toList();
+ }
+
+ private void ensureSpecificMode(SoftwareSchedule schedule) {
+ if (schedule.getSelectionMode() != ScheduleDeviceSelectionMode.SPECIFIC) {
+ schedule.setSelectionMode(ScheduleDeviceSelectionMode.SPECIFIC);
+ schedule.setDeviceCriteria(null);
+ scheduleRepository.save(schedule);
+ }
}
private void replaceDevices(String tenantId, String scheduleId, List machineIds, String actor) {
@@ -244,7 +269,8 @@ private SoftwareScheduleResponse toResponse(SoftwareSchedule s) {
return SoftwareScheduleResponse.builder()
.id(s.getId()).name(s.getName()).description(s.getDescription())
.action(s.getAction()).packages(s.getPackages())
- .selectionMode(s.getSelectionMode()).trigger(s.getTrigger()).timeReference(s.getTimeReference())
+ .selectionMode(s.getSelectionMode()).deviceCriteria(s.getDeviceCriteria())
+ .trigger(s.getTrigger()).timeReference(s.getTimeReference())
.offlineBehavior(s.getOfflineBehavior()).reconnectWindowSeconds(s.getReconnectWindowSeconds())
.startAt(s.getStartAt()).repeat(s.getRepeat())
.nextRunAt(s.getNextRunAt()).lastRunAt(s.getLastRunAt())
diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/vulnerability/FleetGlobalVulnerabilityMapper.java b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/vulnerability/FleetGlobalVulnerabilityMapper.java
new file mode 100644
index 0000000000..6145458cff
--- /dev/null
+++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/vulnerability/FleetGlobalVulnerabilityMapper.java
@@ -0,0 +1,95 @@
+package com.openframe.api.service.rmm.vulnerability;
+
+import com.openframe.api.dto.rmm.software.SoftwareCveSeverity;
+import com.openframe.api.dto.rmm.vulnerability.AffectedSoftwareResponse;
+import com.openframe.api.dto.rmm.vulnerability.VulnerabilityResponse;
+import com.openframe.api.service.rmm.fleet.FleetSoftwareCategory;
+import com.openframe.sdk.fleetmdm.model.AffectedSoftware;
+import com.openframe.sdk.fleetmdm.model.Vulnerability;
+
+import java.time.Instant;
+import java.time.format.DateTimeParseException;
+import java.util.List;
+import java.util.Objects;
+
+public final class FleetGlobalVulnerabilityMapper {
+
+ private FleetGlobalVulnerabilityMapper() {
+ }
+
+ public static VulnerabilityResponse toListRow(Vulnerability v) {
+ if (v == null || v.getCve() == null) {
+ return null;
+ }
+ return baseBuilder(v).build();
+ }
+
+ public static VulnerabilityResponse toDetail(Vulnerability v) {
+ if (v == null || v.getCve() == null) {
+ return null;
+ }
+ return baseBuilder(v)
+ .description(v.getCveDescription())
+ .resolvedInVersion(v.getResolvedInVersion())
+ .affectedSoftware(mapAffected(v.getSoftware()))
+ .build();
+ }
+
+ private static VulnerabilityResponse.VulnerabilityResponseBuilder baseBuilder(Vulnerability v) {
+ return VulnerabilityResponse.builder()
+ .cveId(v.getCve())
+ .severity(bucketSeverity(v.getCvssScore()))
+ .cvssScore(v.getCvssScore())
+ .epssProbability(v.getEpssProbability())
+ .cisaKnownExploit(v.getCisaKnownExploit())
+ .publishedAt(parseInstant(v.getCvePublished()))
+ .detailsLink(v.getDetailsLink())
+ .devicesCount(v.getHostsCount());
+ }
+
+ private static List mapAffected(List software) {
+ if (software == null || software.isEmpty()) {
+ return List.of();
+ }
+ return software.stream()
+ .map(FleetGlobalVulnerabilityMapper::toAffected)
+ .filter(Objects::nonNull)
+ .toList();
+ }
+
+ private static AffectedSoftwareResponse toAffected(AffectedSoftware s) {
+ if (s == null) {
+ return null;
+ }
+ return AffectedSoftwareResponse.builder()
+ .id(Objects.toString(s.getId(), null))
+ .name(s.getName())
+ .source(FleetSoftwareCategory.of(s.getSource()).source())
+ .version(s.getVersion())
+ .devicesCount(s.getHostsCount())
+ .resolvedInVersion(s.getResolvedInVersion())
+ .build();
+ }
+
+ static SoftwareCveSeverity bucketSeverity(Double cvss) {
+ if (cvss == null) {
+ return null;
+ }
+ if (cvss >= 9.0) return SoftwareCveSeverity.CRITICAL;
+ if (cvss >= 7.0) return SoftwareCveSeverity.HIGH;
+ if (cvss >= 4.0) return SoftwareCveSeverity.MEDIUM;
+ if (cvss > 0.0) return SoftwareCveSeverity.LOW;
+ return SoftwareCveSeverity.NONE;
+ }
+
+ private static Instant parseInstant(String iso) {
+ if (iso == null || iso.isBlank()) {
+ return null;
+ }
+ try {
+ return Instant.parse(iso);
+ } catch (DateTimeParseException e) {
+ return null;
+ }
+ }
+}
diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/vulnerability/VulnerabilityInventoryService.java b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/vulnerability/VulnerabilityInventoryService.java
new file mode 100644
index 0000000000..8f33236a13
--- /dev/null
+++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/vulnerability/VulnerabilityInventoryService.java
@@ -0,0 +1,131 @@
+package com.openframe.api.service.rmm.vulnerability;
+
+import com.openframe.api.dto.rmm.software.SoftwareCveSeverity;
+import com.openframe.api.dto.rmm.vulnerability.VulnerabilityResponse;
+import com.openframe.api.dto.shared.PageResult;
+import com.openframe.api.service.rmm.fleet.FleetClientProvider;
+import com.openframe.api.service.rmm.fleet.FleetHostMachineResolver;
+import com.openframe.data.document.device.Machine;
+import com.openframe.data.service.TenantIdProvider;
+import com.openframe.sdk.fleetmdm.model.Host;
+import com.openframe.sdk.fleetmdm.model.HostSearchRequest;
+import com.openframe.sdk.fleetmdm.model.VulnerabilitiesResponse;
+import com.openframe.sdk.fleetmdm.model.Vulnerability;
+import com.openframe.sdk.fleetmdm.model.VulnerabilityRequest;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+
+import static org.springframework.util.StringUtils.hasText;
+
+@Slf4j
+@Service
+@ConditionalOnProperty(name = "openframe.rmm.software.enabled", havingValue = "true")
+@RequiredArgsConstructor
+public class VulnerabilityInventoryService {
+
+ private static final int DEVICES_LIMIT = 500;
+
+ private final FleetClientProvider fleet;
+ private final FleetHostMachineResolver hostMachineResolver;
+ private final TenantIdProvider tenantIdProvider;
+
+ public PageResult listDevicesForCve(String cveId, String search, int page, Integer perPage) {
+ if (!hasText(cveId)) {
+ return PageResult.empty(page);
+ }
+ HostSearchRequest request = new HostSearchRequest();
+ request.setCve(cveId);
+ request.setPerPage(DEVICES_LIMIT);
+ List hosts = fleet.call(
+ client -> client.searchHosts(request),
+ "list Fleet hosts for vulnerability=" + cveId);
+ Map machinesByHostId = hostMachineResolver.resolve(tenantIdProvider.getTenantId(), hosts);
+
+ List all = hosts.stream()
+ .map(host -> host.getId() == null ? null : machinesByHostId.get(host.getId()))
+ .filter(Objects::nonNull)
+ .distinct()
+ .filter(machine -> matchesDeviceSearch(machine, search))
+ .toList();
+ return paginate(all, page, perPage);
+ }
+
+ private static boolean matchesDeviceSearch(Machine machine, String search) {
+ if (!hasText(search)) {
+ return true;
+ }
+ String needle = search.toLowerCase(Locale.ROOT);
+ return machine.getHostname() != null && machine.getHostname().toLowerCase(Locale.ROOT).contains(needle);
+ }
+
+ private static PageResult paginate(List all, int page, Integer perPage) {
+ int size = perPage != null && perPage > 0 ? perPage : all.size();
+ int from = Math.max(0, page * size);
+ int to = size == 0 ? 0 : Math.min(all.size(), from + size);
+ List slice = from >= to ? List.of() : List.copyOf(all.subList(from, to));
+ return new PageResult<>(slice, to < all.size(), from > 0, all.size(), page);
+ }
+
+ public Optional findByCveId(String cveId) {
+ if (!hasText(cveId)) {
+ return Optional.empty();
+ }
+ Vulnerability detail = fleet.call(
+ client -> client.getVulnerability(cveId),
+ "get Fleet vulnerability " + cveId);
+ return Optional.ofNullable(FleetGlobalVulnerabilityMapper.toDetail(detail));
+ }
+
+ public PageResult listVulnerabilities(
+ String search, int page, Integer perPage,
+ String orderKey, String orderDirection,
+ Boolean exploit, SoftwareCveSeverity minSeverity) {
+ VulnerabilityRequest request = VulnerabilityRequest.builder()
+ .page(page).perPage(perPage).query(search)
+ .orderKey(orderKey).orderDirection(orderDirection)
+ .exploit(exploit)
+ .build();
+ VulnerabilitiesResponse response = fleet.call(
+ client -> client.listVulnerabilities(request),
+ "list Fleet vulnerabilities");
+ List items = response.getVulnerabilities() == null
+ ? List.of()
+ : response.getVulnerabilities().stream()
+ .map(FleetGlobalVulnerabilityMapper::toListRow)
+ .filter(Objects::nonNull)
+ .filter(row -> passesMinSeverity(row, minSeverity))
+ .toList();
+ boolean hasNext = response.getMeta() != null
+ && Boolean.TRUE.equals(response.getMeta().getHasNextResults());
+ boolean hasPrev = response.getMeta() != null
+ && Boolean.TRUE.equals(response.getMeta().getHasPreviousResults());
+ int total = response.getCount() == null ? items.size() : response.getCount().intValue();
+ return new PageResult<>(items, hasNext, hasPrev, total, page);
+ }
+
+ private static boolean passesMinSeverity(VulnerabilityResponse row, SoftwareCveSeverity min) {
+ if (min == null || min == SoftwareCveSeverity.NONE) {
+ return true;
+ }
+ SoftwareCveSeverity actual = row.getSeverity();
+ return actual != null && severityRank(actual) >= severityRank(min);
+ }
+
+ private static int severityRank(SoftwareCveSeverity s) {
+ return switch (s) {
+ case CRITICAL -> 4;
+ case HIGH -> 3;
+ case MEDIUM -> 2;
+ case LOW -> 1;
+ case NONE -> 0;
+ };
+ }
+}
diff --git a/openframe-api-lib/src/test/java/com/openframe/api/dto/rmm/software/SoftwareActionIdTest.java b/openframe-api-lib/src/test/java/com/openframe/api/dto/rmm/software/SoftwareActionIdTest.java
new file mode 100644
index 0000000000..0bd78bebb2
--- /dev/null
+++ b/openframe-api-lib/src/test/java/com/openframe/api/dto/rmm/software/SoftwareActionIdTest.java
@@ -0,0 +1,41 @@
+package com.openframe.api.dto.rmm.software;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class SoftwareActionIdTest {
+
+ @Test
+ void roundTripsAllThreeKeys() {
+ SoftwareActionId decoded = SoftwareActionId.decode(
+ SoftwareActionId.of("exec-1", "bundle-1", "sched-1").encode());
+ assertThat(decoded.executionId()).isEqualTo("exec-1");
+ assertThat(decoded.bundleId()).isEqualTo("bundle-1");
+ assertThat(decoded.scheduleId()).isEqualTo("sched-1");
+ }
+
+ @Test
+ void roundTripsWithMissingKeys() {
+ SoftwareActionId decoded = SoftwareActionId.decode(
+ SoftwareActionId.of("exec-1", null, "sched-1").encode());
+ assertThat(decoded.executionId()).isEqualTo("exec-1");
+ assertThat(decoded.bundleId()).isNull();
+ assertThat(decoded.scheduleId()).isEqualTo("sched-1");
+ }
+
+ @Test
+ void nonTokenValueIsTreatedAsBareExecutionId() {
+ // A legacy raw executionId (not base64 of 3 parts) still resolves as the executionId.
+ SoftwareActionId decoded = SoftwareActionId.decode("plain-execution-id");
+ assertThat(decoded.executionId()).isEqualTo("plain-execution-id");
+ assertThat(decoded.bundleId()).isNull();
+ assertThat(decoded.scheduleId()).isNull();
+ }
+
+ @Test
+ void blankIsEmpty() {
+ assertThat(SoftwareActionId.decode(null).executionId()).isNull();
+ assertThat(SoftwareActionId.decode("").executionId()).isNull();
+ }
+}
diff --git a/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/software/SoftwareActionDetailServiceTest.java b/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/software/SoftwareActionDetailServiceTest.java
new file mode 100644
index 0000000000..bdadd9b7a2
--- /dev/null
+++ b/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/software/SoftwareActionDetailServiceTest.java
@@ -0,0 +1,176 @@
+package com.openframe.api.service.rmm.software;
+
+import com.openframe.api.dto.rmm.software.SoftwareActionDeviceFilterInput;
+import com.openframe.api.dto.rmm.software.SoftwareActionDeviceResponse;
+import com.openframe.data.document.device.Machine;
+import com.openframe.data.document.organization.Organization;
+import com.openframe.data.document.rmm.schedule.DeviceOnlineDispatchStatus;
+import com.openframe.data.document.rmm.schedule.SoftwareScheduleMachineAssigned;
+import com.openframe.data.document.rmm.schedule.SoftwareScheduleOnlineDispatch;
+import com.openframe.data.document.rmm.script.ExecutionStatus;
+import com.openframe.data.document.rmm.script.ScriptExecution;
+import com.openframe.data.document.rmm.software.SoftwareActionStatus;
+import com.openframe.data.document.rmm.software.SoftwareBundleOnlineDispatch;
+import com.openframe.data.repository.device.MachineRepository;
+import com.openframe.data.repository.organization.OrganizationRepository;
+import com.openframe.data.repository.rmm.ScriptExecutionRepository;
+import com.openframe.data.repository.rmm.SoftwareBundleOnlineDispatchRepository;
+import com.openframe.data.repository.rmm.SoftwareScheduleMachineAssignedRepository;
+import com.openframe.data.repository.rmm.SoftwareScheduleOnlineDispatchRepository;
+import com.openframe.data.service.TenantIdProvider;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class SoftwareActionDetailServiceTest {
+
+ private static final String TENANT = "t1";
+ private static final String EXEC = "exec-1";
+
+ @Mock private ScriptExecutionRepository scriptExecutionRepository;
+ @Mock private SoftwareBundleOnlineDispatchRepository bundleOnlineDispatchRepository;
+ @Mock private SoftwareScheduleOnlineDispatchRepository scheduleOnlineDispatchRepository;
+ @Mock private SoftwareScheduleMachineAssignedRepository scheduleMachineAssignedRepository;
+ @Mock private MachineRepository machineRepository;
+ @Mock private OrganizationRepository organizationRepository;
+ @Mock private TenantIdProvider tenantIdProvider;
+
+ private SoftwareActionDetailService service;
+
+ @BeforeEach
+ void setUp() {
+ service = new SoftwareActionDetailService(scriptExecutionRepository, bundleOnlineDispatchRepository,
+ scheduleOnlineDispatchRepository, scheduleMachineAssignedRepository, machineRepository,
+ organizationRepository, tenantIdProvider);
+ when(tenantIdProvider.getTenantId()).thenReturn(TENANT);
+ // Enrichment always runs; default to no machine metadata unless a test provides it.
+ lenient().when(machineRepository.findByTenantIdAndMachineIdIn(eq(TENANT), any())).thenReturn(List.of());
+ }
+
+ @Test
+ @DisplayName("NOW: leaves map their status, offline NEW sentinels become SCHEDULED, OS-skip is hidden")
+ void bundleDevices() {
+ when(scriptExecutionRepository.findByTenantIdAndExecutionId(TENANT, EXEC)).thenReturn(List.of(
+ leaf("m-success", ExecutionStatus.SUCCESS),
+ leaf("m-running", ExecutionStatus.RUNNING)));
+ when(bundleOnlineDispatchRepository.findByTenantIdAndBundleId(TENANT, "b1")).thenReturn(List.of(
+ bundleSentinel("m-offline", DeviceOnlineDispatchStatus.NEW), // waiting → SCHEDULED
+ bundleSentinel("m-skipped", DeviceOnlineDispatchStatus.DISPATCHED)));// dispatched, no leaf → hidden
+
+ List rows = service.devices(EXEC, "b1", null, null, null);
+
+ assertThat(rows).extracting(SoftwareActionDeviceResponse::getMachineId)
+ .containsExactlyInAnyOrder("m-success", "m-running", "m-offline");
+ assertThat(status(rows, "m-success")).isEqualTo(SoftwareActionStatus.COMPLETED);
+ assertThat(status(rows, "m-running")).isEqualTo(SoftwareActionStatus.IN_PROGRESS);
+ assertThat(status(rows, "m-offline")).isEqualTo(SoftwareActionStatus.SCHEDULED);
+ }
+
+ @Test
+ @DisplayName("SCHEDULE: assigned machines seed SCHEDULED; EXPIRED sentinel → FAILED; leaf wins")
+ void scheduleDevices() {
+ when(scheduleMachineAssignedRepository.findByTenantIdAndSoftwareScheduleId(TENANT, "s1")).thenReturn(List.of(
+ assigned("m-a"), assigned("m-b"), assigned("m-done")));
+ when(scriptExecutionRepository.findByTenantIdAndExecutionId(TENANT, EXEC)).thenReturn(List.of(
+ leaf("m-done", ExecutionStatus.FAILED)));
+ when(scheduleOnlineDispatchRepository.findByTenantIdAndScheduleId(TENANT, "s1")).thenReturn(List.of(
+ scheduleSentinel("m-a", DeviceOnlineDispatchStatus.NEW),
+ scheduleSentinel("m-b", DeviceOnlineDispatchStatus.EXPIRED)));
+
+ List rows = service.devices(EXEC, null, "s1", null, null);
+
+ assertThat(rows).extracting(SoftwareActionDeviceResponse::getMachineId)
+ .containsExactlyInAnyOrder("m-a", "m-b", "m-done");
+ assertThat(status(rows, "m-a")).isEqualTo(SoftwareActionStatus.SCHEDULED);
+ assertThat(status(rows, "m-b")).isEqualTo(SoftwareActionStatus.FAILED);
+ assertThat(status(rows, "m-done")).isEqualTo(SoftwareActionStatus.FAILED);
+ }
+
+ @Test
+ @DisplayName("enrich + filters: hostname/customer resolved; status / customer / search narrow the rows")
+ void enrichAndFilter() {
+ when(scriptExecutionRepository.findByTenantIdAndExecutionId(TENANT, EXEC)).thenReturn(List.of(
+ leaf("alpha", ExecutionStatus.SUCCESS),
+ leaf("beta", ExecutionStatus.SUCCESS),
+ leaf("gamma", ExecutionStatus.FAILED)));
+ when(machineRepository.findByTenantIdAndMachineIdIn(eq(TENANT), any())).thenReturn(List.of(
+ machine("alpha", "alpha-host", "org-1"),
+ machine("beta", "beta-host", "org-2"),
+ machine("gamma", "gamma-host", "org-1")));
+ when(organizationRepository.findByOrganizationIdIn(any())).thenReturn(List.of(
+ org("org-1", "Acme"), org("org-2", "Globex")));
+
+ // No filter → all three, enriched with hostname + customer name.
+ List all = service.devices(EXEC, null, null, null, null);
+ assertThat(all).hasSize(3);
+ SoftwareActionDeviceResponse alpha = all.stream().filter(r -> r.getMachineId().equals("alpha")).findFirst().orElseThrow();
+ assertThat(alpha.getHostname()).isEqualTo("alpha-host");
+ assertThat(alpha.getOrganizationId()).isEqualTo("org-1");
+ assertThat(alpha.getOrganizationName()).isEqualTo("Acme");
+
+ // Status filter.
+ List completed = service.devices(EXEC, null, null, filter(List.of(SoftwareActionStatus.COMPLETED), null), null);
+ assertThat(completed).extracting(SoftwareActionDeviceResponse::getMachineId).containsExactlyInAnyOrder("alpha", "beta");
+
+ // Customer filter.
+ List org1 = service.devices(EXEC, null, null, filter(null, List.of("org-1")), null);
+ assertThat(org1).extracting(SoftwareActionDeviceResponse::getMachineId).containsExactlyInAnyOrder("alpha", "gamma");
+
+ // Search by hostname.
+ List search = service.devices(EXEC, null, null, null, "BETA-HOST");
+ assertThat(search).extracting(SoftwareActionDeviceResponse::getMachineId).containsExactly("beta");
+ }
+
+ private static SoftwareActionDeviceFilterInput filter(List statuses, List organizationIds) {
+ SoftwareActionDeviceFilterInput f = new SoftwareActionDeviceFilterInput();
+ f.setStatuses(statuses);
+ f.setOrganizationIds(organizationIds);
+ return f;
+ }
+
+ private static SoftwareActionStatus status(List rows, String machineId) {
+ return rows.stream().filter(r -> r.getMachineId().equals(machineId)).findFirst().orElseThrow().getStatus();
+ }
+
+ private static ScriptExecution leaf(String machineId, ExecutionStatus status) {
+ return ScriptExecution.builder().machineId(machineId).status(status).build();
+ }
+
+ private static SoftwareBundleOnlineDispatch bundleSentinel(String machineId, DeviceOnlineDispatchStatus status) {
+ return SoftwareBundleOnlineDispatch.builder().machineId(machineId).status(status).build();
+ }
+
+ private static SoftwareScheduleOnlineDispatch scheduleSentinel(String machineId, DeviceOnlineDispatchStatus status) {
+ return SoftwareScheduleOnlineDispatch.builder().machineId(machineId).status(status).build();
+ }
+
+ private static SoftwareScheduleMachineAssigned assigned(String machineId) {
+ SoftwareScheduleMachineAssigned a = new SoftwareScheduleMachineAssigned();
+ a.setMachineId(machineId);
+ return a;
+ }
+
+ private static Machine machine(String machineId, String hostname, String organizationId) {
+ Machine m = new Machine();
+ m.setMachineId(machineId);
+ m.setHostname(hostname);
+ m.setOrganizationId(organizationId);
+ return m;
+ }
+
+ private static Organization org(String organizationId, String name) {
+ return Organization.builder().organizationId(organizationId).name(name).build();
+ }
+}
diff --git a/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/software/SoftwareActionServiceTest.java b/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/software/SoftwareActionServiceTest.java
new file mode 100644
index 0000000000..df94675b38
--- /dev/null
+++ b/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/software/SoftwareActionServiceTest.java
@@ -0,0 +1,149 @@
+package com.openframe.api.service.rmm.software;
+
+import com.openframe.api.dto.rmm.script.ScriptFilterOption;
+import com.openframe.api.dto.rmm.software.SoftwareActionFilters;
+import com.openframe.api.dto.rmm.software.SoftwareActionResponse;
+import com.openframe.api.dto.shared.PageResult;
+import com.openframe.api.mapper.ScriptFilterOptionMapper;
+import com.openframe.data.document.packagesearch.PackageManagerType;
+import com.openframe.data.document.rmm.schedule.SoftwareSchedule;
+import com.openframe.data.document.rmm.schedule.SoftwareSchedulePackage;
+import com.openframe.data.document.rmm.script.ScriptStatus;
+import com.openframe.data.document.rmm.software.SoftwareAction;
+import com.openframe.data.document.rmm.software.SoftwareActionStatus;
+import com.openframe.data.document.rmm.software.SoftwareActionSummary;
+import com.openframe.data.repository.rmm.SoftwareActionAggregationRepository;
+import com.openframe.data.repository.rmm.SoftwareScheduleMachineAssignedRepository;
+import com.openframe.data.repository.rmm.SoftwareScheduleRepository;
+import com.openframe.data.service.TenantIdProvider;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.data.domain.Sort;
+
+import java.time.Instant;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class SoftwareActionServiceTest {
+
+ private static final String TENANT = "t1";
+
+ @Mock private SoftwareActionAggregationRepository aggregationRepository;
+ @Mock private SoftwareScheduleRepository scheduleRepository;
+ @Mock private SoftwareScheduleMachineAssignedRepository assignedRepository;
+ @Mock private TenantIdProvider tenantIdProvider;
+
+ private final ScriptFilterOptionMapper optionMapper = new ScriptFilterOptionMapper(null, null);
+
+ private SoftwareActionService service;
+
+ @BeforeEach
+ void setUp() {
+ service = new SoftwareActionService(aggregationRepository, scheduleRepository, assignedRepository, optionMapper, tenantIdProvider);
+ when(tenantIdProvider.getTenantId()).thenReturn(TENANT);
+ }
+
+ @Test
+ @DisplayName("list: executed rows come from the leaf aggregation with derived status + X/Y")
+ void list_executedRows() {
+ when(aggregationRepository.getDefaultSortField()).thenReturn("dispatchedAt");
+ when(scheduleRepository.findByTenantIdAndStatusAndNextRunAtGreaterThanOrderByNextRunAtAsc(eq(TENANT), eq(ScriptStatus.ACTIVE), any()))
+ .thenReturn(List.of());
+ when(aggregationRepository.count(eq(TENANT), any(), any())).thenReturn(1L);
+ when(aggregationRepository.findPage(eq(TENANT), any(), any(), eq("dispatchedAt"), eq(Sort.Direction.DESC), eq(0), eq(25)))
+ .thenReturn(List.of(summary("exec-1", PackageManagerType.BREW, "slack",
+ SoftwareAction.UPDATE, SoftwareActionStatus.FAILED, 7, 4)));
+
+ PageResult result = service.list(null, null, null, 0, 25);
+
+ assertThat(result.filteredCount()).isEqualTo(1);
+ SoftwareActionResponse row = result.items().get(0);
+ assertThat(row.getSoftware()).isEqualTo("slack");
+ assertThat(row.getAction()).isEqualTo(SoftwareAction.UPDATE);
+ assertThat(row.getEngine()).isEqualTo(PackageManagerType.BREW);
+ assertThat(row.getStatus()).isEqualTo(SoftwareActionStatus.FAILED);
+ assertThat(row.getTotalMachineCount()).isEqualTo(7);
+ assertThat(row.getRespondedMachineCount()).isEqualTo(4);
+ }
+
+ @Test
+ @DisplayName("list: upcoming schedules appear as SCHEDULED rows (0/Y) on top, merged with executed")
+ void list_mergesScheduledOnTop() {
+ SoftwareSchedule schedule = SoftwareSchedule.builder()
+ .id("s1").tenantId(TENANT).createdBy("user-1").action(SoftwareAction.INSTALL)
+ .nextRunAt(Instant.now().plusSeconds(3600))
+ .packages(List.of(SoftwareSchedulePackage.builder()
+ .packageManager(PackageManagerType.BREW).packageName("crowdstrike").build()))
+ .build();
+ when(scheduleRepository.findByTenantIdAndStatusAndNextRunAtGreaterThanOrderByNextRunAtAsc(eq(TENANT), eq(ScriptStatus.ACTIVE), any()))
+ .thenReturn(List.of(schedule));
+ when(assignedRepository.countByTenantIdAndSoftwareScheduleId(TENANT, "s1")).thenReturn(7L);
+ when(aggregationRepository.count(eq(TENANT), any(), any())).thenReturn(0L);
+ when(aggregationRepository.findPage(eq(TENANT), any(), any(), any(), any(), eq(0), eq(24)))
+ .thenReturn(List.of());
+
+ PageResult result = service.list(null, null, null, 0, 25);
+
+ assertThat(result.filteredCount()).isEqualTo(1);
+ SoftwareActionResponse row = result.items().get(0);
+ assertThat(row.getStatus()).isEqualTo(SoftwareActionStatus.SCHEDULED);
+ assertThat(row.getSoftware()).isEqualTo("crowdstrike");
+ assertThat(row.getTotalMachineCount()).isEqualTo(7);
+ assertThat(row.getRespondedMachineCount()).isZero();
+ assertThat(row.getScheduledAt()).isNotNull();
+ }
+
+ @Test
+ @DisplayName("filters: executed facets merge with scheduled tallies; SCHEDULED comes only from schedules")
+ void filters_mergeExecutedAndScheduled() {
+ when(aggregationRepository.facet(eq(TENANT), any(), any(), eq("status")))
+ .thenReturn(new java.util.LinkedHashMap<>(java.util.Map.of("COMPLETED", 3, "FAILED", 1)));
+ when(aggregationRepository.facet(eq(TENANT), any(), any(), eq("action")))
+ .thenReturn(new java.util.LinkedHashMap<>(java.util.Map.of("UPDATE", 4)));
+ when(aggregationRepository.facet(eq(TENANT), any(), any(), eq("packageManager")))
+ .thenReturn(new java.util.LinkedHashMap<>(java.util.Map.of("BREW", 4)));
+ when(aggregationRepository.count(eq(TENANT), any(), any())).thenReturn(4L);
+
+ SoftwareSchedule schedule = SoftwareSchedule.builder()
+ .id("s1").tenantId(TENANT).createdBy("user-1").action(SoftwareAction.INSTALL)
+ .nextRunAt(Instant.now().plusSeconds(3600))
+ .packages(List.of(SoftwareSchedulePackage.builder()
+ .packageManager(PackageManagerType.WINGET).packageName("chrome").build()))
+ .build();
+ when(scheduleRepository.findByTenantIdAndStatusAndNextRunAtGreaterThanOrderByNextRunAtAsc(eq(TENANT), eq(ScriptStatus.ACTIVE), any()))
+ .thenReturn(List.of(schedule));
+ when(assignedRepository.countByTenantIdAndSoftwareScheduleId(TENANT, "s1")).thenReturn(7L);
+
+ SoftwareActionFilters filters = service.filters(null, null);
+
+ assertThat(count(filters.getStatuses(), "COMPLETED")).isEqualTo(3);
+ assertThat(count(filters.getStatuses(), "FAILED")).isEqualTo(1);
+ assertThat(count(filters.getStatuses(), "SCHEDULED")).isEqualTo(1); // only from the schedule
+ assertThat(count(filters.getActions(), "UPDATE")).isEqualTo(4);
+ assertThat(count(filters.getActions(), "INSTALL")).isEqualTo(1); // from the schedule
+ assertThat(count(filters.getEngines(), "BREW")).isEqualTo(4);
+ assertThat(count(filters.getEngines(), "WINGET")).isEqualTo(1); // from the schedule
+ assertThat(filters.getFilteredCount()).isEqualTo(5); // 4 executed + 1 scheduled
+ }
+
+ private static int count(List options, String value) {
+ return options.stream().filter(o -> o.getValue().equals(value)).map(ScriptFilterOption::getCount)
+ .findFirst().orElse(0);
+ }
+
+ private static SoftwareActionSummary summary(String executionId, PackageManagerType pm, String name,
+ SoftwareAction action, SoftwareActionStatus status, int total, int responded) {
+ return SoftwareActionSummary.builder()
+ .executionId(executionId).packageManager(pm).packageName(name).action(action).status(status)
+ .totalMachineCount(total).respondedMachineCount(responded).build();
+ }
+}
diff --git a/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/software/SoftwareBundleServiceTest.java b/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/software/SoftwareBundleServiceTest.java
new file mode 100644
index 0000000000..b06e924c82
--- /dev/null
+++ b/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/software/SoftwareBundleServiceTest.java
@@ -0,0 +1,300 @@
+package com.openframe.api.service.rmm.software;
+
+import com.openframe.api.dto.rmm.software.CreateSoftwareBundleInput;
+import com.openframe.api.dto.rmm.software.CreateSoftwareScheduleInput;
+import com.openframe.api.dto.rmm.software.SoftwareBundleResponse;
+import com.openframe.api.dto.rmm.software.SoftwarePackageInput;
+import com.openframe.api.dto.rmm.software.SoftwareScheduleResponse;
+import com.openframe.api.dto.rmm.software.UpdateSoftwareBundleInput;
+import com.openframe.core.exception.BadRequestException;
+import com.openframe.data.document.packagesearch.PackageManagerType;
+import com.openframe.data.document.rmm.schedule.DeviceOnlineDispatchStatus;
+import com.openframe.data.document.rmm.schedule.ScheduleOfflineBehavior;
+import com.openframe.data.document.rmm.schedule.ScheduleTimeReference;
+import com.openframe.data.document.rmm.software.SoftwareAction;
+import com.openframe.data.document.rmm.software.SoftwareBundle;
+import com.openframe.data.document.rmm.software.SoftwareBundleMode;
+import com.openframe.data.document.rmm.software.SoftwareBundleOnlineDispatch;
+import com.openframe.data.document.rmm.software.SoftwareBundlePackage;
+import com.openframe.data.document.rmm.software.SoftwareBundleStatus;
+import com.openframe.data.repository.rmm.SoftwareBundleOnlineDispatchRepository;
+import com.openframe.data.repository.rmm.SoftwareBundleRepository;
+import com.openframe.data.service.TenantIdProvider;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.List;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class SoftwareBundleServiceTest {
+
+ private static final String TENANT = "t1";
+ private static final String USER = "user-1";
+ private static final String BUNDLE_ID = "b1";
+ private static final long RECONNECT_WINDOW = 604800L;
+
+ @Mock private SoftwareBundleRepository bundleRepository;
+ @Mock private SoftwareBundleOnlineDispatchRepository onlineDispatchRepository;
+ @Mock private SoftwareScheduleService softwareScheduleService;
+ @Mock private TenantIdProvider tenantIdProvider;
+
+ private SoftwareBundleService service;
+
+ @BeforeEach
+ void setUp() {
+ service = new SoftwareBundleService(bundleRepository, onlineDispatchRepository, softwareScheduleService, tenantIdProvider);
+ ReflectionTestUtils.setField(service, "pendingTtl", Duration.ofHours(1));
+ ReflectionTestUtils.setField(service, "scheduleReconnectWindowSeconds", RECONNECT_WINDOW);
+ when(tenantIdProvider.getTenantId()).thenReturn(TENANT);
+ }
+
+ @Test
+ @DisplayName("create: born PENDING with a TTL anchor, mode/devices/packages persisted")
+ void create_bornPending() {
+ when(bundleRepository.save(any())).thenAnswer(inv -> withId(inv.getArgument(0)));
+
+ SoftwareBundleResponse res = service.create(createInput(), USER);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(SoftwareBundle.class);
+ verify(bundleRepository).save(captor.capture());
+ SoftwareBundle saved = captor.getValue();
+ assertThat(saved.getStatus()).isEqualTo(SoftwareBundleStatus.PENDING);
+ assertThat(saved.getMode()).isEqualTo(SoftwareBundleMode.NOW);
+ assertThat(saved.getMachineIds()).containsExactly("m1");
+ assertThat(saved.getPackages()).extracting(SoftwareBundlePackage::getPackageName).containsExactly("slack");
+ assertThat(saved.getExpireAt()).isNotNull();
+ assertThat(res.getStatus()).isEqualTo(SoftwareBundleStatus.PENDING);
+ }
+
+ @Test
+ @DisplayName("run NOW: arms one NEW device-online sentinel per assigned device and marks the bundle COMPLETED")
+ void runNow_armsSentinelsAndCompletes() {
+ SoftwareBundle pending = pending(SoftwareBundleMode.NOW, null, List.of("m1", "m2"),
+ SoftwareBundlePackage.builder().packageManager(PackageManagerType.BREW).packageName("slack").build());
+ when(bundleRepository.findByTenantIdAndId(TENANT, BUNDLE_ID)).thenReturn(Optional.of(pending));
+ when(onlineDispatchRepository.findByTenantIdAndMachineIdAndBundleId(eq(TENANT), anyString(), eq(BUNDLE_ID)))
+ .thenReturn(Optional.empty());
+
+ SoftwareBundleResponse res = service.run(BUNDLE_ID, USER);
+
+ ArgumentCaptor sentinels = ArgumentCaptor.forClass(SoftwareBundleOnlineDispatch.class);
+ verify(onlineDispatchRepository, times(2)).save(sentinels.capture());
+ assertThat(sentinels.getAllValues()).extracting(SoftwareBundleOnlineDispatch::getMachineId)
+ .containsExactlyInAnyOrder("m1", "m2");
+ assertThat(sentinels.getAllValues()).allSatisfy(s -> {
+ assertThat(s.getBundleId()).isEqualTo(BUNDLE_ID);
+ assertThat(s.getStatus()).isEqualTo(DeviceOnlineDispatchStatus.NEW);
+ });
+ verifyNoInteractions(softwareScheduleService);
+
+ ArgumentCaptor saved = ArgumentCaptor.forClass(SoftwareBundle.class);
+ verify(bundleRepository).save(saved.capture());
+ assertThat(saved.getValue().getStatus()).isEqualTo(SoftwareBundleStatus.COMPLETED);
+ assertThat(saved.getValue().getExpireAt()).isNull();
+ assertThat(res.getStatus()).isEqualTo(SoftwareBundleStatus.COMPLETED);
+ }
+
+ @Test
+ @DisplayName("run SCHEDULED: creates a DATE_TIME SoftwareSchedule (SERVER, RETRY_ON_RECONNECT) and links it, no sentinels armed")
+ void runScheduled_createsScheduleWithRetry() {
+ Instant startAt = Instant.parse("2026-09-20T02:00:00Z");
+ SoftwareBundle pending = pending(SoftwareBundleMode.SCHEDULED, startAt, List.of("m1", "m2"),
+ SoftwareBundlePackage.builder().packageManager(PackageManagerType.BREW).packageName("slack").build());
+ when(bundleRepository.findByTenantIdAndId(TENANT, BUNDLE_ID)).thenReturn(Optional.of(pending));
+ when(softwareScheduleService.create(any(), eq(USER)))
+ .thenReturn(SoftwareScheduleResponse.builder().id("sched-1").build());
+
+ SoftwareBundleResponse res = service.run(BUNDLE_ID, USER);
+
+ ArgumentCaptor input = ArgumentCaptor.forClass(CreateSoftwareScheduleInput.class);
+ verify(softwareScheduleService).create(input.capture(), eq(USER));
+ CreateSoftwareScheduleInput created = input.getValue();
+ assertThat(created.getAction()).isEqualTo(SoftwareAction.INSTALL);
+ assertThat(created.getTimeReference()).isEqualTo(ScheduleTimeReference.SERVER);
+ assertThat(created.getOfflineBehavior()).isEqualTo(ScheduleOfflineBehavior.RETRY_ON_RECONNECT);
+ assertThat(created.getReconnectWindowSeconds()).isEqualTo(RECONNECT_WINDOW);
+ assertThat(created.getStartAt()).isEqualTo(startAt);
+ assertThat(created.getRepeat()).isNull();
+ assertThat(created.getMachineIds()).containsExactly("m1", "m2");
+ assertThat(created.getPackages()).extracting("packageName").containsExactly("slack");
+ assertThat(created.getName()).contains(BUNDLE_ID); // unique per tenant
+
+ verifyNoInteractions(onlineDispatchRepository);
+ ArgumentCaptor saved = ArgumentCaptor.forClass(SoftwareBundle.class);
+ verify(bundleRepository).save(saved.capture());
+ assertThat(saved.getValue().getStatus()).isEqualTo(SoftwareBundleStatus.COMPLETED);
+ assertThat(saved.getValue().getScheduleId()).isEqualTo("sched-1");
+ assertThat(res.getScheduleId()).isEqualTo("sched-1");
+ }
+
+ @Test
+ @DisplayName("run SCHEDULED without startAt is rejected before creating a schedule")
+ void runScheduled_noStartAt_rejected() {
+ SoftwareBundle pending = pending(SoftwareBundleMode.SCHEDULED, null, List.of("m1"),
+ SoftwareBundlePackage.builder().packageManager(PackageManagerType.BREW).packageName("slack").build());
+ when(bundleRepository.findByTenantIdAndId(TENANT, BUNDLE_ID)).thenReturn(Optional.of(pending));
+
+ assertThatThrownBy(() -> service.run(BUNDLE_ID, USER)).isInstanceOf(BadRequestException.class);
+ verifyNoInteractions(softwareScheduleService);
+ verify(bundleRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("run: an already-COMPLETED bundle cannot be re-run")
+ void run_completed_rejected() {
+ SoftwareBundle completed = pending(SoftwareBundleMode.NOW, null, List.of("m1"),
+ SoftwareBundlePackage.builder().packageManager(PackageManagerType.BREW).packageName("slack").build());
+ completed.setStatus(SoftwareBundleStatus.COMPLETED);
+ when(bundleRepository.findByTenantIdAndId(TENANT, BUNDLE_ID)).thenReturn(Optional.of(completed));
+
+ assertThatThrownBy(() -> service.run(BUNDLE_ID, USER)).isInstanceOf(BadRequestException.class);
+ verifyNoInteractions(onlineDispatchRepository, softwareScheduleService);
+ verify(bundleRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("run: a bundle with no packages is rejected before arming")
+ void run_noPackages_rejected() {
+ SoftwareBundle empty = pending(SoftwareBundleMode.NOW, null, List.of("m1"));
+ empty.setPackages(List.of());
+ when(bundleRepository.findByTenantIdAndId(TENANT, BUNDLE_ID)).thenReturn(Optional.of(empty));
+
+ assertThatThrownBy(() -> service.run(BUNDLE_ID, USER)).isInstanceOf(BadRequestException.class);
+ verifyNoInteractions(onlineDispatchRepository, softwareScheduleService);
+ verify(bundleRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("run: a bundle with no devices is rejected before arming")
+ void run_noDevices_rejected() {
+ SoftwareBundle empty = pending(SoftwareBundleMode.NOW, null, List.of(),
+ SoftwareBundlePackage.builder().packageManager(PackageManagerType.BREW).packageName("slack").build());
+ when(bundleRepository.findByTenantIdAndId(TENANT, BUNDLE_ID)).thenReturn(Optional.of(empty));
+
+ assertThatThrownBy(() -> service.run(BUNDLE_ID, USER)).isInstanceOf(BadRequestException.class);
+ verifyNoInteractions(onlineDispatchRepository, softwareScheduleService);
+ verify(bundleRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("update: a PENDING bundle's mode/devices/packages/startAt are replaced and its TTL anchor refreshed")
+ void update_pending_replacesAndRefreshes() {
+ SoftwareBundle pending = pending(SoftwareBundleMode.NOW, null, List.of("m1"),
+ SoftwareBundlePackage.builder().packageManager(PackageManagerType.BREW).packageName("slack").build());
+ when(bundleRepository.findByTenantIdAndId(TENANT, BUNDLE_ID)).thenReturn(Optional.of(pending));
+ when(bundleRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ Instant startAt = Instant.parse("2026-09-20T02:00:00Z");
+ UpdateSoftwareBundleInput input = new UpdateSoftwareBundleInput();
+ input.setId(BUNDLE_ID);
+ input.setMode(SoftwareBundleMode.SCHEDULED);
+ input.setMachineIds(List.of("m1", "m2"));
+ input.setStartAt(startAt);
+ SoftwarePackageInput pkg = new SoftwarePackageInput();
+ pkg.setPackageManager(PackageManagerType.BREW);
+ pkg.setPackageName("chrome");
+ input.setPackages(List.of(pkg));
+
+ service.update(input, USER);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(SoftwareBundle.class);
+ verify(bundleRepository).save(captor.capture());
+ SoftwareBundle saved = captor.getValue();
+ assertThat(saved.getStatus()).isEqualTo(SoftwareBundleStatus.PENDING);
+ assertThat(saved.getMode()).isEqualTo(SoftwareBundleMode.SCHEDULED);
+ assertThat(saved.getMachineIds()).containsExactly("m1", "m2");
+ assertThat(saved.getStartAt()).isEqualTo(startAt);
+ assertThat(saved.getPackages()).extracting(SoftwareBundlePackage::getPackageName).containsExactly("chrome");
+ assertThat(saved.getExpireAt()).isNotNull();
+ }
+
+ @Test
+ @DisplayName("update: a COMPLETED bundle cannot be edited")
+ void update_completed_rejected() {
+ SoftwareBundle completed = pending(SoftwareBundleMode.NOW, null, List.of("m1"));
+ completed.setStatus(SoftwareBundleStatus.COMPLETED);
+ when(bundleRepository.findByTenantIdAndId(TENANT, BUNDLE_ID)).thenReturn(Optional.of(completed));
+
+ UpdateSoftwareBundleInput input = new UpdateSoftwareBundleInput();
+ input.setId(BUNDLE_ID);
+ input.setMode(SoftwareBundleMode.NOW);
+ input.setMachineIds(List.of("m1"));
+
+ assertThatThrownBy(() -> service.update(input, USER)).isInstanceOf(BadRequestException.class);
+ verify(bundleRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("delete: a PENDING bundle is removed")
+ void delete_pending_ok() {
+ SoftwareBundle pending = pending(SoftwareBundleMode.NOW, null, List.of("m1"));
+ when(bundleRepository.findByTenantIdAndId(TENANT, BUNDLE_ID)).thenReturn(Optional.of(pending));
+
+ assertThat(service.delete(BUNDLE_ID, USER)).isTrue();
+ verify(bundleRepository).delete(pending);
+ }
+
+ @Test
+ @DisplayName("delete: a COMPLETED bundle is protected (history is immutable)")
+ void delete_completed_rejected() {
+ SoftwareBundle completed = pending(SoftwareBundleMode.NOW, null, List.of("m1"));
+ completed.setStatus(SoftwareBundleStatus.COMPLETED);
+ when(bundleRepository.findByTenantIdAndId(TENANT, BUNDLE_ID)).thenReturn(Optional.of(completed));
+
+ assertThatThrownBy(() -> service.delete(BUNDLE_ID, USER)).isInstanceOf(BadRequestException.class);
+ verify(bundleRepository, never()).delete(any());
+ }
+
+ private static CreateSoftwareBundleInput createInput() {
+ CreateSoftwareBundleInput input = new CreateSoftwareBundleInput();
+ input.setAction(SoftwareAction.INSTALL);
+ input.setMode(SoftwareBundleMode.NOW);
+ input.setMachineIds(List.of("m1"));
+ SoftwarePackageInput pkg = new SoftwarePackageInput();
+ pkg.setPackageManager(PackageManagerType.BREW);
+ pkg.setPackageName("slack");
+ input.setPackages(List.of(pkg));
+ return input;
+ }
+
+ private static SoftwareBundle pending(SoftwareBundleMode mode, Instant startAt, List machineIds,
+ SoftwareBundlePackage... packages) {
+ return SoftwareBundle.builder()
+ .id(BUNDLE_ID)
+ .tenantId(TENANT)
+ .action(SoftwareAction.INSTALL)
+ .mode(mode)
+ .status(SoftwareBundleStatus.PENDING)
+ .machineIds(machineIds)
+ .packages(List.of(packages))
+ .startAt(startAt)
+ .build();
+ }
+
+ private static SoftwareBundle withId(SoftwareBundle b) {
+ if (b.getId() == null) {
+ b.setId(BUNDLE_ID);
+ }
+ return b;
+ }
+}
diff --git a/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/software/SoftwareInventoryServiceTest.java b/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/software/SoftwareInventoryServiceTest.java
new file mode 100644
index 0000000000..361b0e2959
--- /dev/null
+++ b/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/software/SoftwareInventoryServiceTest.java
@@ -0,0 +1,104 @@
+package com.openframe.api.service.rmm.software;
+
+import com.openframe.api.dto.rmm.software.SoftwareFilters;
+import com.openframe.api.dto.rmm.software.SoftwareOnDeviceResponse;
+import com.openframe.api.dto.shared.PageResult;
+import com.openframe.api.service.rmm.fleet.FleetClientProvider;
+import com.openframe.api.service.rmm.fleet.FleetHostMachineResolver;
+import com.openframe.data.document.device.Machine;
+import com.openframe.data.service.TenantIdProvider;
+import com.openframe.sdk.fleetmdm.model.Host;
+import com.openframe.sdk.fleetmdm.model.SoftwareTitle;
+import com.openframe.sdk.fleetmdm.model.SoftwareTitleVersion;
+import com.openframe.sdk.fleetmdm.model.SoftwareTitlesResponse;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class SoftwareInventoryServiceTest {
+
+ @Mock private FleetClientProvider fleet;
+ @Mock private FleetHostMachineResolver hostMachineResolver;
+ @Mock private TenantIdProvider tenantIdProvider;
+
+ private SoftwareInventoryService service;
+
+ @BeforeEach
+ void setUp() {
+ service = new SoftwareInventoryService(fleet, hostMachineResolver, tenantIdProvider);
+ }
+
+ @Test
+ @DisplayName("listDevicesForSoftware: a non-numeric id short-circuits to empty without touching Fleet")
+ void listDevices_nonNumericId_emptyNoFleet() {
+ assertThat(service.listDevicesForSoftware("not-a-number", null, 0, 50).items()).isEmpty();
+ verifyNoInteractions(fleet, hostMachineResolver);
+ }
+
+ @Test
+ @DisplayName("listDevicesForSoftware: fans out per version, tags each host with its version, correlates to a Machine, and drops hosts not enrolled in OpenFrame")
+ void listDevices_correlatesAndDropsUnenrolled() {
+ SoftwareTitle title = new SoftwareTitle();
+ title.setName("Google Chrome");
+ SoftwareTitleVersion version = new SoftwareTitleVersion();
+ version.setId(10L);
+ version.setVersion("1.2.3");
+ title.setVersions(List.of(version));
+
+ Host enrolled = host(1L, "u1", "host-1");
+ Host foreign = host(2L, "u2", "host-2"); // no matching Machine → dropped
+
+ // call 1 = getSoftwareTitle, call 2 = searchHosts(software_version_id=10)
+ when(fleet.call(any(), any())).thenReturn(title, List.of(enrolled, foreign));
+ when(tenantIdProvider.getTenantId()).thenReturn("t1");
+ Machine machine = new Machine();
+ machine.setMachineId("m-1");
+ machine.setHostname("host-1");
+ when(hostMachineResolver.resolve(eq("t1"), anyList())).thenReturn(Map.of(1L, machine));
+
+ PageResult result = service.listDevicesForSoftware("42", null, 0, 50);
+
+ assertThat(result.items()).hasSize(1);
+ SoftwareOnDeviceResponse row = result.items().get(0);
+ assertThat(row.getDevice().getMachineId()).isEqualTo("m-1");
+ assertThat(row.getSoftwareVersion()).isEqualTo("1.2.3");
+ assertThat(row.getStatus()).isNotNull();
+ }
+
+ @Test
+ @DisplayName("getSoftwareFilters: no titles → valid empty facet lists (never null)")
+ void getSoftwareFilters_noTitles_emptyFacets() {
+ SoftwareTitlesResponse response = mock(SoftwareTitlesResponse.class);
+ when(response.getSoftwareTitles()).thenReturn(List.of());
+ when(fleet.call(any(), any())).thenReturn(response);
+
+ SoftwareFilters filters = service.getSoftwareFilters(null);
+
+ assertThat(filters.getSources()).isEmpty();
+ assertThat(filters.getVersionStatuses()).isEmpty();
+ assertThat(filters.getSeverities()).isEmpty();
+ }
+
+ private static Host host(long id, String uuid, String hostname) {
+ Host h = new Host();
+ h.setId(id);
+ h.setUuid(uuid);
+ h.setHostname(hostname);
+ return h;
+ }
+}
diff --git a/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/vulnerability/FleetGlobalVulnerabilityMapperTest.java b/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/vulnerability/FleetGlobalVulnerabilityMapperTest.java
new file mode 100644
index 0000000000..7a52288961
--- /dev/null
+++ b/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/vulnerability/FleetGlobalVulnerabilityMapperTest.java
@@ -0,0 +1,185 @@
+package com.openframe.api.service.rmm.vulnerability;
+
+import com.openframe.api.dto.rmm.software.SoftwareCveSeverity;
+import com.openframe.api.dto.rmm.software.SoftwareSource;
+import com.openframe.api.dto.rmm.vulnerability.AffectedSoftwareResponse;
+import com.openframe.api.dto.rmm.vulnerability.VulnerabilityResponse;
+import com.openframe.sdk.fleetmdm.model.AffectedSoftware;
+import com.openframe.sdk.fleetmdm.model.Vulnerability;
+import org.junit.jupiter.api.Test;
+
+import java.time.Instant;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class FleetGlobalVulnerabilityMapperTest {
+
+ @Test
+ void toListRow_nullInput_returnsNull() {
+ assertNull(FleetGlobalVulnerabilityMapper.toListRow(null));
+ }
+
+ @Test
+ void toListRow_missingCve_returnsNullSoTheListSkipsRowsWithoutId() {
+ Vulnerability v = new Vulnerability();
+ v.setCvssScore(7.0);
+
+ assertNull(FleetGlobalVulnerabilityMapper.toListRow(v));
+ }
+
+ @Test
+ void toDetail_nullInput_returnsNull() {
+ assertNull(FleetGlobalVulnerabilityMapper.toDetail(null));
+ }
+
+
+ @Test
+ void toListRow_populatesBaseFields_leavesDetailOnlyFieldsNull() {
+ // setup
+ Vulnerability v = new Vulnerability();
+ v.setCve("CVE-2024-38063");
+ v.setCvssScore(9.8);
+ v.setEpssProbability(0.42);
+ v.setCisaKnownExploit(Boolean.TRUE);
+ v.setCvePublished("2024-08-13T00:00:00Z");
+ v.setDetailsLink("https://nvd.nist.gov/vuln/detail/CVE-2024-38063");
+ v.setHostsCount(17);
+ v.setCveDescription("Windows TCP/IP RCE");
+ v.setResolvedInVersion("2023.001.20143");
+ AffectedSoftware sw = new AffectedSoftware();
+ sw.setId(42L);
+ sw.setName("WinRAR");
+ v.setSoftware(List.of(sw));
+
+ VulnerabilityResponse row = FleetGlobalVulnerabilityMapper.toListRow(v);
+
+ assertNotNull(row);
+ assertEquals("CVE-2024-38063", row.getCveId());
+ assertEquals(SoftwareCveSeverity.CRITICAL, row.getSeverity());
+ assertEquals(9.8, row.getCvssScore());
+ assertEquals(0.42, row.getEpssProbability());
+ assertEquals(Boolean.TRUE, row.getCisaKnownExploit());
+ assertEquals(Instant.parse("2024-08-13T00:00:00Z"), row.getPublishedAt());
+ assertEquals("https://nvd.nist.gov/vuln/detail/CVE-2024-38063", row.getDetailsLink());
+ assertEquals(17, row.getDevicesCount());
+ assertNull(row.getDescription());
+ assertNull(row.getResolvedInVersion());
+ assertNull(row.getAffectedSoftware());
+ }
+
+ @Test
+ void toListRow_nullCvssScore_severityIsNull() {
+ Vulnerability v = new Vulnerability();
+ v.setCve("CVE-2024-00000");
+
+ VulnerabilityResponse row = FleetGlobalVulnerabilityMapper.toListRow(v);
+
+ assertNotNull(row);
+ assertNull(row.getSeverity());
+ assertNull(row.getCvssScore());
+ }
+
+
+ @Test
+ void toListRow_cvssBucketing_matchesNvdRanges() {
+ // 9.0+ → CRITICAL, 7.0..8.9 → HIGH, 4.0..6.9 → MEDIUM, >0..3.9 → LOW, 0 → NONE
+ assertEquals(SoftwareCveSeverity.CRITICAL, severityFor(9.9));
+ assertEquals(SoftwareCveSeverity.CRITICAL, severityFor(9.0));
+ assertEquals(SoftwareCveSeverity.HIGH, severityFor(8.9));
+ assertEquals(SoftwareCveSeverity.HIGH, severityFor(7.0));
+ assertEquals(SoftwareCveSeverity.MEDIUM, severityFor(6.9));
+ assertEquals(SoftwareCveSeverity.MEDIUM, severityFor(4.0));
+ assertEquals(SoftwareCveSeverity.LOW, severityFor(3.9));
+ assertEquals(SoftwareCveSeverity.LOW, severityFor(0.1));
+ assertEquals(SoftwareCveSeverity.NONE, severityFor(0.0));
+ }
+
+ private static SoftwareCveSeverity severityFor(double cvss) {
+ Vulnerability v = new Vulnerability();
+ v.setCve("CVE-x");
+ v.setCvssScore(cvss);
+ return FleetGlobalVulnerabilityMapper.toListRow(v).getSeverity();
+ }
+
+ @Test
+ void toListRow_unparseablePublishedAt_leavesInstantNullInsteadOfCrashing() {
+ // setup — Fleet occasionally returns partial or malformed dates
+ Vulnerability v = new Vulnerability();
+ v.setCve("CVE-2024-00000");
+ v.setCvePublished("not-a-date");
+
+ // execution
+ VulnerabilityResponse row = FleetGlobalVulnerabilityMapper.toListRow(v);
+
+ // verifications — parseInstant swallows the DateTimeParseException, leaves publishedAt null
+ assertNull(row.getPublishedAt());
+ }
+
+
+ @Test
+ void toDetail_populatesDetailFieldsAndAffectedSoftware() {
+ // setup
+ Vulnerability v = new Vulnerability();
+ v.setCve("CVE-2024-38063");
+ v.setCvssScore(9.2);
+ v.setCveDescription("Windows TCP/IP RCE");
+ v.setResolvedInVersion("N/A");
+ AffectedSoftware sw = new AffectedSoftware();
+ sw.setId(42L);
+ sw.setName("WinRAR");
+ sw.setSource("homebrew_packages");
+ sw.setVersion("6.22");
+ sw.setHostsCount(2);
+ sw.setResolvedInVersion("6.23");
+ v.setSoftware(List.of(sw));
+
+ // execution
+ VulnerabilityResponse detail = FleetGlobalVulnerabilityMapper.toDetail(v);
+
+ // verifications
+ assertNotNull(detail);
+ assertEquals("Windows TCP/IP RCE", detail.getDescription());
+ assertEquals("N/A", detail.getResolvedInVersion());
+ assertNotNull(detail.getAffectedSoftware());
+ assertEquals(1, detail.getAffectedSoftware().size());
+ AffectedSoftwareResponse aff = detail.getAffectedSoftware().get(0);
+ assertEquals("42", aff.getId());
+ assertEquals("WinRAR", aff.getName());
+ assertEquals(SoftwareSource.BREW, aff.getSource());
+ assertEquals("6.22", aff.getVersion());
+ assertEquals(2, aff.getDevicesCount());
+ assertEquals("6.23", aff.getResolvedInVersion());
+ }
+
+ @Test
+ void toDetail_nullSoftwareList_producesEmptyListNotNull_soFeCanTellAskedFromNotAsked() {
+ Vulnerability v = new Vulnerability();
+ v.setCve("CVE-2024-38063");
+
+ VulnerabilityResponse detail = FleetGlobalVulnerabilityMapper.toDetail(v);
+
+ assertNotNull(detail.getAffectedSoftware());
+ assertTrue(detail.getAffectedSoftware().isEmpty());
+ }
+
+ @Test
+ void toDetail_unknownSourceString_fallsBackToUnmanaged() {
+ // setup — Fleet emits sources like "programs", "apps" etc that our category doesn't map
+ Vulnerability v = new Vulnerability();
+ v.setCve("CVE-2024-38063");
+ AffectedSoftware sw = new AffectedSoftware();
+ sw.setId(1L);
+ sw.setSource("programs");
+ v.setSoftware(List.of(sw));
+
+ // execution
+ VulnerabilityResponse detail = FleetGlobalVulnerabilityMapper.toDetail(v);
+
+ // verifications
+ assertEquals(SoftwareSource.UNMANAGED, detail.getAffectedSoftware().get(0).getSource());
+ }
+}
diff --git a/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/vulnerability/VulnerabilityInventoryServiceTest.java b/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/vulnerability/VulnerabilityInventoryServiceTest.java
new file mode 100644
index 0000000000..40df69fef7
--- /dev/null
+++ b/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/vulnerability/VulnerabilityInventoryServiceTest.java
@@ -0,0 +1,228 @@
+package com.openframe.api.service.rmm.vulnerability;
+
+import com.openframe.api.dto.rmm.software.SoftwareCveSeverity;
+import com.openframe.api.dto.rmm.vulnerability.VulnerabilityResponse;
+import com.openframe.api.dto.shared.PageResult;
+import com.openframe.api.service.rmm.fleet.FleetClientProvider;
+import com.openframe.sdk.fleetmdm.FleetMdmClient;
+import com.openframe.sdk.fleetmdm.model.VulnerabilitiesResponse;
+import com.openframe.sdk.fleetmdm.model.Vulnerability;
+import com.openframe.sdk.fleetmdm.model.VulnerabilityRequest;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.List;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class VulnerabilityInventoryServiceTest {
+
+ @Mock private FleetClientProvider fleet;
+ @Mock private FleetMdmClient fleetClient;
+ @Mock private com.openframe.api.service.rmm.fleet.FleetHostMachineResolver hostMachineResolver;
+ @Mock private com.openframe.data.service.TenantIdProvider tenantIdProvider;
+
+ @Captor private ArgumentCaptor> callCaptor;
+
+ private VulnerabilityInventoryService service;
+
+ @BeforeEach
+ void setUp() {
+ service = new VulnerabilityInventoryService(fleet, hostMachineResolver, tenantIdProvider);
+ }
+
+ @Test
+ void listDevicesForCve_blankCve_returnsEmptyWithoutHittingFleet() {
+ assertTrue(service.listDevicesForCve("", null, 0, 50).items().isEmpty());
+ assertTrue(service.listDevicesForCve(null, null, 0, 50).items().isEmpty());
+ }
+
+ @Test
+ void listDevicesForCve_correlatesHostsToMachines_dropsUnenrolled() {
+ com.openframe.sdk.fleetmdm.model.Host h1 = new com.openframe.sdk.fleetmdm.model.Host();
+ h1.setId(1L);
+ h1.setHostname("host-1");
+ com.openframe.sdk.fleetmdm.model.Host h2 = new com.openframe.sdk.fleetmdm.model.Host();
+ h2.setId(2L);
+ h2.setHostname("host-2");
+ when(fleet.call(any(), any())).thenReturn(List.of(h1, h2));
+ when(tenantIdProvider.getTenantId()).thenReturn("t1");
+ com.openframe.data.document.device.Machine m1 = new com.openframe.data.document.device.Machine();
+ m1.setMachineId("m-1");
+ m1.setHostname("host-1");
+ when(hostMachineResolver.resolve(eq("t1"), any())).thenReturn(java.util.Map.of(1L, m1));
+
+ PageResult result =
+ service.listDevicesForCve("CVE-2024-38063", null, 0, 50);
+
+ assertEquals(1, result.items().size()); // h2 dropped (no matching Machine)
+ assertEquals("m-1", result.items().get(0).getMachineId());
+ }
+
+ @Test
+ void findByCveId_blankId_returnsEmptyWithoutHittingFleet() {
+ // execution + verifications
+ assertTrue(service.findByCveId("").isEmpty());
+ assertTrue(service.findByCveId(null).isEmpty());
+ // no interaction with fleet — the guard fires before we dispatch
+ }
+
+ @Test
+ void findByCveId_fleetReturnsNull_returnsEmpty() {
+ // setup — 404 from Fleet detail: the SDK returns null and we propagate as empty
+ when(fleet.call(any(), any())).thenReturn(null);
+
+ // execution
+ Optional found = service.findByCveId("CVE-2024-00000");
+
+ // verifications
+ assertTrue(found.isEmpty());
+ }
+
+ @Test
+ void findByCveId_fleetReturnsDetail_mapsToDetailFlow() throws Exception {
+ // setup — verify we hit the detail (getVulnerability) not the list endpoint
+ Vulnerability detail = new Vulnerability();
+ detail.setCve("CVE-2024-38063");
+ detail.setCveDescription("Windows TCP/IP RCE");
+ detail.setCvssScore(9.2);
+ when(fleet.call(any(), any())).thenAnswer(inv -> {
+ FleetClientProvider.FleetSdkCall> call = inv.getArgument(0);
+ when(fleetClient.getVulnerability("CVE-2024-38063")).thenReturn(detail);
+ return call.execute(fleetClient);
+ });
+
+ // execution
+ Optional found = service.findByCveId("CVE-2024-38063");
+
+ // verifications
+ assertTrue(found.isPresent());
+ assertEquals("CVE-2024-38063", found.get().getCveId());
+ assertEquals("Windows TCP/IP RCE", found.get().getDescription());
+ assertEquals(SoftwareCveSeverity.CRITICAL, found.get().getSeverity());
+ // detail flow always populates affectedSoftware (possibly empty), never null
+ assertNotNull(found.get().getAffectedSoftware());
+ }
+
+ @Test
+ void listVulnerabilities_buildsRequestFromFilters_wiresPagingFromMeta() throws Exception {
+ // setup
+ VulnerabilitiesResponse response = new VulnerabilitiesResponse();
+ response.setVulnerabilities(List.of(cve("CVE-1", 9.2), cve("CVE-2", 5.0)));
+ response.setCount(37L);
+ VulnerabilitiesResponse.Meta meta = new VulnerabilitiesResponse.Meta();
+ meta.setHasNextResults(true);
+ meta.setHasPreviousResults(false);
+ response.setMeta(meta);
+ ArgumentCaptor reqCaptor = ArgumentCaptor.forClass(VulnerabilityRequest.class);
+ when(fleetClient.listVulnerabilities(reqCaptor.capture())).thenReturn(response);
+ stubCallThatUnwrapsSdkCallOn(fleetClient);
+
+ // execution
+ PageResult result = service.listVulnerabilities(
+ "chrome", 0, 20, "cvss_score", "desc", Boolean.TRUE, null);
+
+ // verifications
+ VulnerabilityRequest req = reqCaptor.getValue();
+ assertEquals("chrome", req.getQuery());
+ assertEquals(0, req.getPage());
+ assertEquals(20, req.getPerPage());
+ assertEquals("cvss_score", req.getOrderKey());
+ assertEquals("desc", req.getOrderDirection());
+ assertEquals(Boolean.TRUE, req.getExploit());
+
+ assertEquals(2, result.items().size());
+ assertTrue(result.hasNext());
+ assertFalse(result.hasPrevious());
+ assertEquals(37, result.filteredCount());
+ assertEquals(0, result.page());
+ }
+
+ @Test
+ void listVulnerabilities_minSeverityHigh_filtersRowsBelowFloor() throws Exception {
+ // setup — 4 CVEs, 3 below HIGH
+ VulnerabilitiesResponse response = new VulnerabilitiesResponse();
+ response.setVulnerabilities(List.of(
+ cve("CVE-CRIT", 9.5),
+ cve("CVE-HIGH", 7.2),
+ cve("CVE-MED", 4.5),
+ cve("CVE-LOW", 1.2)));
+ response.setCount(4L);
+ when(fleetClient.listVulnerabilities(any())).thenReturn(response);
+ stubCallThatUnwrapsSdkCallOn(fleetClient);
+
+ // execution
+ PageResult result = service.listVulnerabilities(
+ null, 0, 20, null, null, null, SoftwareCveSeverity.HIGH);
+
+ // verifications
+ assertEquals(2, result.items().size());
+ assertEquals("CVE-CRIT", result.items().get(0).getCveId());
+ assertEquals("CVE-HIGH", result.items().get(1).getCveId());
+ }
+
+ @Test
+ void listVulnerabilities_minSeverityNone_isTreatedAsNoFilter() throws Exception {
+ // setup
+ VulnerabilitiesResponse response = new VulnerabilitiesResponse();
+ response.setVulnerabilities(List.of(cve("CVE-LOW", 1.2)));
+ response.setCount(1L);
+ when(fleetClient.listVulnerabilities(any())).thenReturn(response);
+ stubCallThatUnwrapsSdkCallOn(fleetClient);
+
+ // execution
+ PageResult result = service.listVulnerabilities(
+ null, 0, 20, null, null, null, SoftwareCveSeverity.NONE);
+
+ // verifications — NONE floor keeps the row (defensive against strict-mode UI)
+ assertEquals(1, result.items().size());
+ }
+
+ @Test
+ void listVulnerabilities_nullVulnerabilitiesArray_emitsEmptyPageNotNpe() throws Exception {
+ // setup — Fleet returns {"vulnerabilities": null, "count": 0} on some pages
+ VulnerabilitiesResponse response = new VulnerabilitiesResponse();
+ response.setVulnerabilities(null);
+ response.setCount(0L);
+ when(fleetClient.listVulnerabilities(any())).thenReturn(response);
+ stubCallThatUnwrapsSdkCallOn(fleetClient);
+
+ // execution
+ PageResult result = service.listVulnerabilities(
+ null, 5, 20, null, null, null, null);
+
+ // verifications
+ assertTrue(result.items().isEmpty());
+ assertFalse(result.hasNext());
+ assertEquals(5, result.page()); // page-index preserved on empty responses
+ assertEquals(0, result.filteredCount());
+ }
+
+ private static Vulnerability cve(String id, Double cvss) {
+ Vulnerability v = new Vulnerability();
+ v.setCve(id);
+ v.setCvssScore(cvss);
+ return v;
+ }
+
+ private void stubCallThatUnwrapsSdkCallOn(FleetMdmClient client) {
+ when(fleet.call(any(), any())).thenAnswer(inv -> {
+ FleetClientProvider.FleetSdkCall> call = inv.getArgument(0);
+ return call.execute(client);
+ });
+ }
+}
diff --git a/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/PageCursors.java b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/PageCursors.java
new file mode 100644
index 0000000000..bb43b6fd47
--- /dev/null
+++ b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/PageCursors.java
@@ -0,0 +1,52 @@
+package com.openframe.api.datafetcher.rmm;
+
+import com.openframe.api.dto.CountedGenericConnection;
+import com.openframe.api.dto.GenericEdge;
+import com.openframe.api.dto.shared.PageInfo;
+import com.openframe.api.dto.shared.PageResult;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.List;
+
+final class PageCursors {
+
+ private PageCursors() {
+ }
+
+ static int decodePage(String cursor) {
+ if (cursor == null || cursor.isBlank()) {
+ return 0;
+ }
+ try {
+ return Math.max(0, Integer.parseInt(
+ new String(Base64.getUrlDecoder().decode(cursor), StandardCharsets.UTF_8)));
+ } catch (IllegalArgumentException e) {
+ return 0;
+ }
+ }
+
+ static String encodePage(int page) {
+ return Base64.getUrlEncoder().withoutPadding()
+ .encodeToString(Integer.toString(page).getBytes(StandardCharsets.UTF_8));
+ }
+
+ static CountedGenericConnection> toConnection(PageResult page) {
+ String currentCursor = encodePage(page.page());
+ String nextCursor = page.hasNext() ? encodePage(page.page() + 1) : null;
+ List> edges = page.items().stream()
+ .map(node -> GenericEdge.builder().node(node).cursor(currentCursor).build())
+ .toList();
+ PageInfo pageInfo = PageInfo.builder()
+ .hasNextPage(page.hasNext())
+ .hasPreviousPage(page.hasPrevious())
+ .startCursor(edges.isEmpty() ? null : currentCursor)
+ .endCursor(nextCursor != null ? nextCursor : (edges.isEmpty() ? null : currentCursor))
+ .build();
+ return CountedGenericConnection.>builder()
+ .edges(edges)
+ .pageInfo(pageInfo)
+ .filteredCount(page.filteredCount())
+ .build();
+ }
+}
diff --git a/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareActionDataFetcher.java b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareActionDataFetcher.java
new file mode 100644
index 0000000000..c855201a36
--- /dev/null
+++ b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareActionDataFetcher.java
@@ -0,0 +1,63 @@
+package com.openframe.api.datafetcher.rmm;
+
+import com.netflix.graphql.dgs.DgsComponent;
+import com.netflix.graphql.dgs.DgsQuery;
+import com.netflix.graphql.dgs.InputArgument;
+import com.openframe.api.dto.CountedGenericConnection;
+import com.openframe.api.dto.GenericEdge;
+import com.openframe.api.dto.rmm.software.SoftwareActionDeviceFilterInput;
+import com.openframe.api.dto.rmm.software.SoftwareActionDeviceResponse;
+import com.openframe.api.dto.rmm.software.SoftwareActionFilterInput;
+import com.openframe.api.dto.rmm.software.SoftwareActionFilters;
+import com.openframe.api.dto.rmm.software.SoftwareActionId;
+import com.openframe.api.dto.rmm.software.SoftwareActionResponse;
+import com.openframe.api.dto.shared.SortInput;
+import com.openframe.api.service.rmm.software.SoftwareActionDetailService;
+import com.openframe.api.service.rmm.software.SoftwareActionService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+
+import java.util.List;
+
+@DgsComponent
+@ConditionalOnProperty(name = "openframe.rmm.software.enabled", havingValue = "true")
+@RequiredArgsConstructor
+@Slf4j
+public class SoftwareActionDataFetcher {
+
+ private final SoftwareActionService softwareActionService;
+ private final SoftwareActionDetailService softwareActionDetailService;
+
+ @DgsQuery
+ public SoftwareActionResponse softwareAction(@InputArgument String id) {
+ return softwareActionService.findById(id).orElse(null);
+ }
+
+ @DgsQuery
+ public CountedGenericConnection> softwareActions(
+ @InputArgument SoftwareActionFilterInput filter,
+ @InputArgument Integer first, @InputArgument String after,
+ @InputArgument Integer last, @InputArgument String before,
+ @InputArgument String search, @InputArgument SortInput sort) {
+ int page = PageCursors.decodePage(after != null ? after : before);
+ Integer perPage = first != null ? first : last;
+ return PageCursors.toConnection(softwareActionService.list(filter, search, sort, page, perPage));
+ }
+
+ @DgsQuery
+ public SoftwareActionFilters softwareActionFilters(
+ @InputArgument SoftwareActionFilterInput filter,
+ @InputArgument String search) {
+ return softwareActionService.filters(filter, search);
+ }
+
+ @DgsQuery
+ public List softwareActionExecutions(
+ @InputArgument String actionId,
+ @InputArgument SoftwareActionDeviceFilterInput filter,
+ @InputArgument String search) {
+ SoftwareActionId id = SoftwareActionId.decode(actionId);
+ return softwareActionDetailService.devices(id.executionId(), id.bundleId(), id.scheduleId(), filter, search);
+ }
+}
diff --git a/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareBundleDataFetcher.java b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareBundleDataFetcher.java
new file mode 100644
index 0000000000..b21c8bd076
--- /dev/null
+++ b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareBundleDataFetcher.java
@@ -0,0 +1,67 @@
+package com.openframe.api.datafetcher.rmm;
+
+import com.netflix.graphql.dgs.DgsComponent;
+import com.netflix.graphql.dgs.DgsMutation;
+import com.netflix.graphql.dgs.DgsQuery;
+import com.netflix.graphql.dgs.InputArgument;
+import com.openframe.api.dto.rmm.software.CreateSoftwareBundleInput;
+import com.openframe.api.dto.rmm.software.SoftwareBundleResponse;
+import com.openframe.api.dto.rmm.software.UpdateSoftwareBundleInput;
+import com.openframe.api.service.rmm.software.SoftwareBundleService;
+import com.openframe.data.document.rmm.software.SoftwareBundleStatus;
+import com.openframe.security.authentication.AuthPrincipal;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.validation.annotation.Validated;
+
+import java.util.List;
+
+@DgsComponent
+@ConditionalOnProperty(name = "openframe.rmm.software.enabled", havingValue = "true")
+@RequiredArgsConstructor
+@Slf4j
+@Validated
+public class SoftwareBundleDataFetcher {
+
+ private final SoftwareBundleService softwareBundleService;
+
+ @DgsQuery
+ public SoftwareBundleResponse softwareBundle(@InputArgument String id) {
+ return softwareBundleService.findById(id).orElse(null);
+ }
+
+ @DgsQuery
+ public List softwareBundles(@InputArgument SoftwareBundleStatus status) {
+ return softwareBundleService.list(status);
+ }
+
+ @DgsMutation
+ public SoftwareBundleResponse createSoftwareBundle(@InputArgument @Valid CreateSoftwareBundleInput input) {
+ return softwareBundleService.create(input, getCurrentUserId());
+ }
+
+ @DgsMutation
+ public SoftwareBundleResponse updateSoftwareBundle(@InputArgument @Valid UpdateSoftwareBundleInput input) {
+ return softwareBundleService.update(input, getCurrentUserId());
+ }
+
+ @DgsMutation
+ public boolean deleteSoftwareBundle(@InputArgument String id) {
+ return softwareBundleService.delete(id, getCurrentUserId());
+ }
+
+ @DgsMutation
+ public SoftwareBundleResponse runSoftwareBundle(@InputArgument String id) {
+ return softwareBundleService.run(id, getCurrentUserId());
+ }
+
+ private String getCurrentUserId() {
+ Authentication auth = SecurityContextHolder.getContext().getAuthentication();
+ return AuthPrincipal.fromJwt((Jwt) auth.getPrincipal()).getId();
+ }
+}
diff --git a/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareDataFetcher.java b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareDataFetcher.java
new file mode 100644
index 0000000000..121ce4afd4
--- /dev/null
+++ b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareDataFetcher.java
@@ -0,0 +1,90 @@
+package com.openframe.api.datafetcher.rmm;
+
+import com.netflix.graphql.dgs.DgsComponent;
+import com.netflix.graphql.dgs.DgsQuery;
+import com.netflix.graphql.dgs.InputArgument;
+import com.openframe.api.dto.CountedGenericConnection;
+import com.openframe.api.dto.GenericEdge;
+import com.openframe.api.dto.rmm.software.SoftwareCveSeverity;
+import com.openframe.api.dto.rmm.software.SoftwareFilterInput;
+import com.openframe.api.dto.rmm.software.SoftwareFilters;
+import com.openframe.api.dto.rmm.software.SoftwareOnDeviceResponse;
+import com.openframe.api.dto.rmm.software.SoftwareResponse;
+import com.openframe.api.dto.rmm.software.SoftwareVulnerabilityResponse;
+import com.openframe.api.dto.shared.SortDirection;
+import com.openframe.api.dto.shared.SortInput;
+import com.openframe.api.service.rmm.software.SoftwareInventoryService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+
+import java.util.Locale;
+import java.util.Map;
+
+@DgsComponent
+@ConditionalOnProperty(name = "openframe.rmm.software.enabled", havingValue = "true")
+@RequiredArgsConstructor
+@Slf4j
+public class SoftwareDataFetcher {
+
+ private static final Map FLEET_ORDER_KEY = Map.of(
+ "name", "name",
+ "devicesCount", "hosts_count",
+ "hosts_count", "hosts_count");
+
+ private final SoftwareInventoryService softwareInventoryService;
+
+ @DgsQuery
+ public SoftwareResponse software(@InputArgument String id) {
+ return softwareInventoryService.findById(id).orElse(null);
+ }
+
+ @DgsQuery
+ public CountedGenericConnection> softwares(
+ @InputArgument SoftwareFilterInput filter,
+ @InputArgument Integer first, @InputArgument String after,
+ @InputArgument Integer last, @InputArgument String before,
+ @InputArgument String search, @InputArgument SortInput sort) {
+ int page = PageCursors.decodePage(after != null ? after : before);
+ Integer perPage = first != null ? first : last;
+ String orderKey = sort == null ? null : FLEET_ORDER_KEY.get(sort.getField());
+ String orderDirection = sort == null || sort.getDirection() == null
+ ? null : sort.getDirection().name().toLowerCase(Locale.ROOT);
+ Boolean vulnerable = filter != null && filter.getMinSeverity() != null
+ && filter.getMinSeverity() != SoftwareCveSeverity.NONE
+ ? Boolean.TRUE : null;
+ return PageCursors.toConnection(softwareInventoryService.listSoftware(
+ search, page, perPage, orderKey, orderDirection, vulnerable));
+ }
+
+ @DgsQuery
+ public CountedGenericConnection> softwareDevices(
+ @InputArgument String softwareId, @InputArgument Object filter,
+ @InputArgument Integer first, @InputArgument String after,
+ @InputArgument Integer last, @InputArgument String before,
+ @InputArgument String search, @InputArgument Object sort) {
+ int page = PageCursors.decodePage(after != null ? after : before);
+ Integer perPage = first != null ? first : last;
+ return PageCursors.toConnection(
+ softwareInventoryService.listDevicesForSoftware(softwareId, search, page, perPage));
+ }
+
+ @DgsQuery
+ public CountedGenericConnection> softwareVulnerabilities(
+ @InputArgument String softwareId, @InputArgument Object filter,
+ @InputArgument Integer first, @InputArgument String after,
+ @InputArgument Integer last, @InputArgument String before,
+ @InputArgument String search, @InputArgument SortInput sort) {
+ int page = PageCursors.decodePage(after != null ? after : before);
+ Integer perPage = first != null ? first : last;
+ String sortField = sort == null ? null : sort.getField();
+ boolean asc = sort != null && sort.getDirection() == SortDirection.ASC;
+ return PageCursors.toConnection(softwareInventoryService.listVulnerabilitiesForSoftware(
+ softwareId, search, page, perPage, sortField, asc));
+ }
+
+ @DgsQuery
+ public SoftwareFilters softwareFilters(@InputArgument Object filter, @InputArgument String search) {
+ return softwareInventoryService.getSoftwareFilters(search);
+ }
+}
diff --git a/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareScheduleDataFetcher.java b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareScheduleDataFetcher.java
index 120bc2aa44..acf2888541 100644
--- a/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareScheduleDataFetcher.java
+++ b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareScheduleDataFetcher.java
@@ -18,6 +18,7 @@
import com.openframe.api.dto.rmm.software.SoftwareScheduleResponse;
import com.openframe.api.dto.rmm.software.UpdateSoftwareScheduleInput;
import com.openframe.api.dto.shared.ConnectionArgs;
+import com.openframe.api.dto.rmm.schedule.ScheduleDeviceCriteriaInput;
import com.openframe.api.dto.shared.CursorPaginationCriteria;
import com.openframe.api.dto.shared.SortInput;
import com.openframe.api.dto.user.UserResponse;
@@ -25,6 +26,7 @@
import com.openframe.api.service.device.DeviceService;
import com.openframe.api.service.rmm.software.SoftwareScheduleService;
import com.openframe.data.document.device.Machine;
+import com.openframe.data.document.rmm.schedule.ScheduleDeviceCriteria;
import com.openframe.security.authentication.AuthPrincipal;
import graphql.relay.Relay;
import jakarta.validation.Valid;
@@ -118,6 +120,18 @@ public SoftwareScheduleResponse removeDevicesFromSoftwareSchedule(@InputArgument
return scheduleService.get(rawScheduleId);
}
+ @DgsMutation
+ public SoftwareScheduleResponse setSoftwareScheduleDeviceCriteria(@InputArgument @NotBlank String scheduleId,
+ @InputArgument @Valid ScheduleDeviceCriteriaInput criteria,
+ @AuthenticationPrincipal AuthPrincipal principal) {
+ ScheduleDeviceCriteria domainCriteria = ScheduleDeviceCriteria.builder()
+ .organizationIds(criteria.getOrganizationIds())
+ .deviceTypes(criteria.getDeviceTypes())
+ .osTypes(criteria.getOsTypes())
+ .build();
+ return scheduleService.setDeviceCriteria(decodeId(scheduleId), domainCriteria, principal.getId());
+ }
+
@DgsData(parentType = "SoftwareSchedule", field = "id")
public String softwareScheduleNodeId(DgsDataFetchingEnvironment dfe) {
SoftwareScheduleResponse schedule = dfe.getSource();
diff --git a/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/VulnerabilityDataFetcher.java b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/VulnerabilityDataFetcher.java
new file mode 100644
index 0000000000..13c2e9e69d
--- /dev/null
+++ b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/VulnerabilityDataFetcher.java
@@ -0,0 +1,71 @@
+package com.openframe.api.datafetcher.rmm;
+
+import com.netflix.graphql.dgs.DgsComponent;
+import com.netflix.graphql.dgs.DgsQuery;
+import com.netflix.graphql.dgs.InputArgument;
+import com.openframe.api.dto.CountedGenericConnection;
+import com.openframe.api.dto.GenericEdge;
+import com.openframe.api.dto.rmm.vulnerability.VulnerabilityFilterInput;
+import com.openframe.api.dto.rmm.vulnerability.VulnerabilityResponse;
+import com.openframe.api.dto.shared.SortInput;
+import com.openframe.api.service.rmm.vulnerability.VulnerabilityInventoryService;
+import com.openframe.data.document.device.Machine;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+
+import java.util.Locale;
+import java.util.Map;
+
+@DgsComponent
+@ConditionalOnProperty(name = "openframe.rmm.software.enabled", havingValue = "true")
+@RequiredArgsConstructor
+@Slf4j
+public class VulnerabilityDataFetcher {
+
+ private static final Map FLEET_ORDER_KEY = Map.of(
+ "cveId", "cve",
+ "cvssScore", "cvss_score",
+ "severity", "cvss_score",
+ "devicesCount", "hosts_count",
+ "epssProbability", "epss_probability",
+ "publishedAt", "cve_published",
+ "createdAt", "created_at");
+
+ private final VulnerabilityInventoryService vulnerabilityInventoryService;
+
+ @DgsQuery
+ public VulnerabilityResponse vulnerability(@InputArgument String cveId) {
+ return vulnerabilityInventoryService.findByCveId(cveId).orElse(null);
+ }
+
+ @DgsQuery
+ public CountedGenericConnection> vulnerabilities(
+ @InputArgument VulnerabilityFilterInput filter,
+ @InputArgument Integer first, @InputArgument String after,
+ @InputArgument Integer last, @InputArgument String before,
+ @InputArgument String search, @InputArgument SortInput sort) {
+ int page = PageCursors.decodePage(after != null ? after : before);
+ Integer perPage = first != null ? first : last;
+ String orderKey = sort == null ? null : FLEET_ORDER_KEY.get(sort.getField());
+ String orderDirection = sort == null || sort.getDirection() == null
+ ? null : sort.getDirection().name().toLowerCase(Locale.ROOT);
+ Boolean exploit = filter != null && Boolean.TRUE.equals(filter.getExploited())
+ ? Boolean.TRUE : null;
+ return PageCursors.toConnection(vulnerabilityInventoryService.listVulnerabilities(
+ search, page, perPage, orderKey, orderDirection, exploit,
+ filter == null ? null : filter.getMinSeverity()));
+ }
+
+ @DgsQuery
+ public CountedGenericConnection> vulnerabilityDevices(
+ @InputArgument String cveId,
+ @InputArgument Integer first, @InputArgument String after,
+ @InputArgument Integer last, @InputArgument String before,
+ @InputArgument String search, @InputArgument SortInput sort) {
+ int page = PageCursors.decodePage(after != null ? after : before);
+ Integer perPage = first != null ? first : last;
+ return PageCursors.toConnection(
+ vulnerabilityInventoryService.listDevicesForCve(cveId, search, page, perPage));
+ }
+}
diff --git a/openframe-api-service-core/src/main/resources/schema/software-action.graphqls b/openframe-api-service-core/src/main/resources/schema/software-action.graphqls
new file mode 100644
index 0000000000..49c67b41c1
--- /dev/null
+++ b/openframe-api-service-core/src/main/resources/schema/software-action.graphqls
@@ -0,0 +1,124 @@
+# Software Actions — the "Software Actions" page: one row per install/update of a package across its devices.
+# A row is aggregated from the execution leaves by executionId; the X/Y ratio is respondedMachineCount / totalMachineCount.
+# Gated behind openframe.rmm.software.enabled=true. Reuses SoftwareAction, PackageManagerType, Instant, SortInput.
+
+extend type Query {
+ """One software action by id. Null when unknown."""
+ softwareAction(id: ID!): SoftwareActionRun
+
+ """Software actions for the tenant (install/update runs + scheduled ones), newest first."""
+ softwareActions(
+ filter: SoftwareActionFilterInput,
+ first: Int,
+ after: String,
+ last: Int,
+ before: String,
+ search: String,
+ sort: SortInput
+ ): SoftwareActionRunConnection!
+
+ """
+ Device drill-down for one action: every target device with its result. Pass the SoftwareActionRun's
+ opaque id as actionId (it carries the execution / bundle / schedule keys). Executed devices come from
+ their execution leaves (with output for "Show Result"); devices still offline come from the action's
+ reconnect sentinels, and a not-yet-fired schedule lists its assigned devices — all as SCHEDULED.
+ Optional filter (Status / Customer) and search (hostname or machine id).
+ """
+ softwareActionExecutions(
+ actionId: String!,
+ filter: SoftwareActionDeviceFilterInput,
+ search: String
+ ): [SoftwareActionDevice!]!
+
+ """Available filter options (with live counts) for the softwareActions list — the ACTION / ENGINE /
+ STATUS dropdowns. Same filter/search scope as softwareActions; each facet is counted under the active
+ filters. filteredCount = total rows (executed + scheduled) matching them."""
+ softwareActionFilters(
+ filter: SoftwareActionFilterInput,
+ search: String
+ ): SoftwareActionFilters!
+}
+
+"""A single install/update of one package across its target devices."""
+type SoftwareActionRun {
+ id: ID!
+ executionId: String!
+ "Package name (the SOFTWARE column)."
+ software: String!
+ "INSTALL or UPDATE."
+ action: SoftwareAction!
+ "Package manager engine — BREW / WINGET / CHOCO."
+ engine: PackageManagerType!
+ status: SoftwareActionStatus!
+ "Total target devices (Y in X / Y)."
+ totalMachineCount: Int!
+ "Devices that have finished (X in X / Y)."
+ respondedMachineCount: Int!
+ "When a SCHEDULED action will fire. Null once dispatched / for NOW."
+ scheduledAt: Instant
+ "When the action was dispatched. Null while SCHEDULED."
+ dispatchedAt: Instant
+ finishedAt: Instant
+ initiatedBy: String
+ "Source bundle of a NOW action — pass to softwareActionExecutions. Null for SCHEDULE."
+ bundleId: String
+ "Source schedule of a SCHEDULE action — pass to softwareActionExecutions. Null for NOW."
+ scheduleId: String
+}
+
+enum SoftwareActionStatus {
+ SCHEDULED
+ IN_PROGRESS
+ COMPLETED
+ FAILED
+}
+
+"""One target device of a software action, with its result. Customer = the device's organization."""
+type SoftwareActionDevice {
+ machineId: ID!
+ hostname: String
+ organizationId: String
+ organizationName: String
+ status: SoftwareActionStatus!
+ exitCode: Int
+ stdout: String
+ stdoutTruncated: Boolean
+ stderr: String
+ stderrTruncated: Boolean
+ error: String
+ dispatchedAt: Instant
+ finishedAt: Instant
+}
+
+"""Filter for softwareActionExecutions. All fields optional; null/empty = no constraint.
+organizationIds is the Customer filter."""
+input SoftwareActionDeviceFilterInput {
+ statuses: [SoftwareActionStatus!]
+ organizationIds: [ID!]
+}
+
+type SoftwareActionRunConnection {
+ edges: [SoftwareActionRunEdge!]!
+ pageInfo: PageInfo!
+ filteredCount: Int!
+}
+
+type SoftwareActionRunEdge {
+ node: SoftwareActionRun!
+ cursor: String!
+}
+
+input SoftwareActionFilterInput {
+ statuses: [SoftwareActionStatus!]
+ actions: [SoftwareAction!]
+ engines: [PackageManagerType!]
+}
+
+"""Faceted filter options (with live counts) for the "Software Actions" page. Reuses ScriptFilterOption
+(value / label / count); values are enum names. filteredCount = total rows matching all active filters."""
+type SoftwareActionFilters {
+ statuses: [ScriptFilterOption!]!
+ actions: [ScriptFilterOption!]!
+ engines: [ScriptFilterOption!]!
+ filteredCount: Int!
+}
diff --git a/openframe-api-service-core/src/main/resources/schema/software-bundle.graphqls b/openframe-api-service-core/src/main/resources/schema/software-bundle.graphqls
new file mode 100644
index 0000000000..baa92008e0
--- /dev/null
+++ b/openframe-api-service-core/src/main/resources/schema/software-bundle.graphqls
@@ -0,0 +1,97 @@
+# Software bundle — the staging layer for immediate Install / Update Software.
+#
+# The frontend creates a bundle the moment the first device is assigned on the Install/Update screen, then
+# edits it (devices/packages) as the user builds the request. Running the bundle dispatches every package to
+# the matching-OS subset of its devices (brew → macOS, winget/choco → Windows) and flips it to COMPLETED.
+# A PENDING bundle left unrun is disposable and reaped by a Mongo TTL index.
+#
+# Gated behind openframe.rmm.software.enabled=true (+ spring.cloud.stream.enabled for the run dispatch).
+# Reuses SoftwareAction, SoftwarePackageInput, SoftwareDispatchResult, PackageManagerType and BrewPackageType
+# from software.graphqls.
+
+extend type Query {
+ """One bundle by id. Null when it does not exist (or was already reaped)."""
+ softwareBundle(id: ID!): SoftwareBundle
+
+ """Bundles for the tenant, newest first. Optionally filtered by status."""
+ softwareBundles(status: SoftwareBundleStatus): [SoftwareBundle!]!
+}
+
+extend type Mutation {
+ """Create a staged bundle (status PENDING). Called when the first device is assigned; packages may be
+ empty at this point and added later via updateSoftwareBundle."""
+ createSoftwareBundle(input: CreateSoftwareBundleInput!): SoftwareBundle!
+
+ """Replace a PENDING bundle's devices and/or packages. Fails if the bundle is already COMPLETED."""
+ updateSoftwareBundle(input: UpdateSoftwareBundleInput!): SoftwareBundle!
+
+ """Discard a PENDING bundle. Fails if the bundle is already COMPLETED (history is immutable)."""
+ deleteSoftwareBundle(id: ID!): Boolean!
+
+ """Run a PENDING bundle now (Device-Online): arm each assigned device so its OS-compatible packages run as
+ soon as it is online — immediately for online devices, on reconnect for offline ones — and mark the bundle
+ COMPLETED. Returns the COMPLETED bundle. Fails if already COMPLETED."""
+ runSoftwareBundle(id: ID!): SoftwareBundle!
+}
+
+"""A staged install/update operation: devices + packages, plus lifecycle state."""
+type SoftwareBundle {
+ id: ID!
+ "INSTALL or UPDATE — fixed at creation."
+ action: SoftwareAction!
+ "NOW (Device-Online) or SCHEDULED (fires a DATE_TIME schedule at startAt)."
+ mode: SoftwareBundleMode!
+ "PENDING while drafting, COMPLETED once run. Server-controlled; never settable."
+ status: SoftwareBundleStatus!
+ "Assigned device machineIds (1..n)."
+ machineIds: [String!]!
+ "Packages to act on. May be empty while PENDING."
+ packages: [SoftwareBundlePackage!]!
+ "For SCHEDULED bundles: when the run fires (absolute instant, SERVER time). Null for NOW."
+ startAt: Instant
+ "For SCHEDULED bundles: the schedule created on run. Null for NOW / while PENDING."
+ scheduleId: String
+ createdBy: String
+ createdAt: Instant
+ updatedAt: Instant
+ "When the bundle was run. Null while PENDING."
+ completedAt: Instant
+ "Execution ids from the run (one per dispatched package). Null/empty while PENDING."
+ executionIds: [String!]
+}
+
+enum SoftwareBundleMode {
+ NOW
+ SCHEDULED
+}
+
+"""One catalog package staged in a bundle."""
+type SoftwareBundlePackage {
+ packageManager: PackageManagerType!
+ packageName: String!
+ brewPackageType: BrewPackageType
+}
+
+enum SoftwareBundleStatus {
+ PENDING
+ COMPLETED
+}
+
+"""Create a bundle. machineIds must have at least one device; packages may be omitted while drafting.
+startAt is required for SCHEDULED bundles (enforced at run) and ignored for NOW."""
+input CreateSoftwareBundleInput {
+ action: SoftwareAction!
+ mode: SoftwareBundleMode!
+ machineIds: [String!]!
+ packages: [SoftwarePackageInput!]
+ startAt: Instant
+}
+
+"""Edit a PENDING bundle. action and status are immutable and cannot be set."""
+input UpdateSoftwareBundleInput {
+ id: ID!
+ mode: SoftwareBundleMode!
+ machineIds: [String!]!
+ packages: [SoftwarePackageInput!]
+ startAt: Instant
+}
diff --git a/openframe-api-service-core/src/main/resources/schema/software-schedule.graphqls b/openframe-api-service-core/src/main/resources/schema/software-schedule.graphqls
index 7f63ade35f..0225e4a748 100644
--- a/openframe-api-service-core/src/main/resources/schema/software-schedule.graphqls
+++ b/openframe-api-service-core/src/main/resources/schema/software-schedule.graphqls
@@ -41,6 +41,12 @@ extend type Mutation {
"""Incrementally unassign the given devices (the trash / "Remove selected" actions). Missing ids are no-ops."""
removeDevicesFromSoftwareSchedule(scheduleId: ID!, machineIds: [ID!]!): SoftwareSchedule!
+
+ """Switch a software schedule to CRITERIA device selection and store its rule (customer / type / OS) —
+ backs "Select Devices by Criteria" → Save Devices. The target device set is then resolved live at
+ dispatch and display time, so devices registered later that match the rule are included automatically.
+ Reuses the same ScheduleDeviceCriteriaInput as script schedules. Returns the updated schedule."""
+ setSoftwareScheduleDeviceCriteria(scheduleId: ID!, criteria: ScheduleDeviceCriteriaInput!): SoftwareSchedule!
}
type SoftwareSchedule implements Node {
@@ -51,8 +57,12 @@ type SoftwareSchedule implements Node {
action: SoftwareAction!
"""The packages this schedule installs/updates."""
packages: [SoftwareSchedulePackage!]!
- """How this schedule targets devices. Always SPECIFIC for now (criteria out of scope)."""
+ """How this schedule targets devices: SPECIFIC (an explicit machine set) or CRITERIA (a live rule;
+ devices registered later that match are included automatically). Never null — legacy schedules read as SPECIFIC."""
selectionMode: ScheduleDeviceSelectionMode!
+ """The device-selection rule when selectionMode is CRITERIA (customer / type / OS). Null for SPECIFIC schedules.
+ Reuses the ScheduleDeviceCriteria type from script schedules."""
+ deviceCriteria: ScheduleDeviceCriteria
"""How a DATE_TIME schedule reads its startAt: SERVER (an absolute instant) or DEVICE_LOCAL (a wall-clock re-based into each device's own timezone). Never null; defaults to SERVER."""
timeReference: ScheduleTimeReference!
"""What to do when a target device is offline at the scheduled time. Never null; defaults to SKIP."""
diff --git a/openframe-api-service-core/src/main/resources/schema/software.graphqls b/openframe-api-service-core/src/main/resources/schema/software.graphqls
index f4da4808e2..5c7b772598 100644
--- a/openframe-api-service-core/src/main/resources/schema/software.graphqls
+++ b/openframe-api-service-core/src/main/resources/schema/software.graphqls
@@ -1,3 +1,304 @@
+# Software inventory (read side, thin proxy over Fleet MDM REST).
+#
+# The wire contract is stable so the frontend can start binding against it;
+# resolvers are gated behind a Conditional-property and only wire up when the
+# Software feature is enabled via openframe.rmm.software.enabled=true.
+
+extend type Query {
+ """Single software row by id (Relay global id). Null when unknown."""
+ software(id: ID!): Software
+
+ """Paginated list of software titles installed across the tenant's fleet.
+ One row = one software title (aggregated across devices)."""
+ softwares(
+ filter: SoftwareFilterInput,
+ first: Int,
+ after: String,
+ last: Int,
+ before: String,
+ search: String,
+ sort: SortInput
+ ): SoftwareConnection!
+
+ """Devices that have a given software title installed, with each device's own
+ installed version and per-device software status (OUTDATED / SCHEDULED_UPDATE / …).
+ Powers the Software → Devices tab."""
+ softwareDevices(
+ softwareId: ID!,
+ filter: SoftwareOnDeviceFilterInput,
+ first: Int,
+ after: String,
+ last: Int,
+ before: String,
+ search: String,
+ sort: SortInput
+ ): SoftwareOnDeviceConnection!
+
+ """CVEs affecting a given software title. Powers the Software → Vulnerabilities tab."""
+ softwareVulnerabilities(
+ softwareId: ID!,
+ filter: SoftwareVulnerabilityFilterInput,
+ first: Int,
+ after: String,
+ last: Int,
+ before: String,
+ search: String,
+ sort: SortInput
+ ): SoftwareVulnerabilityConnection!
+
+ """Faceted filter-option counts for the software list — Type / Source / Version-status /
+ Vulnerability-severity dropdowns. Mirrors scriptFilters / deviceFilters."""
+ softwareFilters(filter: SoftwareFilterInput, search: String): SoftwareFilters!
+
+ """Single global CVE record by id (e.g. "CVE-2024-38063"). Null when Fleet has no record.
+ Populates the vulnerability detail page — includes affected-software list."""
+ vulnerability(cveId: String!): Vulnerability
+
+ """Global list of CVEs across every software title in the tenant's fleet.
+ Powers the Vulnerabilities page. One row = one CVE (not one CVE × software)."""
+ vulnerabilities(
+ filter: VulnerabilityFilterInput,
+ first: Int,
+ after: String,
+ last: Int,
+ before: String,
+ search: String,
+ sort: SortInput
+ ): VulnerabilityConnection!
+
+ """Devices affected by a given CVE — the hosts that have an affected software version, correlated
+ to their OpenFrame Machine. Powers the vulnerability-detail "Devices" table. Each node is a Machine
+ (same shape as the top-level `devices` query)."""
+ vulnerabilityDevices(
+ cveId: String!,
+ first: Int,
+ after: String,
+ last: Int,
+ before: String,
+ search: String,
+ sort: SortInput
+ ): DeviceConnection!
+}
+
+# ────────── Software (aggregate row) ──────────
+
+type Software {
+ id: ID!
+ name: String!
+ publisher: String
+
+ """Package-manager source — WINGET / CHOCOLATEY / BREW / UNMANAGED."""
+ source: SoftwareSource
+
+ """Most-recent version installed anywhere in the fleet — shown as CURRENT VERSION."""
+ currentVersion: String
+
+ """Most up-to-date known version — shown as Latest Version on the detail page."""
+ latestVersion: String
+
+ """UP_TO_DATE / OUTDATED / UNKNOWN — drives the "OUTDATED" chip in the list."""
+ versionStatus: SoftwareVersionStatus
+
+ """Additional older versions in use across the fleet, beyond currentVersion.
+ Shows as "+N older versions" line in the list."""
+ olderVersionsCount: Int
+
+ """Number of devices with this software installed."""
+ devicesCount: Int
+
+ """Vulnerability roll-up — highest severity + CVE count for the badge."""
+ vulnerabilitySummary: SoftwareVulnerabilitySummary
+
+ """False when the vulnerability scanner could not find a matching CPE entry
+ — shown as "no CPE match" instead of a severity badge."""
+ cpeMatched: Boolean
+}
+
+type SoftwareVulnerabilitySummary {
+ highestSeverity: SoftwareCveSeverity
+ cveCount: Int!
+}
+
+# ────────── Software on device (per-device row) ──────────
+
+type SoftwareOnDevice {
+ device: Machine!
+ softwareVersion: String
+ status: SoftwareOnDeviceStatus
+}
+
+# ────────── Vulnerability (CVE row) ──────────
+
+type SoftwareVulnerability {
+ cveId: String!
+ severity: SoftwareCveSeverity
+ cvssScore: Float
+ affectedVersion: String
+ publishedAt: String
+}
+
+# ────────── Enums ──────────
+
+enum SoftwareSource {
+ WINGET
+ CHOCOLATEY
+ BREW
+ UNMANAGED
+}
+
+enum SoftwareVersionStatus {
+ UP_TO_DATE
+ OUTDATED
+ UNKNOWN
+}
+
+enum SoftwareCveSeverity {
+ CRITICAL
+ HIGH
+ MEDIUM
+ LOW
+ NONE
+}
+
+"""Per-device software state: static (UP_TO_DATE / OUTDATED) or in a lifecycle
+transition (SCHEDULED_UPDATE / UNINSTALLING / SCHEDULED_UNINSTALL)."""
+enum SoftwareOnDeviceStatus {
+ UP_TO_DATE
+ OUTDATED
+ SCHEDULED_UPDATE
+ UNINSTALLING
+ SCHEDULED_UNINSTALL
+}
+
+# ────────── Connections (Relay-style) ──────────
+
+type SoftwareConnection {
+ edges: [SoftwareEdge!]!
+ pageInfo: PageInfo!
+ filteredCount: Int!
+}
+
+type SoftwareEdge {
+ node: Software!
+ cursor: String!
+}
+
+type SoftwareOnDeviceConnection {
+ edges: [SoftwareOnDeviceEdge!]!
+ pageInfo: PageInfo!
+ filteredCount: Int!
+}
+
+type SoftwareOnDeviceEdge {
+ node: SoftwareOnDevice!
+ cursor: String!
+}
+
+type SoftwareVulnerabilityConnection {
+ edges: [SoftwareVulnerabilityEdge!]!
+ pageInfo: PageInfo!
+ filteredCount: Int!
+}
+
+type SoftwareVulnerabilityEdge {
+ node: SoftwareVulnerability!
+ cursor: String!
+}
+
+# ────────── Facet dropdowns ──────────
+
+type SoftwareFilters {
+ sources: [SoftwareFilterOption!]!
+ versionStatuses: [SoftwareFilterOption!]!
+ severities: [SoftwareFilterOption!]!
+}
+
+type SoftwareFilterOption {
+ value: String!
+ label: String!
+ count: Int!
+}
+
+# ────────── Inputs ──────────
+
+input SoftwareFilterInput {
+ sources: [SoftwareSource!]
+ versionStatuses: [SoftwareVersionStatus!]
+ minSeverity: SoftwareCveSeverity
+ deviceTagIds: [ID!]
+}
+
+input SoftwareOnDeviceFilterInput {
+ statuses: [SoftwareOnDeviceStatus!]
+ deviceTagIds: [ID!]
+}
+
+input SoftwareVulnerabilityFilterInput {
+ severities: [SoftwareCveSeverity!]
+}
+
+# ────────── Global vulnerability (CVE row) ──────────
+
+"""Global CVE record. Detail-only fields (description, resolvedInVersion, affectedSoftware)
+are null on list rows and populated on the detail page."""
+type Vulnerability {
+ cveId: String!
+
+ """Severity bucketed from CVSS via the NVD ranges (CRITICAL ≥ 9, HIGH ≥ 7, MEDIUM ≥ 4, LOW > 0).
+ Null when Fleet has no CVSS score for the CVE."""
+ severity: SoftwareCveSeverity
+
+ cvssScore: Float
+
+ """Exploit Prediction Scoring System probability (0..1) — Fleet Premium only."""
+ epssProbability: Float
+
+ """True when the CVE is on CISA's Known Exploited Vulnerabilities list — Fleet Premium only."""
+ cisaKnownExploit: Boolean
+
+ """CVE publication timestamp (ISO-8601)."""
+ publishedAt: String
+
+ """External details link Fleet returns (usually NVD)."""
+ detailsLink: String
+
+ """Number of tenant devices affected by any (software, version) pair hit by this CVE."""
+ devicesCount: Int
+
+ # ── Detail-only ──
+ description: String
+ resolvedInVersion: String
+ affectedSoftware: [AffectedSoftware!]
+}
+
+"""One row of the "Affected Software" table on the vulnerability detail page."""
+type AffectedSoftware {
+ id: ID!
+ name: String!
+ source: SoftwareSource
+ version: String
+ devicesCount: Int
+ resolvedInVersion: String
+}
+
+type VulnerabilityConnection {
+ edges: [VulnerabilityEdge!]!
+ pageInfo: PageInfo!
+ filteredCount: Int!
+}
+
+type VulnerabilityEdge {
+ node: Vulnerability!
+ cursor: String!
+}
+
+input VulnerabilityFilterInput {
+ """Cut-off severity — rows with severity below this are excluded."""
+ minSeverity: SoftwareCveSeverity
+ """True → only CVEs on CISA's Known Exploited Vulnerabilities list."""
+ exploited: Boolean
+}
+
# GraphQL schema for RMM software management (install / update packages).
#
# Built entirely on top of the existing RMM script-execution machinery: each package becomes its
diff --git a/openframe-api-service-core/src/test/java/com/openframe/api/service/rmm/ScheduleDeviceTargetResolverTest.java b/openframe-api-service-core/src/test/java/com/openframe/api/service/rmm/ScheduleDeviceTargetResolverTest.java
index 51e954a92e..c2064ded1a 100644
--- a/openframe-api-service-core/src/test/java/com/openframe/api/service/rmm/ScheduleDeviceTargetResolverTest.java
+++ b/openframe-api-service-core/src/test/java/com/openframe/api/service/rmm/ScheduleDeviceTargetResolverTest.java
@@ -11,12 +11,13 @@
import com.openframe.data.document.device.filter.MachineQueryFilter;
import com.openframe.data.repository.device.MachineRepository;
import com.openframe.data.repository.rmm.ScriptScheduleMachineAssignedRepository;
+import com.openframe.data.service.rmm.ScheduleCriteriaDeviceResolver;
import com.openframe.data.service.rmm.ScheduleDeviceTargetResolver;
+import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
-import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@@ -40,7 +41,12 @@ class ScheduleDeviceTargetResolverTest {
@Mock private MachineRepository machineRepository;
@Mock private ScriptScheduleMachineAssignedRepository assignedRepository;
- @InjectMocks private ScheduleDeviceTargetResolver resolver;
+ private ScheduleDeviceTargetResolver resolver;
+
+ @BeforeEach
+ void setUp() {
+ resolver = new ScheduleDeviceTargetResolver(machineRepository, assignedRepository, new ScheduleCriteriaDeviceResolver(machineRepository));
+ }
@Test
@DisplayName("resolveTargetMachineIds: SPECIFIC reads the join rows (deduped) and keeps active machines in input order")
diff --git a/openframe-api-service-core/src/test/java/com/openframe/api/service/rmm/software/SoftwareInstallUpdateManagementServiceTest.java b/openframe-api-service-core/src/test/java/com/openframe/api/service/rmm/software/SoftwareInstallUpdateManagementServiceTest.java
index d9d55a50c8..f113171b08 100644
--- a/openframe-api-service-core/src/test/java/com/openframe/api/service/rmm/software/SoftwareInstallUpdateManagementServiceTest.java
+++ b/openframe-api-service-core/src/test/java/com/openframe/api/service/rmm/software/SoftwareInstallUpdateManagementServiceTest.java
@@ -5,14 +5,20 @@
import com.openframe.api.dto.rmm.software.SoftwareManagementInput;
import com.openframe.api.dto.rmm.software.SoftwarePackageInput;
import com.openframe.api.service.rmm.script.ScriptService;
+import com.openframe.data.document.device.Machine;
import com.openframe.data.document.packagesearch.BrewPackageType;
import com.openframe.data.document.packagesearch.PackageManagerType;
import com.openframe.data.document.rmm.script.ExecutionSource;
+import com.openframe.data.document.rmm.script.OsType;
import com.openframe.data.document.rmm.script.PrivilegeLevel;
import com.openframe.data.document.rmm.software.SoftwareAction;
import com.openframe.data.document.rmm.software.SoftwareScriptCode;
+import com.openframe.data.repository.device.MachineRepository;
+import com.openframe.data.service.TenantIdProvider;
+import com.openframe.data.service.rmm.MachinePlatformResolver;
import com.openframe.data.service.rmm.software.BrewPackageManagerHandler;
import com.openframe.data.service.rmm.software.PackageManagerRegistry;
+import com.openframe.data.service.rmm.software.WingetPackageManagerHandler;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -26,6 +32,7 @@
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -36,22 +43,35 @@ class SoftwareInstallUpdateManagementServiceTest {
private static final String USER = "user-1";
private static final List MACHINES = List.of("m1", "m2");
+ private static final String TENANT = "t1";
+
@Mock private PackageManagerRegistry registry;
@Mock private ScriptService scriptService;
@Mock private SoftwareDispatchService softwareDispatchService;
+ @Mock private MachineRepository machineRepository;
+ @Mock private TenantIdProvider tenantIdProvider;
private SoftwareInstallUpdateManagementService service;
@org.junit.jupiter.api.BeforeEach
void setUp() {
- service = new SoftwareInstallUpdateManagementService(registry, scriptService, softwareDispatchService);
+ MachinePlatformResolver platformResolver = new MachinePlatformResolver(machineRepository, tenantIdProvider);
+ service = new SoftwareInstallUpdateManagementService(registry, scriptService, softwareDispatchService,
+ platformResolver);
// A real brew handler so we exercise real script-code + arg building.
when(registry.handlerFor(PackageManagerType.BREW)).thenReturn(new BrewPackageManagerHandler());
+ when(tenantIdProvider.getTenantId()).thenReturn(TENANT);
+ }
+
+ private void allMacTargets() {
+ when(machineRepository.findByTenantIdAndMachineIdIn(eq(TENANT), any()))
+ .thenReturn(List.of(machine("m1", OsType.MAC_OS), machine("m2", OsType.MAC_OS)));
}
@Test
@DisplayName("install: each package dispatched with the right script + args; system script resolved once")
void install_dispatchesPerPackage() {
+ allMacTargets();
ScriptResponse installScript = systemScript("brew-install-id");
when(scriptService.getSoftwareScript(SoftwareScriptCode.BREW_INSTALL)).thenReturn(installScript);
when(softwareDispatchService.dispatch(any(), anyList(), anyList(), eq(USER), eq(ExecutionSource.MANUAL),
@@ -87,6 +107,7 @@ void install_dispatchesPerPackage() {
@Test
@DisplayName("update: routes to the BREW_UPDATE system script and propagates the caller's ExecutionSource (e.g. AI_ASSISTANT for MingoAI)")
void update_usesUpdateScript_andPropagatesSource() {
+ allMacTargets();
ScriptResponse updateScript = systemScript("brew-update-id");
when(scriptService.getSoftwareScript(SoftwareScriptCode.BREW_UPDATE)).thenReturn(updateScript);
when(softwareDispatchService.dispatch(any(), anyList(), anyList(), eq(USER), eq(ExecutionSource.AI_ASSISTANT),
@@ -102,6 +123,65 @@ void update_usesUpdateScript_andPropagatesSource() {
eq(PackageManagerType.BREW), eq("slack"), eq(SoftwareAction.UPDATE));
}
+ @Test
+ @DisplayName("install: a mixed brew+winget bundle routes each package to its own OS — brew→macOS, winget→Windows")
+ void install_mixedOs_routesPerPackage() {
+ when(registry.handlerFor(PackageManagerType.WINGET)).thenReturn(new WingetPackageManagerHandler());
+ when(machineRepository.findByTenantIdAndMachineIdIn(eq(TENANT), any()))
+ .thenReturn(List.of(machine("m-mac", OsType.MAC_OS), machine("m-win", OsType.WINDOWS)));
+ ScriptResponse brew = script("brew-install-id", OsType.MAC_OS);
+ ScriptResponse winget = script("winget-install-id", OsType.WINDOWS);
+ when(scriptService.getSoftwareScript(SoftwareScriptCode.BREW_INSTALL)).thenReturn(brew);
+ when(scriptService.getSoftwareScript(SoftwareScriptCode.WINGET_INSTALL)).thenReturn(winget);
+ when(softwareDispatchService.dispatch(any(), anyList(), anyList(), eq(USER), eq(ExecutionSource.MANUAL),
+ any(), any(), any()))
+ .thenReturn("exec-brew", "exec-winget");
+
+ SoftwareManagementInput mixed = new SoftwareManagementInput();
+ mixed.setMachineIds(List.of("m-mac", "m-win"));
+ mixed.setPackages(List.of(
+ pkg(PackageManagerType.BREW, "slack", BrewPackageType.CASK),
+ pkg(PackageManagerType.WINGET, "vscode", null)));
+
+ service.install(mixed, USER, ExecutionSource.MANUAL);
+
+ verify(softwareDispatchService).dispatch(eq(brew), eq(List.of("m-mac")), anyList(), eq(USER),
+ eq(ExecutionSource.MANUAL), eq(PackageManagerType.BREW), eq("slack"), eq(SoftwareAction.INSTALL));
+ verify(softwareDispatchService).dispatch(eq(winget), eq(List.of("m-win")), anyList(), eq(USER),
+ eq(ExecutionSource.MANUAL), eq(PackageManagerType.WINGET), eq("vscode"), eq(SoftwareAction.INSTALL));
+ }
+
+ @Test
+ @DisplayName("install: a package with no OS-compatible device is skipped, while a compatible one still dispatches")
+ void install_incompatiblePackage_skipped() {
+ when(registry.handlerFor(PackageManagerType.WINGET)).thenReturn(new WingetPackageManagerHandler());
+ // Only a macOS device is assigned — the winget package has nowhere to go.
+ when(machineRepository.findByTenantIdAndMachineIdIn(eq(TENANT), any()))
+ .thenReturn(List.of(machine("m-mac", OsType.MAC_OS)));
+ ScriptResponse brew = script("brew-install-id", OsType.MAC_OS);
+ ScriptResponse winget = script("winget-install-id", OsType.WINDOWS);
+ when(scriptService.getSoftwareScript(SoftwareScriptCode.BREW_INSTALL)).thenReturn(brew);
+ when(scriptService.getSoftwareScript(SoftwareScriptCode.WINGET_INSTALL)).thenReturn(winget);
+ when(softwareDispatchService.dispatch(eq(brew), anyList(), anyList(), eq(USER), eq(ExecutionSource.MANUAL),
+ any(), any(), any()))
+ .thenReturn("exec-brew");
+
+ SoftwareManagementInput in = new SoftwareManagementInput();
+ in.setMachineIds(List.of("m-mac"));
+ in.setPackages(List.of(
+ pkg(PackageManagerType.BREW, "slack", BrewPackageType.CASK),
+ pkg(PackageManagerType.WINGET, "vscode", null)));
+
+ List results = service.install(in, USER, ExecutionSource.MANUAL);
+
+ // brew dispatched to the macOS device; winget skipped entirely (no Windows device).
+ assertThat(results).extracting(SoftwareDispatchResult::getPackageName).containsExactly("slack");
+ verify(softwareDispatchService).dispatch(eq(brew), eq(List.of("m-mac")), anyList(), eq(USER),
+ eq(ExecutionSource.MANUAL), eq(PackageManagerType.BREW), eq("slack"), eq(SoftwareAction.INSTALL));
+ verify(softwareDispatchService, never()).dispatch(eq(winget), anyList(), anyList(), any(), any(),
+ any(), any(), any());
+ }
+
private static SoftwareManagementInput input(SoftwarePackageInput... packages) {
SoftwareManagementInput input = new SoftwareManagementInput();
input.setMachineIds(MACHINES);
@@ -118,10 +198,22 @@ private static SoftwarePackageInput pkg(PackageManagerType manager, String id, B
}
private static ScriptResponse systemScript(String id) {
+ return script(id, OsType.MAC_OS);
+ }
+
+ private static ScriptResponse script(String id, OsType supported) {
return ScriptResponse.builder()
.id(id)
.privilegeLevel(PrivilegeLevel.ADMIN)
.defaultTimeoutSeconds(600)
+ .supportedPlatforms(List.of(supported))
.build();
}
+
+ private static Machine machine(String machineId, OsType osType) {
+ Machine m = new Machine();
+ m.setMachineId(machineId);
+ m.setOsType(osType);
+ return m;
+ }
}
diff --git a/openframe-api-service-core/src/test/java/com/openframe/api/service/rmm/software/SoftwareScheduleServiceTest.java b/openframe-api-service-core/src/test/java/com/openframe/api/service/rmm/software/SoftwareScheduleServiceTest.java
index 5f690ddfdf..0b808efcf9 100644
--- a/openframe-api-service-core/src/test/java/com/openframe/api/service/rmm/software/SoftwareScheduleServiceTest.java
+++ b/openframe-api-service-core/src/test/java/com/openframe/api/service/rmm/software/SoftwareScheduleServiceTest.java
@@ -10,6 +10,8 @@
import com.openframe.core.exception.NotFoundException;
import com.openframe.data.document.packagesearch.BrewPackageType;
import com.openframe.data.document.packagesearch.PackageManagerType;
+import com.openframe.data.document.rmm.schedule.ScheduleDeviceCriteria;
+import com.openframe.data.document.rmm.schedule.ScheduleDeviceSelectionMode;
import com.openframe.data.document.rmm.schedule.ScheduleTimeReference;
import com.openframe.data.document.rmm.schedule.SoftwareSchedule;
import com.openframe.data.document.rmm.schedule.SoftwareScheduleMachineAssigned;
@@ -218,6 +220,33 @@ void getDeletedThrows() {
assertThatThrownBy(() -> service.get(SCHEDULE_ID)).isInstanceOf(NotFoundException.class);
}
+ @Test
+ @DisplayName("setDeviceCriteria: switches the schedule to CRITERIA and stores the rule")
+ void setDeviceCriteria_switchesToCriteria() {
+ SoftwareSchedule existing = existingActive();
+ when(scheduleRepository.findByTenantIdAndId(TENANT_ID, SCHEDULE_ID)).thenReturn(Optional.of(existing));
+ ScheduleDeviceCriteria criteria = ScheduleDeviceCriteria.builder()
+ .organizationIds(List.of("org-1")).build();
+
+ service.setDeviceCriteria(SCHEDULE_ID, criteria, ACTOR);
+
+ ArgumentCaptor saved = ArgumentCaptor.forClass(SoftwareSchedule.class);
+ verify(scheduleRepository).save(saved.capture());
+ assertThat(saved.getValue().getSelectionMode()).isEqualTo(ScheduleDeviceSelectionMode.CRITERIA);
+ assertThat(saved.getValue().getDeviceCriteria()).isEqualTo(criteria);
+ }
+
+ @Test
+ @DisplayName("getMachineIds: delegates to the target resolver (CRITERIA resolved live)")
+ void getMachineIds_delegatesToResolver() {
+ SoftwareSchedule existing = existingActive();
+ existing.setSelectionMode(ScheduleDeviceSelectionMode.CRITERIA);
+ when(scheduleRepository.findByTenantIdAndId(TENANT_ID, SCHEDULE_ID)).thenReturn(Optional.of(existing));
+ when(targetResolver.resolveMachineIds(existing)).thenReturn(List.of("m-7", "m-8"));
+
+ assertThat(service.getMachineIds(SCHEDULE_ID)).containsExactly("m-7", "m-8");
+ }
+
private static SoftwareSchedule existingActive() {
return SoftwareSchedule.builder()
.id(SCHEDULE_ID).tenantId(TENANT_ID).name("Nightly Slack")
diff --git a/openframe-api-service-core/src/test/java/com/openframe/data/service/rmm/ScheduleCriteriaDeviceResolverTest.java b/openframe-api-service-core/src/test/java/com/openframe/data/service/rmm/ScheduleCriteriaDeviceResolverTest.java
new file mode 100644
index 0000000000..238555ba25
--- /dev/null
+++ b/openframe-api-service-core/src/test/java/com/openframe/data/service/rmm/ScheduleCriteriaDeviceResolverTest.java
@@ -0,0 +1,93 @@
+package com.openframe.data.service.rmm;
+
+import com.openframe.data.document.device.DeviceType;
+import com.openframe.data.document.device.Machine;
+import com.openframe.data.document.device.filter.MachineQueryFilter;
+import com.openframe.data.document.rmm.schedule.ScheduleDeviceCriteria;
+import com.openframe.data.document.rmm.script.OsType;
+import com.openframe.data.repository.device.MachineRepository;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.List;
+
+import static com.openframe.data.document.rmm.script.OsType.MAC_OS;
+import static com.openframe.data.document.rmm.script.OsType.WINDOWS;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class ScheduleCriteriaDeviceResolverTest {
+
+ private static final String TENANT = "tenant-1";
+
+ @Mock private MachineRepository machineRepository;
+ @InjectMocks private ScheduleCriteriaDeviceResolver resolver;
+
+ @Test
+ @DisplayName("resolveMachineIds: criteria OS ∩ supportedPlatforms is passed as the scope; org/type land on the filter")
+ void resolve_intersectsOsScopeAndBuildsFilter() {
+ ScheduleDeviceCriteria criteria = ScheduleDeviceCriteria.builder()
+ .organizationIds(List.of("org-1"))
+ .deviceTypes(List.of(DeviceType.DESKTOP))
+ .osTypes(List.of(WINDOWS, MAC_OS))
+ .build();
+ when(machineRepository.findMachineIdsByCriteria(eq(TENANT), any(), eq(List.of(WINDOWS))))
+ .thenReturn(List.of("m-1", "m-2"));
+
+ List result = resolver.resolveMachineIds(TENANT, criteria, List.of(WINDOWS));
+
+ assertThat(result).containsExactly("m-1", "m-2");
+ ArgumentCaptor filter = ArgumentCaptor.forClass(MachineQueryFilter.class);
+ verify(machineRepository).findMachineIdsByCriteria(eq(TENANT), filter.capture(), eq(List.of(WINDOWS)));
+ assertThat(filter.getValue().getOrganizationIds()).containsExactly("org-1");
+ assertThat(filter.getValue().getDeviceTypes()).containsExactly(DeviceType.DESKTOP.name());
+ }
+
+ @Test
+ @DisplayName("resolveMachineIds: a contradictory OS scope (criteria OS disjoint from supported) matches nothing — no query")
+ void resolve_contradictoryScope_shortCircuits() {
+ ScheduleDeviceCriteria criteria = ScheduleDeviceCriteria.builder().osTypes(List.of(WINDOWS)).build();
+
+ assertThat(resolver.resolveMachineIds(TENANT, criteria, List.of(MAC_OS))).isEmpty();
+ verify(machineRepository, never()).findMachineIdsByCriteria(any(), any(), any());
+ }
+
+ @Test
+ @DisplayName("resolveMachineIds: no OS anywhere → unconstrained scope (null) passed through to the repository")
+ void resolve_unconstrained_passesNullScope() {
+ ScheduleDeviceCriteria criteria = ScheduleDeviceCriteria.builder().organizationIds(List.of("org-1")).build();
+ when(machineRepository.findMachineIdsByCriteria(eq(TENANT), any(), eq(null))).thenReturn(List.of("m-9"));
+
+ assertThat(resolver.resolveMachineIds(TENANT, criteria, null)).containsExactly("m-9");
+ }
+
+ @Test
+ @DisplayName("matches: device satisfies every constrained dimension; a wrong OS or org fails it")
+ void matches_perDimension() {
+ ScheduleDeviceCriteria criteria = ScheduleDeviceCriteria.builder()
+ .organizationIds(List.of("org-1")).osTypes(List.of(WINDOWS)).build();
+
+ assertThat(resolver.matches(machine("org-1", WINDOWS), criteria, null)).isTrue();
+ assertThat(resolver.matches(machine("org-1", MAC_OS), criteria, null)).isFalse(); // wrong OS
+ assertThat(resolver.matches(machine("org-2", WINDOWS), criteria, null)).isFalse(); // wrong org
+ assertThat(resolver.matches(null, criteria, null)).isFalse();
+ }
+
+ private static Machine machine(String orgId, OsType osType) {
+ Machine m = new Machine();
+ m.setMachineId("m");
+ m.setOrganizationId(orgId);
+ m.setOsType(osType);
+ return m;
+ }
+}
diff --git a/openframe-client-core/src/main/java/com/openframe/client/scheduler/SoftwareBundleOnlineDispatchScheduler.java b/openframe-client-core/src/main/java/com/openframe/client/scheduler/SoftwareBundleOnlineDispatchScheduler.java
new file mode 100644
index 0000000000..7106eab07e
--- /dev/null
+++ b/openframe-client-core/src/main/java/com/openframe/client/scheduler/SoftwareBundleOnlineDispatchScheduler.java
@@ -0,0 +1,31 @@
+package com.openframe.client.scheduler;
+
+import com.openframe.client.service.rmm.SoftwareBundleOnlineDispatchService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import net.javacrumbs.shedlock.spring.annotation.SchedulerLock;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+@Component
+@ConditionalOnProperty(name = "openframe.rmm.software.enabled", havingValue = "true")
+@RequiredArgsConstructor
+@Slf4j
+public class SoftwareBundleOnlineDispatchScheduler {
+
+ private final SoftwareBundleOnlineDispatchService dispatchService;
+
+ @Scheduled(fixedDelayString = "${openframe.rmm.software.bundle.online-dispatch.interval:60000}")
+ @SchedulerLock(name = "softwareBundleOnlineDispatch",
+ lockAtMostFor = "${openframe.rmm.software.bundle.online-dispatch.lock-at-most-for:2m}",
+ lockAtLeastFor = "${openframe.rmm.software.bundle.online-dispatch.lock-at-least-for:10s}"
+ )
+ public void run() {
+ try {
+ dispatchService.processDevicesBecameOnline();
+ } catch (Exception e) {
+ log.error("Software bundle online-dispatch sweep failed", e);
+ }
+ }
+}
diff --git a/openframe-client-core/src/main/java/com/openframe/client/scheduler/SoftwareScheduleOnlineDispatchScheduler.java b/openframe-client-core/src/main/java/com/openframe/client/scheduler/SoftwareScheduleOnlineDispatchScheduler.java
new file mode 100644
index 0000000000..e07057b401
--- /dev/null
+++ b/openframe-client-core/src/main/java/com/openframe/client/scheduler/SoftwareScheduleOnlineDispatchScheduler.java
@@ -0,0 +1,31 @@
+package com.openframe.client.scheduler;
+
+import com.openframe.client.service.rmm.SoftwareScheduleOnlineDispatchService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import net.javacrumbs.shedlock.spring.annotation.SchedulerLock;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+@Component
+@ConditionalOnProperty(name = "openframe.rmm.software.enabled", havingValue = "true")
+@RequiredArgsConstructor
+@Slf4j
+public class SoftwareScheduleOnlineDispatchScheduler {
+
+ private final SoftwareScheduleOnlineDispatchService dispatchService;
+
+ @Scheduled(fixedDelayString = "${openframe.rmm.software.schedule.online-dispatch.interval:60000}")
+ @SchedulerLock(name = "softwareScheduleOnlineDispatch",
+ lockAtMostFor = "${openframe.rmm.software.schedule.online-dispatch.lock-at-most-for:2m}",
+ lockAtLeastFor = "${openframe.rmm.software.schedule.online-dispatch.lock-at-least-for:10s}"
+ )
+ public void run() {
+ try {
+ dispatchService.processReconnectedDevices();
+ } catch (Exception e) {
+ log.error("Software schedule reconnect sweep failed", e);
+ }
+ }
+}
diff --git a/openframe-client-core/src/main/java/com/openframe/client/service/rmm/SoftwareBundleOnlineDispatchService.java b/openframe-client-core/src/main/java/com/openframe/client/service/rmm/SoftwareBundleOnlineDispatchService.java
new file mode 100644
index 0000000000..3a319043d7
--- /dev/null
+++ b/openframe-client-core/src/main/java/com/openframe/client/service/rmm/SoftwareBundleOnlineDispatchService.java
@@ -0,0 +1,108 @@
+package com.openframe.client.service.rmm;
+
+import com.openframe.data.document.device.DeviceStatus;
+import com.openframe.data.document.device.Machine;
+import com.openframe.data.document.rmm.schedule.DeviceOnlineDispatchStatus;
+import com.openframe.data.document.rmm.software.SoftwareBundle;
+import com.openframe.data.document.rmm.software.SoftwareBundleOnlineDispatch;
+import com.openframe.data.repository.device.MachineRepository;
+import com.openframe.data.repository.rmm.SoftwareBundleOnlineDispatchRepository;
+import com.openframe.data.repository.rmm.SoftwareBundleRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Sort;
+import org.springframework.stereotype.Service;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import static java.util.stream.Collectors.groupingBy;
+import static java.util.stream.Collectors.toMap;
+import static java.util.stream.Collectors.toSet;
+
+@Service
+@ConditionalOnProperty(name = "openframe.rmm.software.enabled", havingValue = "true")
+@RequiredArgsConstructor
+@Slf4j
+public class SoftwareBundleOnlineDispatchService {
+
+ private static final String FIELD_FIRST_SEEN_AT = "firstSeenAt";
+
+ private final SoftwareBundleOnlineDispatchRepository dispatchRepository;
+ private final MachineRepository machineRepository;
+ private final SoftwareBundleRepository bundleRepository;
+ private final SoftwareBundleOnlineDispatcher bundleDispatcher;
+
+ @Value("${openframe.rmm.software.bundle.online-dispatch.batch-size:500}")
+ private int batchSize;
+
+ public void processDevicesBecameOnline() {
+ List batch = dispatchRepository.findByStatus(
+ DeviceOnlineDispatchStatus.NEW, PageRequest.of(0, batchSize, Sort.by(Sort.Direction.ASC, FIELD_FIRST_SEEN_AT)));
+ if (batch.isEmpty()) {
+ return;
+ }
+ log.info("Software bundle online-dispatch tick: processing up to {} pending row(s) (oldest first)", batch.size());
+
+ Instant now = Instant.now();
+ Map> rowsByTenant =
+ batch.stream().collect(groupingBy(SoftwareBundleOnlineDispatch::getTenantId));
+
+ List changed = new ArrayList<>();
+ for (Map.Entry> e : rowsByTenant.entrySet()) {
+ changed.addAll(processTenant(e.getKey(), e.getValue(), now));
+ }
+
+ if (!changed.isEmpty()) {
+ dispatchRepository.saveAll(changed);
+ }
+ }
+
+ private List processTenant(String tenantId,
+ List tenantRows, Instant now) {
+ Set machineIds = tenantRows.stream()
+ .map(SoftwareBundleOnlineDispatch::getMachineId).collect(toSet());
+ Map machinesById = machineRepository
+ .findByTenantIdAndMachineIdIn(tenantId, machineIds).stream()
+ .collect(toMap(Machine::getMachineId, m -> m));
+
+ Set bundleIds = tenantRows.stream()
+ .map(SoftwareBundleOnlineDispatch::getBundleId).collect(toSet());
+ Map bundlesById = bundleRepository
+ .findByTenantIdAndIdIn(tenantId, bundleIds).stream()
+ .collect(toMap(SoftwareBundle::getId, b -> b));
+
+ List changed = new ArrayList<>(tenantRows.size());
+ for (SoftwareBundleOnlineDispatch row : tenantRows) {
+ try {
+ Machine machine = machinesById.get(row.getMachineId());
+ if (machine == null || machine.getStatus() != DeviceStatus.ONLINE) {
+ continue;
+ }
+
+ SoftwareBundle bundle = bundlesById.get(row.getBundleId());
+ if (bundle != null) {
+ bundleDispatcher.dispatch(bundle, machine);
+ log.info("Software bundle online-dispatched: machineId={} bundleId={} tenantId={}",
+ row.getMachineId(), row.getBundleId(), tenantId);
+ } else {
+ log.warn("Software bundle online-dispatch: bundle missing bundleId={} machineId={} tenantId={} — draining",
+ row.getBundleId(), row.getMachineId(), tenantId);
+ }
+ row.setStatus(DeviceOnlineDispatchStatus.DISPATCHED);
+ row.setDispatchedAt(now);
+ changed.add(row);
+ } catch (Exception ex) {
+ log.error("Software bundle online-dispatch failed: tenantId={} machineId={} bundleId={} (will retry next tick)",
+ row.getTenantId(), row.getMachineId(), row.getBundleId(), ex);
+ }
+ }
+ return changed;
+ }
+}
diff --git a/openframe-client-core/src/main/java/com/openframe/client/service/rmm/SoftwareBundleOnlineDispatcher.java b/openframe-client-core/src/main/java/com/openframe/client/service/rmm/SoftwareBundleOnlineDispatcher.java
new file mode 100644
index 0000000000..416c34d9cb
--- /dev/null
+++ b/openframe-client-core/src/main/java/com/openframe/client/service/rmm/SoftwareBundleOnlineDispatcher.java
@@ -0,0 +1,106 @@
+package com.openframe.client.service.rmm;
+
+import com.openframe.data.document.device.Machine;
+import com.openframe.data.document.rmm.script.DeliveryChannel;
+import com.openframe.data.document.rmm.script.ExecutionSource;
+import com.openframe.data.document.rmm.script.OsType;
+import com.openframe.data.document.rmm.script.RunningExecutionRows;
+import com.openframe.data.document.rmm.script.Script;
+import com.openframe.data.document.rmm.script.ScriptType;
+import com.openframe.data.document.rmm.software.SoftwareBundle;
+import com.openframe.data.document.rmm.software.SoftwareBundlePackage;
+import com.openframe.data.document.rmm.software.SoftwareExecutionId;
+import com.openframe.data.document.rmm.software.SoftwareScriptCode;
+import com.openframe.data.nats.rmm.model.ScriptMessage;
+import com.openframe.data.nats.rmm.publisher.SoftwareNatsPublisher;
+import com.openframe.data.repository.rmm.ScriptExecutionRepository;
+import com.openframe.data.repository.rmm.ScriptRepository;
+import com.openframe.data.service.rmm.software.PackageManagerHandler;
+import com.openframe.data.service.rmm.software.PackageManagerRegistry;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+import java.util.Optional;
+
+@Component
+@ConditionalOnProperty(name = "openframe.rmm.software.enabled", havingValue = "true")
+@RequiredArgsConstructor
+@Slf4j
+public class SoftwareBundleOnlineDispatcher {
+
+ private final ScriptRepository scriptRepository;
+ private final PackageManagerRegistry packageManagerRegistry;
+ private final ScriptExecutionRepository scriptExecutionRepository;
+ private final SoftwareNatsPublisher softwareNatsPublisher;
+ private final ScriptDeliveryRetryStore retryStore;
+
+ public void dispatch(SoftwareBundle bundle, Machine machine) {
+ if (bundle.getPackages() == null || bundle.getPackages().isEmpty()) {
+ return;
+ }
+ for (SoftwareBundlePackage pkg : bundle.getPackages()) {
+ dispatchPackage(bundle, pkg, machine);
+ }
+ }
+
+ private void dispatchPackage(SoftwareBundle bundle, SoftwareBundlePackage pkg, Machine machine) {
+ String machineId = machine.getMachineId();
+ PackageManagerHandler handler = packageManagerRegistry.handlerFor(pkg.getPackageManager());
+ SoftwareScriptCode code = handler.scriptCode(bundle.getAction());
+
+ Optional