Skip to content

Commit 5619633

Browse files
committed
Added UI to create and assign groups
1 parent f890509 commit 5619633

15 files changed

Lines changed: 1074 additions & 47 deletions
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
namespace LabSync.Core.Dto;
2+
3+
public sealed class DeviceGroupDto
4+
{
5+
public Guid Id { get; set; }
6+
public string Name { get; set; } = "";
7+
public string? Description { get; set; }
8+
public DateTime CreatedAt { get; set; }
9+
public int DeviceCount { get; set; }
10+
public DeviceGroupDeviceDto[] Devices { get; set; } = [];
11+
}
12+
13+
public sealed class DeviceGroupDeviceDto
14+
{
15+
public Guid Id { get; set; }
16+
public string Hostname { get; set; } = "";
17+
public bool IsOnline { get; set; }
18+
}
19+
20+
public sealed class CreateDeviceGroupRequest
21+
{
22+
public string Name { get; set; } = "";
23+
public string? Description { get; set; }
24+
}
25+
26+
public sealed class UpdateDeviceGroupRequest
27+
{
28+
public string Name { get; set; } = "";
29+
public string? Description { get; set; }
30+
}
31+
32+
public sealed class AssignDeviceGroupRequest
33+
{
34+
public Guid GroupId { get; set; }
35+
}

src/LabSync.Core/Entities/Device.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,5 +60,15 @@ public void RecordHeartbeat(string ipAddress)
6060
IpAddress = ipAddress;
6161
LastSeenAt = DateTime.UtcNow;
6262
}
63+
64+
public void AssignToGroup(Guid groupId)
65+
{
66+
GroupId = groupId;
67+
}
68+
69+
public void RemoveFromGroup()
70+
{
71+
GroupId = null;
72+
}
6373
}
6474

src/LabSync.Core/Entities/DeviceGroup.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ public class DeviceGroup
88
public Guid Id { get; init; }
99
public string Name { get; private set; }
1010
public string? Description { get; private set; }
11+
public DateTime CreatedAt { get; init; }
1112

1213
private readonly List<Device> _devices = new();
1314
public IReadOnlyCollection<Device> Devices => _devices.AsReadOnly();
@@ -20,6 +21,7 @@ public DeviceGroup(string name, string? description = null)
2021
throw new ArgumentException("Group name cannot be empty.", nameof(name));
2122

2223
Id = Guid.NewGuid();
24+
CreatedAt = DateTime.UtcNow;
2325
UpdateDetails(name, description);
2426
}
2527

@@ -50,6 +52,7 @@ public void AddDevice(Device device)
5052
if (_devices.Contains(device))
5153
return;
5254

55+
device.AssignToGroup(Id);
5356
_devices.Add(device);
5457
}
5558

@@ -58,6 +61,7 @@ public void RemoveDevice(Device device)
5861
if (device is null)
5962
throw new ArgumentNullException(nameof(device));
6063

64+
device.RemoveFromGroup();
6165
_devices.Remove(device);
6266
}
6367
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
using LabSync.Core.Dto;
2+
using LabSync.Core.Entities;
3+
using LabSync.Server.Data;
4+
using Microsoft.AspNetCore.Authorization;
5+
using Microsoft.AspNetCore.Mvc;
6+
using Microsoft.EntityFrameworkCore;
7+
8+
namespace LabSync.Server.Controllers;
9+
10+
[ApiController]
11+
[Route("api/device-groups")]
12+
[Authorize(Policy = "RequireAdminRole")]
13+
public sealed class DeviceGroupsController(LabSyncDbContext context) : ControllerBase
14+
{
15+
private const int MaxNameLength = 100;
16+
private const int MaxDescriptionLength = 500;
17+
18+
[HttpGet]
19+
public async Task<ActionResult<IEnumerable<DeviceGroupDto>>> GetAll(CancellationToken cancellationToken)
20+
{
21+
var groups = await context.Set<DeviceGroup>()
22+
.AsNoTracking()
23+
.Include(g => g.Devices)
24+
.OrderBy(g => g.Name)
25+
.ToListAsync(cancellationToken);
26+
27+
return Ok(groups.Select(ToDto));
28+
}
29+
30+
[HttpPost]
31+
public async Task<ActionResult<DeviceGroupDto>> Create(
32+
[FromBody] CreateDeviceGroupRequest request,
33+
CancellationToken cancellationToken)
34+
{
35+
var validation = Validate(request.Name, request.Description);
36+
if (validation is not null)
37+
return BadRequest(new ApiResponse(validation));
38+
39+
var normalizedName = request.Name.Trim();
40+
var exists = await context.Set<DeviceGroup>()
41+
.AnyAsync(g => g.Name.ToLower() == normalizedName.ToLower(), cancellationToken);
42+
if (exists)
43+
return BadRequest(new ApiResponse("A group with this name already exists."));
44+
45+
var group = new DeviceGroup(normalizedName, request.Description?.Trim());
46+
context.Set<DeviceGroup>().Add(group);
47+
await context.SaveChangesAsync(cancellationToken);
48+
49+
return CreatedAtAction(nameof(GetAll), new { id = group.Id }, ToDto(group));
50+
}
51+
52+
[HttpPut("{id:guid}")]
53+
public async Task<ActionResult<DeviceGroupDto>> Update(
54+
Guid id,
55+
[FromBody] UpdateDeviceGroupRequest request,
56+
CancellationToken cancellationToken)
57+
{
58+
var validation = Validate(request.Name, request.Description);
59+
if (validation is not null)
60+
return BadRequest(new ApiResponse(validation));
61+
62+
var group = await context.Set<DeviceGroup>()
63+
.Include(g => g.Devices)
64+
.FirstOrDefaultAsync(g => g.Id == id, cancellationToken);
65+
if (group is null)
66+
return NotFound(new ApiResponse("Group not found."));
67+
68+
var normalizedName = request.Name.Trim();
69+
var duplicate = await context.Set<DeviceGroup>()
70+
.AnyAsync(g => g.Id != id && g.Name.ToLower() == normalizedName.ToLower(), cancellationToken);
71+
if (duplicate)
72+
return BadRequest(new ApiResponse("A group with this name already exists."));
73+
74+
group.UpdateDetails(normalizedName, request.Description?.Trim());
75+
await context.SaveChangesAsync(cancellationToken);
76+
77+
return Ok(ToDto(group));
78+
}
79+
80+
[HttpDelete("{id:guid}")]
81+
public async Task<ActionResult> Delete(Guid id, CancellationToken cancellationToken)
82+
{
83+
var group = await context.Set<DeviceGroup>().FirstOrDefaultAsync(g => g.Id == id, cancellationToken);
84+
if (group is null)
85+
return NotFound(new ApiResponse("Group not found."));
86+
87+
context.Set<DeviceGroup>().Remove(group);
88+
await context.SaveChangesAsync(cancellationToken);
89+
return NoContent();
90+
}
91+
92+
private static string? Validate(string name, string? description)
93+
{
94+
if (string.IsNullOrWhiteSpace(name))
95+
return "Group name is required.";
96+
if (name.Trim().Length > MaxNameLength)
97+
return $"Group name cannot exceed {MaxNameLength} characters.";
98+
if (!string.IsNullOrWhiteSpace(description) && description.Trim().Length > MaxDescriptionLength)
99+
return $"Description cannot exceed {MaxDescriptionLength} characters.";
100+
return null;
101+
}
102+
103+
private static DeviceGroupDto ToDto(DeviceGroup group)
104+
{
105+
var devices = group.Devices.Select(d => new DeviceGroupDeviceDto
106+
{
107+
Id = d.Id,
108+
Hostname = d.Hostname,
109+
IsOnline = d.IsOnline
110+
}).ToArray();
111+
112+
return new DeviceGroupDto
113+
{
114+
Id = group.Id,
115+
Name = group.Name,
116+
Description = group.Description,
117+
CreatedAt = group.CreatedAt,
118+
DeviceCount = devices.Length,
119+
Devices = devices
120+
};
121+
}
122+
}

src/LabSync.Server/Controllers/DevicesController.cs

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
using LabSync.Core.Dto;
1+
using LabSync.Core.Dto;
22
using LabSync.Core.Interfaces;
33
using LabSync.Server.Data;
44
using LabSync.Server.Services;
@@ -76,6 +76,43 @@ public async Task<ActionResult<ApiResponse>> ApproveDevice(Guid id, Cancellation
7676
return Ok(new ApiResponse("Device approved successfully."));
7777
}
7878

79+
[HttpPost("{id}/group")]
80+
public async Task<ActionResult<ApiResponse>> AssignToGroup(
81+
Guid id,
82+
[FromBody] AssignDeviceGroupRequest request,
83+
CancellationToken cancellationToken)
84+
{
85+
if (request.GroupId == Guid.Empty)
86+
return BadRequest(new ApiResponse("GroupId is required."));
87+
88+
var device = await context.Devices.FirstOrDefaultAsync(d => d.Id == id, cancellationToken);
89+
if (device is null)
90+
return NotFound(new ApiResponse("Device not found."));
91+
92+
var groupExists = await context.Set<Core.Entities.DeviceGroup>()
93+
.AnyAsync(g => g.Id == request.GroupId, cancellationToken);
94+
if (!groupExists)
95+
return NotFound(new ApiResponse("Group not found."));
96+
97+
device.AssignToGroup(request.GroupId);
98+
await context.SaveChangesAsync(cancellationToken);
99+
100+
return Ok(new ApiResponse("Device assigned to group."));
101+
}
102+
103+
[HttpDelete("{id}/group")]
104+
public async Task<ActionResult<ApiResponse>> RemoveFromGroup(Guid id, CancellationToken cancellationToken)
105+
{
106+
var device = await context.Devices.FirstOrDefaultAsync(d => d.Id == id, cancellationToken);
107+
if (device is null)
108+
return NotFound(new ApiResponse("Device not found."));
109+
110+
device.RemoveFromGroup();
111+
await context.SaveChangesAsync(cancellationToken);
112+
113+
return Ok(new ApiResponse("Device removed from group."));
114+
}
115+
79116
[HttpPost("{id}/jobs")]
80117
public async Task<ActionResult<JobDto>> CreateJob(Guid id, [FromBody] CreateJobRequest request, CancellationToken cancellationToken)
81118
{

src/LabSync.Server/Data/Configurations/DeviceGroupConfiguration.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ public void Configure(EntityTypeBuilder<DeviceGroup> builder)
1919
builder.Property(g => g.Description)
2020
.HasMaxLength(500);
2121

22+
builder.Property(g => g.CreatedAt)
23+
.IsRequired();
24+
2225
builder.Navigation(g => g.Devices)
2326
.UsePropertyAccessMode(PropertyAccessMode.Field);
2427
}

0 commit comments

Comments
 (0)