Skip to content

Commit 5068d62

Browse files
NotYuShengclaude
andauthored
feat: per-snapshot subnet definition overrides in Monitor mode (#351)
* feat: per-snapshot subnet definition overrides in Monitor mode (#346) Adds optional per-snapshot subnet CIDR overrides that shadow the global definitions during change detection. Snapshots without overrides continue to use the global config unchanged. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: mention per-snapshot subnet overrides in detection tooltip Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: document per-snapshot subnet overrides in network-monitor.rst Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix/subnet-override-validation-stability-ux - Add @Valid cascade and @notblank + CIDR pattern to SubnetOverrideInput - Bulk-delete via @Modifying @query (avoid N+1 delete) - Enable customize mode even when global fetch fails or returns empty - Flip inherited→false on any field edit in both modals - Fix useEffect dep instability (array ref churn on polls) - Fix misleading empty-state copy in both modals - Defensive ?? [] for snapshot.subnetOverrides in SnapshotDetailModal Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix/jpa-query-and-stale-subnet-draft-across-snapshots Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent f5dd098 commit 5068d62

17 files changed

Lines changed: 647 additions & 35 deletions

File tree

backend/src/main/java/com/tracepcap/monitor/controller/NetworkSnapshotController.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,14 @@ public List<NetworkSnapshotDto> listSnapshots(@PathVariable UUID networkId) {
2727
@ResponseStatus(HttpStatus.CREATED)
2828
public NetworkSnapshotDto addSnapshot(
2929
@PathVariable UUID networkId, @Valid @RequestBody AddSnapshotRequest request) {
30-
return snapshotService.addSnapshot(networkId, request.getFileId());
30+
return snapshotService.addSnapshot(networkId, request.getFileId(), request.getSubnetOverrides());
3131
}
3232

3333
@PatchMapping("/{snapshotId}")
3434
public NetworkSnapshotDto patchSnapshot(
3535
@PathVariable UUID networkId,
3636
@PathVariable UUID snapshotId,
37-
@RequestBody PatchSnapshotRequest request) {
37+
@Valid @RequestBody PatchSnapshotRequest request) {
3838
return snapshotService.patchSnapshot(networkId, snapshotId, request);
3939
}
4040

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
package com.tracepcap.monitor.dto;
22

3+
import jakarta.validation.Valid;
34
import jakarta.validation.constraints.NotNull;
5+
import java.util.List;
46
import java.util.UUID;
57
import lombok.Data;
68

79
@Data
810
public class AddSnapshotRequest {
911
@NotNull private UUID fileId;
12+
private List<@Valid SubnetOverrideInput> subnetOverrides;
1013
}

backend/src/main/java/com/tracepcap/monitor/dto/NetworkSnapshotDto.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.tracepcap.monitor.dto;
22

33
import java.time.LocalDateTime;
4+
import java.util.List;
45
import java.util.UUID;
56
import lombok.Builder;
67
import lombok.Data;
@@ -23,4 +24,5 @@ public class NetworkSnapshotDto {
2324
private String notes;
2425
private boolean hasInsights;
2526
private LocalDateTime addedAt;
27+
private List<SnapshotSubnetOverrideDto> subnetOverrides;
2628
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
package com.tracepcap.monitor.dto;
22

3+
import jakarta.validation.Valid;
4+
import java.util.List;
35
import lombok.Data;
46

57
@Data
68
public class PatchSnapshotRequest {
79
private String context;
810
private String notes;
11+
/** null = leave overrides unchanged; empty list = remove all (revert to global); non-empty = replace */
12+
private List<@Valid SubnetOverrideInput> subnetOverrides;
913
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package com.tracepcap.monitor.dto;
2+
3+
import lombok.Builder;
4+
import lombok.Data;
5+
6+
@Data
7+
@Builder
8+
public class SnapshotSubnetOverrideDto {
9+
private Long id;
10+
private String cidr;
11+
private String label;
12+
private String description;
13+
private boolean inherited;
14+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package com.tracepcap.monitor.dto;
2+
3+
import jakarta.validation.constraints.NotBlank;
4+
import jakarta.validation.constraints.Pattern;
5+
import lombok.Data;
6+
7+
@Data
8+
public class SubnetOverrideInput {
9+
@NotBlank
10+
@Pattern(
11+
regexp = "^((25[0-5]|2[0-4]\\d|[01]?\\d\\d?)\\.){3}(25[0-5]|2[0-4]\\d|[01]?\\d\\d?)/(\\d|[12]\\d|3[0-2])$",
12+
message = "must be a valid CIDR block (e.g. 10.0.0.0/24)")
13+
private String cidr;
14+
private String label;
15+
private String description;
16+
private boolean inherited;
17+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package com.tracepcap.monitor.entity;
2+
3+
import jakarta.persistence.*;
4+
import lombok.AllArgsConstructor;
5+
import lombok.Builder;
6+
import lombok.Data;
7+
import lombok.NoArgsConstructor;
8+
9+
@Entity
10+
@Table(name = "snapshot_subnet_overrides")
11+
@Data
12+
@Builder
13+
@NoArgsConstructor
14+
@AllArgsConstructor
15+
public class SnapshotSubnetOverrideEntity {
16+
17+
@Id
18+
@GeneratedValue(strategy = GenerationType.IDENTITY)
19+
private Long id;
20+
21+
@ManyToOne(fetch = FetchType.LAZY)
22+
@JoinColumn(name = "snapshot_id", nullable = false)
23+
private NetworkSnapshotEntity snapshot;
24+
25+
@Column(nullable = false, length = 50)
26+
private String cidr;
27+
28+
@Column(length = 255)
29+
private String label;
30+
31+
@Column(columnDefinition = "TEXT")
32+
private String description;
33+
34+
@Builder.Default
35+
@Column(nullable = false)
36+
private boolean inherited = false;
37+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package com.tracepcap.monitor.repository;
2+
3+
import com.tracepcap.monitor.entity.SnapshotSubnetOverrideEntity;
4+
import java.util.List;
5+
import java.util.UUID;
6+
import org.springframework.data.jpa.repository.JpaRepository;
7+
import org.springframework.data.jpa.repository.Modifying;
8+
import org.springframework.data.jpa.repository.Query;
9+
import org.springframework.data.repository.query.Param;
10+
import org.springframework.stereotype.Repository;
11+
12+
@Repository
13+
public interface SnapshotSubnetOverrideRepository
14+
extends JpaRepository<SnapshotSubnetOverrideEntity, Long> {
15+
16+
@Query("SELECT o FROM SnapshotSubnetOverrideEntity o WHERE o.snapshot.id = :snapshotId")
17+
List<SnapshotSubnetOverrideEntity> findBySnapshotId(@Param("snapshotId") UUID snapshotId);
18+
19+
@Modifying
20+
@Query("DELETE FROM SnapshotSubnetOverrideEntity o WHERE o.snapshot.id = :snapshotId")
21+
void deleteBySnapshotId(@Param("snapshotId") UUID snapshotId);
22+
23+
@Query("SELECT o FROM SnapshotSubnetOverrideEntity o WHERE o.snapshot.id IN :ids")
24+
List<SnapshotSubnetOverrideEntity> findBySnapshotIdIn(@Param("ids") List<UUID> ids);
25+
}

backend/src/main/java/com/tracepcap/monitor/service/SnapshotService.java

Lines changed: 76 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,15 @@
88
import com.tracepcap.insights.repository.SnapshotInsightRepository;
99
import com.tracepcap.monitor.dto.NetworkSnapshotDto;
1010
import com.tracepcap.monitor.dto.PatchSnapshotRequest;
11+
import com.tracepcap.monitor.dto.SnapshotSubnetOverrideDto;
12+
import com.tracepcap.monitor.dto.SubnetOverrideInput;
1113
import com.tracepcap.monitor.entity.NetworkEntity;
1214
import com.tracepcap.monitor.entity.NetworkSnapshotEntity;
15+
import com.tracepcap.monitor.entity.SnapshotSubnetOverrideEntity;
1316
import com.tracepcap.monitor.repository.NetworkChangeEventRepository;
1417
import com.tracepcap.monitor.repository.NetworkSnapshotRepository;
18+
import com.tracepcap.monitor.repository.SnapshotSubnetOverrideRepository;
19+
import java.util.Collections;
1520
import java.util.List;
1621
import java.util.Map;
1722
import java.util.UUID;
@@ -33,6 +38,7 @@ public class SnapshotService {
3338
private final ChangeDetectionService changeDetectionService;
3439
private final NetworkChangeEventRepository changeEventRepository;
3540
private final SnapshotInsightRepository snapshotInsightRepository;
41+
private final SnapshotSubnetOverrideRepository subnetOverrideRepository;
3642

3743
@Transactional(readOnly = true)
3844
public List<NetworkSnapshotDto> listSnapshots(UUID networkId) {
@@ -41,18 +47,27 @@ public List<NetworkSnapshotDto> listSnapshots(UUID networkId) {
4147
snapshotRepository.findByNetworkIdOrderBySnapshotOrderAsc(networkId);
4248
if (snapshots.isEmpty()) return List.of();
4349

44-
// Bulk-fetch counts to avoid N+1 queries
4550
List<UUID> ids = snapshots.stream().map(NetworkSnapshotEntity::getId).collect(Collectors.toList());
4651
Map<UUID, Long> changeCounts = changeEventRepository.countByToSnapshotIds(ids);
4752
Map<UUID, Long> criticalCounts = changeEventRepository.countCriticalByToSnapshotIds(ids);
4853

54+
// Batch-fetch all subnet overrides to avoid N+1
55+
Map<UUID, List<SnapshotSubnetOverrideDto>> overridesMap =
56+
subnetOverrideRepository.findBySnapshotIdIn(ids).stream()
57+
.collect(Collectors.groupingBy(
58+
o -> o.getSnapshot().getId(),
59+
Collectors.mapping(this::toOverrideDto, Collectors.toList())));
60+
4961
return snapshots.stream()
50-
.map(s -> toDto(s, changeCounts.getOrDefault(s.getId(), 0L),
51-
criticalCounts.getOrDefault(s.getId(), 0L)))
62+
.map(s -> toDto(
63+
s,
64+
changeCounts.getOrDefault(s.getId(), 0L),
65+
criticalCounts.getOrDefault(s.getId(), 0L),
66+
overridesMap.getOrDefault(s.getId(), Collections.emptyList())))
5267
.collect(Collectors.toList());
5368
}
5469

55-
public NetworkSnapshotDto addSnapshot(UUID networkId, UUID fileId) {
70+
public NetworkSnapshotDto addSnapshot(UUID networkId, UUID fileId, List<SubnetOverrideInput> subnetOverrides) {
5671
NetworkEntity network = networkService.findOrThrow(networkId);
5772

5873
FileEntity file =
@@ -79,6 +94,21 @@ public NetworkSnapshotDto addSnapshot(UUID networkId, UUID fileId) {
7994
.build();
8095
snapshot = snapshotRepository.save(snapshot);
8196

97+
// Save subnet overrides if provided
98+
if (subnetOverrides != null && !subnetOverrides.isEmpty()) {
99+
final NetworkSnapshotEntity savedSnapshot = snapshot;
100+
List<SnapshotSubnetOverrideEntity> entities = subnetOverrides.stream()
101+
.map(o -> SnapshotSubnetOverrideEntity.builder()
102+
.snapshot(savedSnapshot)
103+
.cidr(o.getCidr())
104+
.label(o.getLabel())
105+
.description(o.getDescription())
106+
.inherited(o.isInherited())
107+
.build())
108+
.collect(Collectors.toList());
109+
subnetOverrideRepository.saveAll(entities);
110+
}
111+
82112
// Reorder all snapshots by capture start time
83113
reorderSnapshots(networkId);
84114

@@ -92,7 +122,6 @@ public NetworkSnapshotDto addSnapshot(UUID networkId, UUID fileId) {
92122
int newOrder = snapshot.getSnapshotOrder();
93123
NetworkSnapshotEntity prev = newOrder > 0 ? ordered.get(newOrder - 1) : null;
94124

95-
// Detect: prev → new
96125
if (prev != null) {
97126
try {
98127
changeDetectionService.detectChanges(prev, snapshot);
@@ -103,8 +132,6 @@ public NetworkSnapshotDto addSnapshot(UUID networkId, UUID fileId) {
103132
}
104133
}
105134

106-
// If the new snapshot was inserted mid-timeline, the successor's events now reflect
107-
// the wrong predecessor. Delete its stale events and re-detect: new → successor.
108135
if (newOrder + 1 < ordered.size()) {
109136
NetworkSnapshotEntity successor = ordered.get(newOrder + 1);
110137
changeEventRepository.deleteByToSnapshotId(successor.getId());
@@ -120,7 +147,8 @@ public NetworkSnapshotDto addSnapshot(UUID networkId, UUID fileId) {
120147

121148
long changeCount = changeEventRepository.countByToSnapshotId(snapshot.getId());
122149
long criticalCount = changeEventRepository.countCriticalByToSnapshotId(snapshot.getId());
123-
return toDto(snapshot, changeCount, criticalCount);
150+
List<SnapshotSubnetOverrideDto> overrides = fetchOverrideDtos(snapshot.getId());
151+
return toDto(snapshot, changeCount, criticalCount, overrides);
124152
}
125153

126154
public NetworkSnapshotDto patchSnapshot(UUID networkId, UUID snapshotId, PatchSnapshotRequest req) {
@@ -130,9 +158,29 @@ public NetworkSnapshotDto patchSnapshot(UUID networkId, UUID snapshotId, PatchSn
130158
if (req.getContext() != null) snapshot.setContext(req.getContext());
131159
if (req.getNotes() != null) snapshot.setNotes(req.getNotes());
132160
snapshot = snapshotRepository.save(snapshot);
161+
162+
// null = untouched; empty list = clear all; non-empty = replace
163+
if (req.getSubnetOverrides() != null) {
164+
subnetOverrideRepository.deleteBySnapshotId(snapshotId);
165+
if (!req.getSubnetOverrides().isEmpty()) {
166+
final NetworkSnapshotEntity savedSnapshot = snapshot;
167+
List<SnapshotSubnetOverrideEntity> entities = req.getSubnetOverrides().stream()
168+
.map(o -> SnapshotSubnetOverrideEntity.builder()
169+
.snapshot(savedSnapshot)
170+
.cidr(o.getCidr())
171+
.label(o.getLabel())
172+
.description(o.getDescription())
173+
.inherited(o.isInherited())
174+
.build())
175+
.collect(Collectors.toList());
176+
subnetOverrideRepository.saveAll(entities);
177+
}
178+
}
179+
133180
long changeCount = changeEventRepository.countByToSnapshotId(snapshotId);
134181
long criticalCount = changeEventRepository.countCriticalByToSnapshotId(snapshotId);
135-
return toDto(snapshot, changeCount, criticalCount);
182+
List<SnapshotSubnetOverrideDto> overrides = fetchOverrideDtos(snapshotId);
183+
return toDto(snapshot, changeCount, criticalCount, overrides);
136184
}
137185

138186
public void removeSnapshot(UUID networkId, UUID snapshotId) {
@@ -149,10 +197,6 @@ public void removeSnapshot(UUID networkId, UUID snapshotId) {
149197

150198
// ── Helpers ──────────────────────────────────────────────────────────────────
151199

152-
/**
153-
* Recomputes snapshot_order for all snapshots in the network, sorted by file.startTime ASC
154-
* (ties broken by addedAt ASC).
155-
*/
156200
private void reorderSnapshots(UUID networkId) {
157201
List<NetworkSnapshotEntity> ordered =
158202
snapshotRepository.findOrderedByStartTime(networkId);
@@ -165,10 +209,6 @@ private void reorderSnapshots(UUID networkId) {
165209
}
166210
}
167211

168-
/**
169-
* Deletes all change events for the network then re-runs detection for every consecutive pair
170-
* from the first snapshot onward.
171-
*/
172212
private void rerunChangeDetectionChain(UUID networkId) {
173213
changeEventRepository.deleteByNetworkId(networkId);
174214

@@ -187,7 +227,24 @@ private void rerunChangeDetectionChain(UUID networkId) {
187227
}
188228
}
189229

190-
NetworkSnapshotDto toDto(NetworkSnapshotEntity s, long changeCount, long criticalCount) {
230+
private List<SnapshotSubnetOverrideDto> fetchOverrideDtos(UUID snapshotId) {
231+
return subnetOverrideRepository.findBySnapshotId(snapshotId).stream()
232+
.map(this::toOverrideDto)
233+
.collect(Collectors.toList());
234+
}
235+
236+
private SnapshotSubnetOverrideDto toOverrideDto(SnapshotSubnetOverrideEntity o) {
237+
return SnapshotSubnetOverrideDto.builder()
238+
.id(o.getId())
239+
.cidr(o.getCidr())
240+
.label(o.getLabel())
241+
.description(o.getDescription())
242+
.inherited(o.isInherited())
243+
.build();
244+
}
245+
246+
NetworkSnapshotDto toDto(NetworkSnapshotEntity s, long changeCount, long criticalCount,
247+
List<SnapshotSubnetOverrideDto> overrides) {
191248
return NetworkSnapshotDto.builder()
192249
.id(s.getId())
193250
.networkId(s.getNetwork().getId())
@@ -204,6 +261,7 @@ NetworkSnapshotDto toDto(NetworkSnapshotEntity s, long changeCount, long critica
204261
.notes(s.getNotes())
205262
.hasInsights(snapshotInsightRepository.existsBySnapshotId(s.getId()))
206263
.addedAt(s.getAddedAt())
264+
.subnetOverrides(overrides)
207265
.build();
208266
}
209267
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
CREATE TABLE snapshot_subnet_overrides (
2+
id BIGSERIAL PRIMARY KEY,
3+
snapshot_id UUID NOT NULL REFERENCES network_snapshots(id) ON DELETE CASCADE,
4+
cidr VARCHAR(50) NOT NULL,
5+
label VARCHAR(255),
6+
description TEXT,
7+
inherited BOOLEAN NOT NULL DEFAULT false
8+
);
9+
10+
CREATE INDEX idx_snapshot_subnet_overrides_snapshot_id ON snapshot_subnet_overrides(snapshot_id);

0 commit comments

Comments
 (0)