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..2c33849fa5
--- /dev/null
+++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareActionId.java
@@ -0,0 +1,41 @@
+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);
+ }
+
+ 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) {
+ }
+ 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/SoftwareBundleScheduleInput.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareBundleScheduleInput.java
new file mode 100644
index 0000000000..df6816ec2b
--- /dev/null
+++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareBundleScheduleInput.java
@@ -0,0 +1,23 @@
+package com.openframe.api.dto.rmm.software;
+
+import com.openframe.data.document.rmm.schedule.ScheduleOfflineBehavior;
+import com.openframe.data.document.rmm.schedule.ScheduleTimeReference;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+
+import java.time.Instant;
+
+@Data
+public class SoftwareBundleScheduleInput {
+
+ private String name;
+ private String description;
+ private ScheduleTimeReference timeReference;
+ private ScheduleOfflineBehavior offlineBehavior;
+ private Long reconnectWindowSeconds;
+
+ @NotNull(message = "startAt is required")
+ private Instant startAt;
+
+ private Long repeat;
+}
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/SoftwarePackageInput.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwarePackageInput.java
index 9238002edc..f6fcfdf4a7 100644
--- a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwarePackageInput.java
+++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwarePackageInput.java
@@ -15,6 +15,6 @@ public class SoftwarePackageInput {
@NotBlank
private String packageName;
- /** Brew-only sub-type (CASK/FORMULA). Required for brew (enforced by its handler), ignored by managers that do not distinguish. */
private BrewPackageType brewPackageType;
+ private String version;
}
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..e3dd9b059a
--- /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 discoveredAt;
+}
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/SubmitSoftwareBundleInput.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SubmitSoftwareBundleInput.java
new file mode 100644
index 0000000000..3dd1b3d581
--- /dev/null
+++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SubmitSoftwareBundleInput.java
@@ -0,0 +1,28 @@
+package com.openframe.api.dto.rmm.software;
+
+import com.openframe.data.document.rmm.software.SoftwareAction;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotEmpty;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+public class SubmitSoftwareBundleInput {
+
+ @NotNull
+ private String id;
+
+ @NotNull
+ private SoftwareAction action;
+
+ @NotEmpty(message = "packages must not be empty")
+ @Size(max = 50, message = "packages must not exceed 50")
+ @Valid
+ private List packages;
+
+ @Valid
+ private SoftwareBundleScheduleInput schedule;
+}
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/VulnerabilityFilters.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/vulnerability/VulnerabilityFilters.java
new file mode 100644
index 0000000000..d1472c26da
--- /dev/null
+++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/vulnerability/VulnerabilityFilters.java
@@ -0,0 +1,14 @@
+package com.openframe.api.dto.rmm.vulnerability;
+
+import com.openframe.api.dto.rmm.software.SoftwareFilterOption;
+import lombok.Builder;
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+@Builder
+public class VulnerabilityFilters {
+
+ private List severities;
+}
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..dab9a4f86d
--- /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 discoveredAt;
+
+ 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/relay/NodeType.java b/openframe-api-lib/src/main/java/com/openframe/api/relay/NodeType.java
index ddc8cc3bef..b0d8d41697 100644
--- a/openframe-api-lib/src/main/java/com/openframe/api/relay/NodeType.java
+++ b/openframe-api-lib/src/main/java/com/openframe/api/relay/NodeType.java
@@ -15,7 +15,8 @@ public enum NodeType {
SCRIPT("Script"),
SCRIPT_EXECUTION("ScriptExecution"),
SCRIPT_SCHEDULE("ScriptSchedule"),
- SCHEDULE_RUN("ScheduleRun");
+ SCHEDULE_RUN("ScheduleRun"),
+ SOFTWARE_BUNDLE("SoftwareBundle");
private final String graphqlTypeName;
diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/FleetVulnerabilityStatusService.java b/openframe-api-lib/src/main/java/com/openframe/api/service/FleetVulnerabilityStatusService.java
index 4b9285c328..ca25e97360 100644
--- a/openframe-api-lib/src/main/java/com/openframe/api/service/FleetVulnerabilityStatusService.java
+++ b/openframe-api-lib/src/main/java/com/openframe/api/service/FleetVulnerabilityStatusService.java
@@ -4,6 +4,7 @@
import com.github.benmanes.caffeine.cache.Caffeine;
import com.openframe.api.config.FleetVulnerabilityStatusProperties;
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;
@@ -33,7 +34,6 @@
@Slf4j
public class FleetVulnerabilityStatusService {
- private static final String FLEETMDM_SERVER_KEY = "fleetmdm-server";
private static final String CACHE_KEY = "lastCompletedVulnerabilityRunAt";
private static final String PORT_SEPARATOR = ":";
private static final int FIRST_PAGE = 0;
@@ -69,7 +69,7 @@ public Instant getLastCompletedVulnerabilityRunAt() {
private Optional loadLastCompletedRunAt() {
try {
- return integratedToolRepository.findByKey(FLEETMDM_SERVER_KEY)
+ return integratedToolRepository.findByKey(IntegratedToolId.FLEET_SERVER_ID.getValue())
.map(this::fetchWithinTimeout)
.orElseGet(this::missingTool);
} catch (Exception e) {
@@ -79,7 +79,7 @@ private Optional loadLastCompletedRunAt() {
}
private Optional missingTool() {
- log.debug("Fleet MDM tool not found: {}", FLEETMDM_SERVER_KEY);
+ log.debug("Fleet MDM tool not found: {}", IntegratedToolId.FLEET_SERVER_ID.getValue());
return Optional.empty();
}
@@ -129,7 +129,7 @@ private Optional parseCountsUpdatedAt(VulnerabilitiesResponse response)
private String resolveApiUrl(IntegratedTool tool) {
List toolUrls = tool.getToolUrls();
if (isEmpty(toolUrls)) {
- log.warn("Fleet MDM tool has no URLs configured: {}", FLEETMDM_SERVER_KEY);
+ log.warn("Fleet MDM tool has no URLs configured: {}", IntegratedToolId.FLEET_SERVER_ID.getValue());
return null;
}
return toolUrls.stream()
@@ -155,17 +155,17 @@ private String buildBaseUrl(ToolUrl toolUrl) {
private String resolveApiToken(IntegratedTool tool) {
ToolCredentials credentials = tool.getCredentials();
if (credentials == null) {
- log.warn("Fleet MDM tool has no credentials yet: {}", FLEETMDM_SERVER_KEY);
+ log.warn("Fleet MDM tool has no credentials yet: {}", IntegratedToolId.FLEET_SERVER_ID.getValue());
return null;
}
ToolApiKey apiKey = credentials.getApiKey();
if (apiKey == null) {
- log.warn("Fleet API token is not provisioned yet: {}", FLEETMDM_SERVER_KEY);
+ log.warn("Fleet API token is not provisioned yet: {}", IntegratedToolId.FLEET_SERVER_ID.getValue());
return null;
}
String key = apiKey.getKey();
if (!hasText(key)) {
- log.warn("Fleet API token is empty: {}", FLEETMDM_SERVER_KEY);
+ log.warn("Fleet API token is empty: {}", IntegratedToolId.FLEET_SERVER_ID.getValue());
return null;
}
return key;
diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/device/DeviceService.java b/openframe-api-lib/src/main/java/com/openframe/api/service/device/DeviceService.java
index 96a4142291..cfbb3bbc85 100644
--- a/openframe-api-lib/src/main/java/com/openframe/api/service/device/DeviceService.java
+++ b/openframe-api-lib/src/main/java/com/openframe/api/service/device/DeviceService.java
@@ -97,11 +97,6 @@ public CountedGenericQueryResult queryDevices(DeviceFilterCriteria filt
return paginate(machineFilter(filterOptions, null, null), search, paginationCriteria, sort);
}
- /**
- * Same as {@link #queryDevices} but restricted to a fixed set of machineIds (e.g. the
- * devices assigned to a script schedule). A {@code null}/empty set yields an empty page;
- * the restriction is intersected with any tag filter.
- */
public CountedGenericQueryResult queryAssignedDevices(Collection machineIds,
DeviceFilterCriteria filterOptions,
CursorPaginationCriteria paginationCriteria,
@@ -170,20 +165,12 @@ private DeviceFilters deviceFilters(MachineQueryFilter filter, String search) {
.build();
}
- /**
- * All machineIds of devices matching {@code filter}/{@code search} within the given platforms —
- * backs "Add all devices" for a schedule (resolve the whole filtered set at once, unpaginated).
- */
public List findDeviceIdsForPlatforms(Collection platformNames,
DeviceFilterCriteria filterOptions, String search) {
return machineRepository.findMachineIds(tenantIdProvider.getTenantId(),
scheduleDeviceFilter(filterOptions, platformNames, null), search);
}
- /**
- * Of the given machineIds, those matching {@code filter}/{@code search} — backs "Remove all
- * devices" (the assigned set narrowed by the Selected-tab filter). Empty/null in → empty out.
- */
public List findAssignedDeviceIds(Collection machineIds,
DeviceFilterCriteria filterOptions, String search) {
if (machineIds == null || machineIds.isEmpty()) {
@@ -196,6 +183,11 @@ public List findAssignedDeviceIds(Collection machineIds,
scheduleDeviceFilter(filterOptions, null, machineIds), search);
}
+ public List findAllDeviceIds(DeviceFilterCriteria filterOptions, String search) {
+ return machineRepository.findMachineIds(tenantIdProvider.getTenantId(),
+ scheduleDeviceFilter(filterOptions, null, null), search);
+ }
+
private CountedGenericQueryResult paginate(MachineQueryFilter filter, String search,
CursorPaginationCriteria paginationCriteria,
SortInput sort) {
@@ -233,10 +225,8 @@ private PageInfo buildPageInfo(List pageItems, boolean hasNextPage, boo
.build();
}
- /** Statuses a device may have to be offered to / kept on a script schedule: only live agents. */
private static final Set SCHEDULE_ALLOWED_STATUSES = EnumSet.of(DeviceStatus.ONLINE, DeviceStatus.OFFLINE);
- /** Every status other than {@link #SCHEDULE_ALLOWED_STATUSES}, applied as a {@code $nin} on top of any user filter. */
private static final List SCHEDULE_EXCLUDED_STATUSES = Arrays.stream(DeviceStatus.values())
.filter(status -> !SCHEDULE_ALLOWED_STATUSES.contains(status))
.map(Enum::name)
@@ -275,11 +265,7 @@ private static List resolveOsTypes(Collection filterOsTypes, Col
return osTypeScope != null && !osTypeScope.isEmpty() ? new ArrayList<>(osTypeScope) : null;
}
- /**
- * Combine the tag-filter machineId restriction with an explicit caller restriction into a
- * single {@code $in} set. {@code null} on a side means "no restriction from that side";
- * when both are present the result is their intersection (possibly empty).
- */
+ // null on a side = no restriction from that side; both present = intersection (possibly empty).
private static Collection intersectMachineIds(List tagMachineIds, Collection restrict) {
if (tagMachineIds == null && restrict == null) {
return null;
@@ -294,11 +280,7 @@ private static Collection intersectMachineIds(List tagMachineIds
return tagMachineIds.stream().filter(restrictSet::contains).collect(Collectors.toList());
}
- /**
- * Resolves tag-based filters (tagKeys, tagValues) to a set of machineIds.
- * Returns null if no tag filters are applied, meaning no restriction needed.
- * Returns an empty list if tag filters are applied but no machines match.
- */
+ // null = no tag filter applied (no restriction); empty list = filter applied but nothing matched.
private List resolveTagFilterToMachineIds(DeviceFilterCriteria filter) {
boolean hasTagKeys = filter.getTagKeys() != null && !filter.getTagKeys().isEmpty();
boolean hasTagValues = filter.getTagValues() != null && !filter.getTagValues().isEmpty();
diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/fleet/FleetDeviceCountEnricher.java b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/fleet/FleetDeviceCountEnricher.java
new file mode 100644
index 0000000000..ed45784c6a
--- /dev/null
+++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/fleet/FleetDeviceCountEnricher.java
@@ -0,0 +1,61 @@
+package com.openframe.api.service.rmm.fleet;
+
+import com.openframe.data.document.device.Machine;
+import com.openframe.data.service.TenantIdProvider;
+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.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.BiConsumer;
+import java.util.function.Function;
+import java.util.stream.IntStream;
+
+@Component
+@ConditionalOnProperty(name = "openframe.rmm.software.enabled", havingValue = "true")
+@RequiredArgsConstructor
+public class FleetDeviceCountEnricher {
+
+ private final FleetHostMachineResolver hostMachineResolver;
+ private final TenantIdProvider tenantIdProvider;
+
+ public void enrich(List rows,
+ Function> hostFetcher,
+ BiConsumer countSetter) {
+ if (rows == null || rows.isEmpty()) {
+ return;
+ }
+ Map> hostsByRow = fanOut(rows, hostFetcher);
+ Map correlated = batchCorrelate(hostsByRow);
+ assignCounts(rows, hostsByRow, correlated, countSetter);
+ }
+
+ private static Map> fanOut(List rows, Function> hostFetcher) {
+ Map> byIndex = new ConcurrentHashMap<>(rows.size());
+ IntStream.range(0, rows.size()).parallel().forEach(i ->
+ byIndex.put(i, hostFetcher.apply(rows.get(i))));
+ return byIndex;
+ }
+
+ private Map batchCorrelate(Map> hostsByRow) {
+ List allHosts = hostsByRow.values().stream()
+ .flatMap(List::stream)
+ .toList();
+ return hostMachineResolver.resolve(tenantIdProvider.getTenantId(), allHosts);
+ }
+
+ private static void assignCounts(List rows,
+ Map> hostsByRow,
+ Map correlated,
+ BiConsumer countSetter) {
+ for (int i = 0; i < rows.size(); i++) {
+ int count = (int) hostsByRow.getOrDefault(i, List.of()).stream()
+ .filter(h -> h.getId() != null && correlated.containsKey(h.getId()))
+ .count();
+ countSetter.accept(rows.get(i), count);
+ }
+ }
+}
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/SoftwareActionDetailService.java b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareActionDetailService.java
new file mode 100644
index 0000000000..8f89db450b
--- /dev/null
+++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareActionDetailService.java
@@ -0,0 +1,191 @@
+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;
+
+@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-load hostname and customer 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..4a922ebaee
--- /dev/null
+++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareBundleService.java
@@ -0,0 +1,362 @@
+package com.openframe.api.service.rmm.software;
+
+import com.openframe.api.dto.rmm.software.CreateSoftwareScheduleInput;
+import com.openframe.api.dto.rmm.software.SoftwareBundleResponse;
+import com.openframe.api.dto.rmm.software.SoftwareBundleScheduleInput;
+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.SubmitSoftwareBundleInput;
+import com.openframe.core.exception.BadRequestException;
+import com.openframe.core.exception.NotFoundException;
+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.script.OsType;
+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.SoftwareActionResult;
+import com.openframe.data.document.rmm.software.SoftwareActionStatus;
+import com.openframe.data.document.rmm.software.SoftwareBundlePackage;
+import com.openframe.data.document.rmm.software.SoftwareBundleStatus;
+import com.openframe.data.document.rmm.software.SoftwareExecutionId;
+import com.openframe.data.repository.rmm.SoftwareActionResultRepository;
+import com.openframe.data.repository.rmm.SoftwareBundleOnlineDispatchRepository;
+import com.openframe.data.repository.rmm.SoftwareBundleRepository;
+import com.openframe.data.service.TenantIdProvider;
+import com.openframe.data.service.rmm.MachinePlatformResolver;
+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.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+@Slf4j
+@Service
+@ConditionalOnProperty(name = "openframe.rmm.software.enabled", havingValue = "true")
+@RequiredArgsConstructor
+public class SoftwareBundleService {
+
+ private static final int MAX_PACKAGES = 50;
+
+ private final SoftwareBundleRepository bundleRepository;
+ private final SoftwareBundleOnlineDispatchRepository onlineDispatchRepository;
+ private final SoftwareScheduleService softwareScheduleService;
+ private final SoftwareActionResultRepository softwareActionResultRepository;
+ private final MachinePlatformResolver machinePlatformResolver;
+ 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 boolean delete(String id, String actor) {
+ Optional found = bundleRepository.findByTenantIdAndId(tenantIdProvider.getTenantId(), id);
+ if (found.isEmpty()) {
+ return true;
+ }
+ SoftwareBundle entity = found.get();
+ if (entity.getStatus() == SoftwareBundleStatus.COMPLETED) {
+ return false;
+ }
+ 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 SoftwareBundleResponse createBundleStub(String createdBy) {
+ String tenantId = tenantIdProvider.getTenantId();
+ Instant now = Instant.now();
+ SoftwareBundle entity = SoftwareBundle.builder()
+ .tenantId(tenantId)
+ .status(SoftwareBundleStatus.PENDING)
+ .machineIds(List.of())
+ .packages(List.of())
+ .createdBy(createdBy)
+ .createdAt(now)
+ .updatedAt(now)
+ .expireAt(now.plus(pendingTtl))
+ .build();
+ SoftwareBundle saved = bundleRepository.save(entity);
+ log.info("Created draft software bundle id={} tenantId={}", saved.getId(), tenantId);
+ return toResponse(saved);
+ }
+
+ public SoftwareBundleResponse addDevices(String bundleId, List machineIds, String actor) {
+ SoftwareBundle entity = loadPendingOrThrow(bundleId);
+ LinkedHashSet set = new LinkedHashSet<>(currentMachineIds(entity));
+ set.addAll(machineIds);
+ entity.setMachineIds(List.copyOf(set));
+ return saveTouched(entity, actor);
+ }
+
+ public SoftwareBundleResponse removeDevices(String bundleId, List machineIds, String actor) {
+ SoftwareBundle entity = loadPendingOrThrow(bundleId);
+ LinkedHashSet set = new LinkedHashSet<>(currentMachineIds(entity));
+ machineIds.forEach(set::remove);
+ entity.setMachineIds(List.copyOf(set));
+ return saveTouched(entity, actor);
+ }
+
+ public SoftwareBundleResponse submit(SubmitSoftwareBundleInput input, String actor) {
+ SoftwareBundle entity = loadOrThrow(input.getId());
+ if (entity.getStatus() == SoftwareBundleStatus.COMPLETED) {
+ log.info("submitSoftwareBundle id={} already COMPLETED — returning unchanged", input.getId());
+ return toResponse(entity);
+ }
+ List machineIds = currentMachineIds(entity);
+ if (machineIds.isEmpty()) {
+ throw new BadRequestException("Cannot submit software bundle " + input.getId() + ": no devices assigned");
+ }
+ List packages = toDomainPackages(input.getPackages());
+ validatePackages(packages);
+ Map deviceOsTypes = machinePlatformResolver.osTypesByMachineId(machineIds);
+ rejectDevicesWithoutCompatiblePackage(machineIds, packages, deviceOsTypes);
+
+ entity.setAction(input.getAction());
+ entity.setPackages(packages);
+
+ Instant now = Instant.now();
+ if (input.getSchedule() != null) {
+ entity.setMode(SoftwareBundleMode.SCHEDULED);
+ entity.setStartAt(input.getSchedule().getStartAt());
+ submitScheduled(entity, input.getSchedule(), actor);
+ } else {
+ entity.setMode(SoftwareBundleMode.NOW);
+ armOnlineDispatch(entity, now);
+ entity.setExecutionIds(executionIds(entity));
+ }
+
+ entity.setStatus(SoftwareBundleStatus.COMPLETED);
+ entity.setCompletedAt(now);
+ entity.setUpdatedAt(now);
+ entity.setExpireAt(null); // completed bundles are history — never reaped
+ bundleRepository.save(entity);
+
+ writeActionResults(entity, packages, deviceOsTypes, now, actor);
+
+ log.info("Submitted software bundle id={} mode={} action={} devices={} packages={} actor={}",
+ entity.getId(), entity.getMode(), entity.getAction(), machineIds.size(), packages.size(), actor);
+ return toResponse(entity);
+ }
+
+ private void writeActionResults(SoftwareBundle bundle, List packages,
+ Map deviceOsTypes, Instant now, String actor) {
+ boolean scheduled = bundle.getMode() == SoftwareBundleMode.SCHEDULED;
+ List results = new java.util.ArrayList<>(packages.size());
+ for (SoftwareBundlePackage pkg : packages) {
+ OsType packageOs = osTypeOf(pkg.getPackageManager());
+ List targets = currentMachineIds(bundle).stream()
+ .filter(machineId -> deviceOsTypes.get(machineId) == packageOs)
+ .toList();
+ String executionId = scheduled
+ ? SoftwareExecutionId.forSchedule(bundle.getScheduleId(), pkg.getPackageManager(), pkg.getPackageName())
+ : SoftwareExecutionId.forBundle(bundle.getId(), pkg.getPackageManager(), pkg.getPackageName());
+ results.add(SoftwareActionResult.builder()
+ .id(executionId)
+ .tenantId(bundle.getTenantId())
+ .executionId(executionId)
+ .action(bundle.getAction())
+ .packageManager(pkg.getPackageManager())
+ .packageName(pkg.getPackageName())
+ .version(pkg.getVersion())
+ .status(scheduled ? SoftwareActionStatus.SCHEDULED : SoftwareActionStatus.IN_PROGRESS)
+ .mode(bundle.getMode())
+ .machineIds(targets)
+ .totalMachineCount(targets.size())
+ .bundleId(scheduled ? null : bundle.getId())
+ .scheduleId(scheduled ? bundle.getScheduleId() : null)
+ .scheduledAt(scheduled ? bundle.getStartAt() : null)
+ .dispatchedAt(scheduled ? null : now)
+ .initiatedBy(actor)
+ .createdAt(now)
+ .build());
+ }
+ softwareActionResultRepository.saveAll(results);
+ log.info("Wrote {} software action result(s) for bundle id={}", results.size(), bundle.getId());
+ }
+
+ private static void validatePackages(List packages) {
+ if (packages.isEmpty()) {
+ throw new BadRequestException("Cannot submit software bundle: at least one package is required");
+ }
+ if (packages.size() > MAX_PACKAGES) {
+ throw new BadRequestException("Cannot submit software bundle: at most " + MAX_PACKAGES
+ + " packages (got " + packages.size() + ")");
+ }
+ List brewMissingType = packages.stream()
+ .filter(p -> p.getPackageManager() == PackageManagerType.BREW && p.getBrewPackageType() == null)
+ .map(SoftwareBundlePackage::getPackageName)
+ .toList();
+ if (!brewMissingType.isEmpty()) {
+ throw new BadRequestException("brewPackageType (CASK or FORMULA) is required for brew packages: "
+ + brewMissingType);
+ }
+ }
+
+ private void rejectDevicesWithoutCompatiblePackage(List machineIds, List packages,
+ Map deviceOsTypes) {
+ Set packageOsTypes = packages.stream()
+ .map(p -> osTypeOf(p.getPackageManager()))
+ .collect(Collectors.toSet());
+ List incompatible = machineIds.stream()
+ .filter(machineId -> {
+ OsType os = deviceOsTypes.get(machineId);
+ return os != null && !packageOsTypes.contains(os);
+ })
+ .toList();
+ if (!incompatible.isEmpty()) {
+ throw new BadRequestException("These devices have no package compatible with their OS: " + incompatible
+ + ". Add a matching package or remove the devices.");
+ }
+ }
+
+ private static List executionIds(SoftwareBundle bundle) {
+ return bundle.getPackages().stream()
+ .map(p -> SoftwareExecutionId.forBundle(bundle.getId(), p.getPackageManager(), p.getPackageName()))
+ .toList();
+ }
+
+ private static OsType osTypeOf(PackageManagerType packageManager) {
+ return packageManager == PackageManagerType.BREW ? OsType.MAC_OS : OsType.WINDOWS;
+ }
+
+ private void submitScheduled(SoftwareBundle bundle, SoftwareBundleScheduleInput sched, String actor) {
+ CreateSoftwareScheduleInput input = new CreateSoftwareScheduleInput();
+ input.setName(sched.getName() != null ? sched.getName() : scheduleName(bundle));
+ input.setDescription(sched.getDescription());
+ input.setAction(bundle.getAction());
+ input.setPackages(toSchedulePackages(bundle.getPackages()));
+ input.setTimeReference(sched.getTimeReference() != null ? sched.getTimeReference() : ScheduleTimeReference.SERVER);
+ input.setOfflineBehavior(sched.getOfflineBehavior() != null ? sched.getOfflineBehavior() : ScheduleOfflineBehavior.RETRY_ON_RECONNECT);
+ input.setReconnectWindowSeconds(sched.getReconnectWindowSeconds() != null
+ ? sched.getReconnectWindowSeconds() : scheduleReconnectWindowSeconds);
+ input.setStartAt(sched.getStartAt());
+ input.setRepeat(sched.getRepeat());
+ input.setMachineIds(bundle.getMachineIds());
+
+ SoftwareScheduleResponse schedule = softwareScheduleService.create(input, actor);
+ bundle.setScheduleId(schedule.getId());
+ log.info("Submitted SCHEDULED software bundle id={} → schedule id={} startAt={}",
+ bundle.getId(), schedule.getId(), bundle.getStartAt());
+ }
+
+ private static List currentMachineIds(SoftwareBundle entity) {
+ return entity.getMachineIds() == null ? List.of() : entity.getMachineIds();
+ }
+
+ private SoftwareBundleResponse saveTouched(SoftwareBundle entity, String actor) {
+ Instant now = Instant.now();
+ entity.setUpdatedAt(now);
+ entity.setExpireAt(now.plus(pendingTtl));
+ SoftwareBundle saved = bundleRepository.save(entity);
+ log.debug("Touched software bundle id={} devices={} actor={}",
+ saved.getId(), currentMachineIds(saved).size(), actor);
+ return toResponse(saved);
+ }
+
+ 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())
+ .version(p.getVersion())
+ .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() == null ? List.of() : b.getMachineIds())
+ .packages(b.getPackages() == null ? List.of() : 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..516a2700f7
--- /dev/null
+++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareInventoryService.java
@@ -0,0 +1,411 @@
+package com.openframe.api.service.rmm.software;
+
+import com.openframe.api.dto.rmm.software.SoftwareCveSeverity;
+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.dto.shared.SortDirection;
+import com.openframe.api.dto.shared.SortInput;
+import com.openframe.core.exception.BadRequestException;
+import com.openframe.api.service.rmm.fleet.FleetDeviceCountEnricher;
+import com.openframe.api.util.FleetSoftwareMapper;
+import com.openframe.api.util.FleetVulnerabilityMapper;
+import com.openframe.data.document.device.Machine;
+import com.openframe.data.document.tool.IntegratedTool;
+import com.openframe.data.document.tool.IntegratedToolId;
+import com.openframe.data.repository.tool.IntegratedToolRepository;
+import com.openframe.data.service.TenantIdProvider;
+import com.openframe.sdk.fleetmdm.FleetMdmClient;
+import com.openframe.sdk.fleetmdm.FleetTenantHeader;
+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 jakarta.annotation.PostConstruct;
+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.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 static final int HOSTS_PER_TITLE_LIMIT = 500;
+ private static final Map FLEET_SORT = Map.of(
+ "name", "name",
+ "devicesCount", "hosts_count");
+ private static final Set CLIENT_SORT = Set.of("cveCount", "highestSeverity", "severity");
+ private static final String SORTABLE_FIELDS = "name, devicesCount, cveCount, highestSeverity";
+ private static final int TITLES_FETCH_PAGE = 500;
+ private static final int TITLES_FETCH_CAP = 5000;
+
+ private final IntegratedToolRepository integratedToolRepository;
+ private final FleetDeviceCountEnricher deviceCountEnricher;
+ private final com.openframe.api.service.rmm.fleet.FleetHostMachineResolver hostMachineResolver;
+ private final TenantIdProvider tenantIdProvider;
+
+ @Value("${TENANT_ID:}")
+ private String tenantIdEnv;
+
+ @Value("${openframe.fleet.multi-tenancy.enabled}")
+ private boolean fleetMultiTenancyEnabled;
+
+ private FleetMdmClient fleet;
+
+ @PostConstruct
+ void wireFleetClient() {
+ FleetTenantHeader.validate(fleetMultiTenancyEnabled, tenantIdEnv);
+ String key = IntegratedToolId.FLEET_SERVER_ID.getValue();
+ IntegratedTool tool = integratedToolRepository.findByKey(key)
+ .orElseThrow(() -> new IllegalStateException("Fleet MDM tool not configured: " + key));
+ this.fleet = new FleetMdmClient(tool.apiUrl(), tool.apiToken(), tenantIdEnv);
+ }
+
+ public Optional findById(String softwareId) {
+ return parseNumericId(softwareId)
+ .map(fleet::getSoftwareTitle)
+ .map(FleetSoftwareMapper::toResponse)
+ .map(row -> {
+ enrichRealDevicesCount(List.of(row));
+ return row;
+ });
+ }
+
+ public PageResult listSoftware(String search, int page, Integer perPage,
+ SortInput sort, Boolean vulnerable) {
+ String field = sort == null ? null : sort.getField();
+ boolean desc = sort != null && sort.getDirection() == SortDirection.DESC;
+
+ if (field == null || FLEET_SORT.containsKey(field)) {
+ String orderKey = field == null ? null : FLEET_SORT.get(field);
+ // Fleet rejects order_direction without order_key — send it only when there is a key.
+ String orderDirection = orderKey == null ? null : (desc ? "desc" : "asc");
+ PageResult pageResult = fleetPage(search, page, perPage, orderKey, orderDirection, vulnerable);
+ // Enrich ONLY the visible page (bounded by perPage) to keep Fleet fan-out predictable.
+ enrichRealDevicesCount(pageResult.items());
+ return pageResult;
+ }
+ if (!CLIENT_SORT.contains(field)) {
+ throw new BadRequestException("Unknown sort field '" + field + "'. Sortable fields: " + SORTABLE_FIELDS);
+ }
+ // fetchAllTitles is bounded by TITLES_FETCH_CAP; we sort in memory then enrich only the returned slice.
+ List all = fetchAllTitles(search, vulnerable).stream()
+ .sorted(clientComparator(field, desc))
+ .toList();
+ PageResult pageResult = paginateList(all, page, perPage);
+ enrichRealDevicesCount(pageResult.items());
+ return pageResult;
+ }
+
+ // Replace Fleet's raw hosts_count with the count of correlated OpenFrame Machines.
+ private void enrichRealDevicesCount(List titles) {
+ deviceCountEnricher.enrich(titles,
+ row -> hostsForTitle(row.getId()),
+ SoftwareResponse::setDevicesCount);
+ }
+
+ private List hostsForTitle(String titleIdStr) {
+ return parseNumericId(titleIdStr)
+ .map(titleId -> {
+ HostSearchRequest request = new HostSearchRequest();
+ request.setSoftwareTitleId(titleId);
+ request.setPerPage(HOSTS_PER_TITLE_LIMIT);
+ return fleet.searchHosts(request);
+ })
+ .orElseGet(List::of);
+ }
+
+ private PageResult fleetPage(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.listSoftwareTitles(request);
+ List items = mapTitles(response);
+ 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);
+ }
+
+ private List fetchAllTitles(String search, Boolean vulnerable) {
+ List all = new ArrayList<>();
+ int page = 0;
+ while (all.size() < TITLES_FETCH_CAP) {
+ SoftwareTitleRequest request = SoftwareTitleRequest.builder()
+ .page(page).perPage(TITLES_FETCH_PAGE).query(search)
+ .vulnerable(vulnerable)
+ .build();
+ SoftwareTitlesResponse response = fleet.listSoftwareTitles(request);
+ all.addAll(mapTitles(response));
+ boolean hasNext = response.getMeta() != null
+ && Boolean.TRUE.equals(response.getMeta().getHasNextResults());
+ if (!hasNext || response.getSoftwareTitles() == null || response.getSoftwareTitles().isEmpty()) {
+ break;
+ }
+ page++;
+ }
+ return all;
+ }
+
+ private static List mapTitles(SoftwareTitlesResponse response) {
+ return response.getSoftwareTitles() == null ? List.of()
+ : response.getSoftwareTitles().stream()
+ .map(FleetSoftwareMapper::toResponse)
+ .filter(Objects::nonNull)
+ .toList();
+ }
+
+ private static Comparator clientComparator(String field, boolean desc) {
+ Comparator base = switch (field) {
+ case "cveCount" -> Comparator.comparingInt(SoftwareInventoryService::cveCount);
+ case "highestSeverity", "severity" -> Comparator.comparingInt(SoftwareInventoryService::severityRank);
+ default -> throw new BadRequestException(
+ "Unknown sort field '" + field + "'. Sortable fields: " + SORTABLE_FIELDS);
+ };
+ Comparator directed = desc ? base.reversed() : base;
+ // Stable tiebreaker: name ascending, case-insensitive, regardless of the primary direction.
+ return directed.thenComparing(row -> row.getName() == null ? "" : row.getName(),
+ String.CASE_INSENSITIVE_ORDER);
+ }
+
+ private static int cveCount(SoftwareResponse row) {
+ return row.getVulnerabilitySummary() == null ? 0 : row.getVulnerabilitySummary().getCveCount();
+ }
+
+ private static int severityRank(SoftwareResponse row) {
+ SoftwareCveSeverity s = row.getVulnerabilitySummary() == null
+ ? null : row.getVulnerabilitySummary().getHighestSeverity();
+ return s == null ? 0 : switch (s) {
+ case CRITICAL -> 4;
+ case HIGH -> 3;
+ case MEDIUM -> 2;
+ case LOW -> 1;
+ case NONE -> 0;
+ };
+ }
+
+ 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.getSoftwareTitle(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);
+ }
+
+ 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.getSoftwareTitle(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.searchHosts(request);
+ 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) {
+ }
+
+ public SoftwareFilters getSoftwareFilters(String search) {
+ // Facet counts don't depend on the enriched devicesCount, so bypass enrichRealDevicesCount
+ // here — it would fire N Fleet /hosts lookups just to produce numbers we don't use.
+ List titles = fetchAllTitles(search, null);
+ 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.getVulnerability(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 "discoveredAt", "discovered" -> Comparator.comparing(
+ SoftwareVulnerabilityResponse::getDiscoveredAt,
+ 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..17d456b9ef 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;
@@ -118,7 +120,6 @@ public List list() {
.stream().map(this::toResponse).toList();
}
- /** Soft-delete (status DELETED). Idempotent; returns the id. */
public String delete(String id) {
String tenantId = tenantIdProvider.getTenantId();
SoftwareSchedule entity = scheduleRepository.findByTenantIdAndId(tenantId, id)
@@ -131,12 +132,10 @@ public String delete(String id) {
return id;
}
- /** Archive a schedule (idempotent). */
public SoftwareScheduleResponse archive(String id) {
return transitionTo(id, ScriptStatus.ARCHIVED);
}
- /** Restore an archived schedule back to ACTIVE (idempotent). */
public SoftwareScheduleResponse unarchive(String id) {
return transitionTo(id, ScriptStatus.ACTIVE);
}
@@ -152,22 +151,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 +186,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) {
@@ -218,7 +240,7 @@ private static ScheduleTimeReference defaultTimeReference(ScheduleTimeReference
return timeReference != null ? timeReference : ScheduleTimeReference.SERVER;
}
- /** SERVER runs off {@code nextRunAt}=startAt; DEVICE_LOCAL is timezone-driven, so its nextRunAt is null. */
+ // DEVICE_LOCAL is timezone-driven → no seeded nextRunAt; SERVER runs off startAt.
private static Instant seedNextRunAt(ScheduleTimeReference timeReference, Instant startAt) {
return timeReference == ScheduleTimeReference.DEVICE_LOCAL ? null : startAt;
}
@@ -244,7 +266,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..58aef1ca16
--- /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())
+ .discoveredAt(parseInstant(v.getCreatedAt()))
+ .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..7276c95b05
--- /dev/null
+++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/vulnerability/VulnerabilityInventoryService.java
@@ -0,0 +1,270 @@
+package com.openframe.api.service.rmm.vulnerability;
+
+import com.openframe.api.dto.rmm.software.SoftwareCveSeverity;
+import com.openframe.api.dto.rmm.software.SoftwareFilterOption;
+import com.openframe.api.dto.rmm.vulnerability.AffectedSoftwareResponse;
+import com.openframe.api.dto.rmm.vulnerability.VulnerabilityFilters;
+import com.openframe.api.dto.rmm.vulnerability.VulnerabilityResponse;
+import com.openframe.api.dto.shared.PageResult;
+import com.openframe.api.service.rmm.fleet.FleetDeviceCountEnricher;
+import com.openframe.api.service.rmm.fleet.FleetHostMachineResolver;
+import com.openframe.data.document.device.Machine;
+import com.openframe.data.document.tool.IntegratedTool;
+import com.openframe.data.document.tool.IntegratedToolId;
+import com.openframe.data.repository.tool.IntegratedToolRepository;
+import com.openframe.data.service.TenantIdProvider;
+import com.openframe.sdk.fleetmdm.FleetMdmClient;
+import com.openframe.sdk.fleetmdm.FleetTenantHeader;
+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 jakarta.annotation.PostConstruct;
+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.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.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 VulnerabilityInventoryService {
+
+ private static final int DEVICES_LIMIT = 500;
+ private static final int HOSTS_PER_ROW_LIMIT = 500;
+ private static final int FETCH_PAGE_SIZE = 500;
+ private static final int FETCH_CAP = 5000;
+
+ private final IntegratedToolRepository integratedToolRepository;
+ private final FleetDeviceCountEnricher deviceCountEnricher;
+ private final FleetHostMachineResolver hostMachineResolver;
+ private final TenantIdProvider tenantIdProvider;
+
+ @Value("${TENANT_ID:}")
+ private String tenantIdEnv;
+
+ @Value("${openframe.fleet.multi-tenancy.enabled}")
+ private boolean fleetMultiTenancyEnabled;
+
+ private FleetMdmClient fleet;
+
+ @PostConstruct
+ void wireFleetClient() {
+ FleetTenantHeader.validate(fleetMultiTenancyEnabled, tenantIdEnv);
+ String key = IntegratedToolId.FLEET_SERVER_ID.getValue();
+ IntegratedTool tool = integratedToolRepository.findByKey(key)
+ .orElseThrow(() -> new IllegalStateException("Fleet MDM tool not configured: " + key));
+ this.fleet = new FleetMdmClient(tool.apiUrl(), tool.apiToken(), tenantIdEnv);
+ }
+
+ 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.searchHosts(request);
+ 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.getVulnerability(cveId);
+ return Optional.ofNullable(FleetGlobalVulnerabilityMapper.toDetail(detail))
+ .map(this::enrichDetail);
+ }
+
+ private VulnerabilityResponse enrichDetail(VulnerabilityResponse row) {
+ enrichVulnerabilityDeviceCounts(List.of(row));
+ enrichAffectedSoftwareDeviceCounts(row.getAffectedSoftware());
+ return row;
+ }
+
+ public PageResult listVulnerabilities(
+ String search, int page, Integer perPage,
+ String orderKey, String orderDirection,
+ Boolean exploit, SoftwareCveSeverity minSeverity) {
+ boolean bySeverity = minSeverity != null && minSeverity != SoftwareCveSeverity.NONE;
+ boolean byExploited = Boolean.TRUE.equals(exploit);
+
+ if (!bySeverity && !byExploited) {
+ VulnerabilityRequest request = VulnerabilityRequest.builder()
+ .page(page).perPage(perPage).query(search)
+ .orderKey(orderKey).orderDirection(orderDirection)
+ .build();
+ VulnerabilitiesResponse response = fleet.listVulnerabilities(request);
+ List items = mapRows(response);
+ enrichVulnerabilityDeviceCounts(items);
+ 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);
+ }
+
+ List filtered = fetchAll(search, orderKey, orderDirection).stream()
+ .filter(row -> passesMinSeverity(row, minSeverity))
+ .filter(row -> passesExploited(row, byExploited))
+ .toList();
+ PageResult pageResult = paginate(filtered, page, perPage);
+ enrichVulnerabilityDeviceCounts(pageResult.items());
+ return pageResult;
+ }
+
+ private void enrichVulnerabilityDeviceCounts(List rows) {
+ deviceCountEnricher.enrich(rows,
+ row -> hostsForCve(row.getCveId()),
+ VulnerabilityResponse::setDevicesCount);
+ }
+
+ private void enrichAffectedSoftwareDeviceCounts(List rows) {
+ deviceCountEnricher.enrich(rows,
+ row -> hostsForTitle(row.getId()),
+ AffectedSoftwareResponse::setDevicesCount);
+ }
+
+ private List hostsForCve(String cveId) {
+ if (!hasText(cveId)) {
+ return List.of();
+ }
+ HostSearchRequest request = new HostSearchRequest();
+ request.setCve(cveId);
+ request.setPerPage(HOSTS_PER_ROW_LIMIT);
+ return fleet.searchHosts(request);
+ }
+
+ private List hostsForTitle(String titleIdStr) {
+ if (!hasText(titleIdStr)) {
+ return List.of();
+ }
+ try {
+ HostSearchRequest request = new HostSearchRequest();
+ request.setSoftwareTitleId(Long.parseLong(titleIdStr));
+ request.setPerPage(HOSTS_PER_ROW_LIMIT);
+ return fleet.searchHosts(request);
+ } catch (NumberFormatException e) {
+ return List.of();
+ }
+ }
+
+ public VulnerabilityFilters getVulnerabilityFilters(String search) {
+ List all = fetchAll(search, null, null);
+ return VulnerabilityFilters.builder()
+ .severities(facet(all, VulnerabilityResponse::getSeverity))
+ .build();
+ }
+
+ private List fetchAll(String search, String orderKey, String orderDirection) {
+ List all = new ArrayList<>();
+ int page = 0;
+ while (all.size() < FETCH_CAP) {
+ VulnerabilityRequest request = VulnerabilityRequest.builder()
+ .page(page).perPage(FETCH_PAGE_SIZE).query(search)
+ .orderKey(orderKey).orderDirection(orderDirection)
+ .build();
+ VulnerabilitiesResponse response = fleet.listVulnerabilities(request);
+ all.addAll(mapRows(response));
+ boolean hasNext = response.getMeta() != null
+ && Boolean.TRUE.equals(response.getMeta().getHasNextResults());
+ if (!hasNext || response.getVulnerabilities() == null || response.getVulnerabilities().isEmpty()) {
+ break;
+ }
+ page++;
+ }
+ return all;
+ }
+
+ private static List mapRows(VulnerabilitiesResponse response) {
+ return response.getVulnerabilities() == null ? List.of()
+ : response.getVulnerabilities().stream()
+ .map(FleetGlobalVulnerabilityMapper::toListRow)
+ .filter(Objects::nonNull)
+ .toList();
+ }
+
+ private static boolean passesExploited(VulnerabilityResponse row, boolean wantExploited) {
+ return !wantExploited || Boolean.TRUE.equals(row.getCisaKnownExploit());
+ }
+
+ private static > List facet(
+ List rows, Function dimension) {
+ Map counts = rows.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 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/main/java/com/openframe/api/util/FleetSoftwareMapper.java b/openframe-api-lib/src/main/java/com/openframe/api/util/FleetSoftwareMapper.java
new file mode 100644
index 0000000000..aa543deee6
--- /dev/null
+++ b/openframe-api-lib/src/main/java/com/openframe/api/util/FleetSoftwareMapper.java
@@ -0,0 +1,64 @@
+package com.openframe.api.util;
+
+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 lombok.experimental.UtilityClass;
+
+import java.util.Comparator;
+import java.util.List;
+import java.util.Objects;
+
+@UtilityClass
+public class FleetSoftwareMapper {
+
+ public 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/util/FleetVulnerabilityMapper.java b/openframe-api-lib/src/main/java/com/openframe/api/util/FleetVulnerabilityMapper.java
new file mode 100644
index 0000000000..f827b55860
--- /dev/null
+++ b/openframe-api-lib/src/main/java/com/openframe/api/util/FleetVulnerabilityMapper.java
@@ -0,0 +1,50 @@
+package com.openframe.api.util;
+
+import com.openframe.api.dto.rmm.software.SoftwareCveSeverity;
+import com.openframe.api.dto.rmm.software.SoftwareVulnerabilityResponse;
+import com.openframe.sdk.fleetmdm.model.Vulnerability;
+import lombok.experimental.UtilityClass;
+
+import java.time.Instant;
+import java.time.format.DateTimeParseException;
+
+@UtilityClass
+public class FleetVulnerabilityMapper {
+
+ public static SoftwareVulnerabilityResponse toResponse(String cveId, Vulnerability enrichment, String affectedVersion) {
+ if (cveId == null) {
+ return null;
+ }
+ Double cvss = enrichment == null ? null : enrichment.getCvssScore();
+ String discovered = enrichment == null ? null : enrichment.getCreatedAt();
+ return SoftwareVulnerabilityResponse.builder()
+ .cveId(cveId)
+ .severity(bucketSeverity(cvss))
+ .cvssScore(cvss)
+ .affectedVersion(affectedVersion)
+ .discoveredAt(parseInstant(discovered))
+ .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/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/fleet/FleetDeviceCountEnricherTest.java b/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/fleet/FleetDeviceCountEnricherTest.java
new file mode 100644
index 0000000000..441b52233e
--- /dev/null
+++ b/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/fleet/FleetDeviceCountEnricherTest.java
@@ -0,0 +1,155 @@
+package com.openframe.api.service.rmm.fleet;
+
+import com.openframe.data.document.device.Machine;
+import com.openframe.data.service.TenantIdProvider;
+import com.openframe.sdk.fleetmdm.model.Host;
+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.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.eq;
+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 FleetDeviceCountEnricherTest {
+
+ @Mock private FleetHostMachineResolver hostMachineResolver;
+ @Mock private TenantIdProvider tenantIdProvider;
+
+ private FleetDeviceCountEnricher enricher;
+
+ @BeforeEach
+ void setUp() {
+ enricher = new FleetDeviceCountEnricher(hostMachineResolver, tenantIdProvider);
+ }
+
+ @Test
+ void enrich_setsCountToCorrelatedHostsPerRow_orphansDropped() {
+ Row rowA = new Row("A");
+ Row rowB = new Row("B");
+ Host h1 = host(1L);
+ Host h2 = host(2L); // orphan — no matching Machine
+ Host h3 = host(3L);
+ when(tenantIdProvider.getTenantId()).thenReturn("t1");
+ when(hostMachineResolver.resolve(eq("t1"), anyList()))
+ .thenReturn(Map.of(1L, new Machine(), 3L, new Machine()));
+
+ enricher.enrich(
+ List.of(rowA, rowB),
+ row -> row.name.equals("A") ? List.of(h1, h2) : List.of(h3),
+ Row::setCount);
+
+ assertThat(rowA.count).isEqualTo(1); // h2 dropped as orphan
+ assertThat(rowB.count).isEqualTo(1);
+ }
+
+ @Test
+ void enrich_batchesCorrelateIntoOneResolverCall_notPerRow() {
+ // Invariant: N rows → 1 resolver.resolve(...) call over the UNION of every row's hosts.
+ List rows = List.of(new Row("A"), new Row("B"), new Row("C"));
+ when(tenantIdProvider.getTenantId()).thenReturn("t1");
+ when(hostMachineResolver.resolve(eq("t1"), anyList())).thenReturn(Map.of());
+
+ enricher.enrich(rows, row -> List.of(host(1L)), Row::setCount);
+
+ verify(hostMachineResolver, times(1)).resolve(eq("t1"), anyList());
+ }
+
+ @Test
+ void enrich_callsHostFetcherOncePerRow_neverMore() {
+ // Invariant: exactly one Fleet /hosts call per row (parallel is OK, N > per-row is NOT).
+ AtomicInteger fetchCalls = new AtomicInteger();
+ when(tenantIdProvider.getTenantId()).thenReturn("t1");
+ when(hostMachineResolver.resolve(eq("t1"), anyList())).thenReturn(Map.of());
+ List rows = new ArrayList<>(List.of(new Row("A"), new Row("B"), new Row("C"), new Row("D")));
+
+ enricher.enrich(rows,
+ row -> { fetchCalls.incrementAndGet(); return List.of(host(1L)); },
+ Row::setCount);
+
+ assertThat(fetchCalls).hasValue(rows.size());
+ }
+
+ @Test
+ void enrich_correlateReceivesTheUnionOfAllRows_hosts() {
+ // Invariant: the resolver is asked about the FULL union — a host appearing on more than one
+ // row is still resolved once, and every row's hosts are eligible for correlation.
+ Row rowA = new Row("A");
+ Row rowB = new Row("B");
+ Host shared = host(1L);
+ Host rowBOnly = host(2L);
+ when(tenantIdProvider.getTenantId()).thenReturn("t1");
+ ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class);
+ when(hostMachineResolver.resolve(eq("t1"), captor.capture())).thenReturn(Map.of());
+
+ enricher.enrich(
+ List.of(rowA, rowB),
+ row -> row.name.equals("A") ? List.of(shared) : List.of(shared, rowBOnly),
+ Row::setCount);
+
+ // The captured list should contain every host the fetchers produced (duplicates allowed —
+ // the resolver's Mongo query naturally dedups; we don't slim it here to keep the enricher generic).
+ assertThat(captor.getValue()).contains(shared, rowBOnly);
+ }
+
+ @Test
+ void enrich_emptyRows_noResolverCall_noFleetCall() {
+ // Empty page must not call the resolver — no work to do, no wasted round-trip.
+ enricher.enrich(List.of(), row -> List.of(host(1L)), (r, c) -> {});
+
+ verifyNoInteractions(hostMachineResolver, tenantIdProvider);
+ }
+
+ @Test
+ void enrich_nullRows_isANoOp() {
+ // Defensive: null callers (from an upstream that couldn't build a page) don't NPE.
+ enricher.enrich(null, row -> List.of(), (r, c) -> {});
+
+ verifyNoInteractions(hostMachineResolver, tenantIdProvider);
+ }
+
+ @Test
+ void enrich_rowWithNoHosts_setsZeroCount() {
+ // A row whose fetcher returns [] must get count = 0 (not left untouched).
+ Row row = new Row("A");
+ row.count = -1; // sentinel: fails if we forget to write
+ when(tenantIdProvider.getTenantId()).thenReturn("t1");
+ when(hostMachineResolver.resolve(eq("t1"), anyList())).thenReturn(Map.of());
+
+ enricher.enrich(List.of(row), r -> List.of(), Row::setCount);
+
+ assertThat(row.count).isZero();
+ }
+
+ private static Host host(long id) {
+ Host h = new Host();
+ h.setId(id);
+ return h;
+ }
+
+ private static class Row {
+ final String name;
+ int count = -1;
+
+ Row(String name) {
+ this.name = name;
+ }
+
+ void setCount(int c) {
+ this.count = c;
+ }
+ }
+}
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..47dedb38f1
--- /dev/null
+++ b/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/software/SoftwareActionDetailServiceTest.java
@@ -0,0 +1,172 @@
+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),
+ bundleSentinel("m-skipped", DeviceOnlineDispatchStatus.DISPATCHED)));
+
+ 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")));
+
+ 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");
+
+ List completed = service.devices(EXEC, null, null, filter(List.of(SoftwareActionStatus.COMPLETED), null), null);
+ assertThat(completed).extracting(SoftwareActionDeviceResponse::getMachineId).containsExactlyInAnyOrder("alpha", "beta");
+
+ List org1 = service.devices(EXEC, null, null, filter(null, List.of("org-1")), null);
+ assertThat(org1).extracting(SoftwareActionDeviceResponse::getMachineId).containsExactlyInAnyOrder("alpha", "gamma");
+
+ List search = service.devices(EXEC, null, null, null, "BETA-HOST");
+ assertThat(search).extracting(SoftwareActionDeviceResponse::getMachineId).containsExactly("beta");
+ }
+
+ private static SoftwareActionDeviceFilterInput filter(List