From 34f0e6659228c7d2bea7766387780cba0a1e3fc1 Mon Sep 17 00:00:00 2001 From: mavnezz Date: Fri, 6 Mar 2026 14:38:39 +0100 Subject: [PATCH] feat: Add generic 'Other' hardware type for user-defined devices (#244) --- .../RackPeekConfigMigrationDeserializer.cs | 2 + .../Yaml/YamlResourceCollection.cs | 2 + .../OtherHardware/DescribeOtherUseCase.cs | 31 +++ .../Resources/OtherHardware/Other.cs | 8 + .../OtherHardware/OtherHardwareReport.cs | 29 ++ .../OtherHardware/UpdateOtherUseCase.cs | 31 +++ RackPeek.Domain/Resources/Resource.cs | 5 +- Shared.Rcl/Hardware/HardwareDetailsPage.razor | 6 + Shared.Rcl/Hardware/HardwareTreePage.razor | 1 + .../OtherHardware/OtherCardComponent.razor | 262 ++++++++++++++++++ Shared.Rcl/OtherHardware/OtherListPage.razor | 32 +++ schemas/v2/schema.v2.json | 17 ++ 12 files changed, 425 insertions(+), 1 deletion(-) create mode 100644 RackPeek.Domain/Resources/OtherHardware/DescribeOtherUseCase.cs create mode 100644 RackPeek.Domain/Resources/OtherHardware/Other.cs create mode 100644 RackPeek.Domain/Resources/OtherHardware/OtherHardwareReport.cs create mode 100644 RackPeek.Domain/Resources/OtherHardware/UpdateOtherUseCase.cs create mode 100644 Shared.Rcl/OtherHardware/OtherCardComponent.razor create mode 100644 Shared.Rcl/OtherHardware/OtherListPage.razor diff --git a/RackPeek.Domain/Persistence/Yaml/RackPeekConfigMigrationDeserializer.cs b/RackPeek.Domain/Persistence/Yaml/RackPeekConfigMigrationDeserializer.cs index ce866a10..8aa5c973 100644 --- a/RackPeek.Domain/Persistence/Yaml/RackPeekConfigMigrationDeserializer.cs +++ b/RackPeek.Domain/Persistence/Yaml/RackPeekConfigMigrationDeserializer.cs @@ -10,6 +10,7 @@ using RackPeek.Domain.Resources.Services; using RackPeek.Domain.Resources.Switches; using RackPeek.Domain.Resources.SystemResources; +using RackPeek.Domain.Resources.OtherHardware; using RackPeek.Domain.Resources.UpsUnits; using YamlDotNet.Serialization; using YamlDotNet.Serialization.NamingConventions; @@ -45,6 +46,7 @@ public RackPeekConfigMigrationDeserializer(IServiceProvider serviceProvider, { Laptop.KindLabel, typeof(Laptop) }, { AccessPoint.KindLabel, typeof(AccessPoint) }, { Ups.KindLabel, typeof(Ups) }, + { Other.KindLabel, typeof(Other) }, { SystemResource.KindLabel, typeof(SystemResource) }, { Service.KindLabel, typeof(Service) } }); diff --git a/RackPeek.Domain/Persistence/Yaml/YamlResourceCollection.cs b/RackPeek.Domain/Persistence/Yaml/YamlResourceCollection.cs index 44f9b349..aef7f2de 100644 --- a/RackPeek.Domain/Persistence/Yaml/YamlResourceCollection.cs +++ b/RackPeek.Domain/Persistence/Yaml/YamlResourceCollection.cs @@ -10,6 +10,7 @@ using RackPeek.Domain.Resources.Services; using RackPeek.Domain.Resources.Switches; using RackPeek.Domain.Resources.SystemResources; +using RackPeek.Domain.Resources.OtherHardware; using RackPeek.Domain.Resources.UpsUnits; using YamlDotNet.Core; using YamlDotNet.Serialization; @@ -368,6 +369,7 @@ private string GetKind(Resource resource) Laptop => "Laptop", AccessPoint => "AccessPoint", Ups => "Ups", + Other => "Other", SystemResource => "System", Service => "Service", _ => throw new InvalidOperationException($"Unknown resource type: {resource.GetType().Name}") diff --git a/RackPeek.Domain/Resources/OtherHardware/DescribeOtherUseCase.cs b/RackPeek.Domain/Resources/OtherHardware/DescribeOtherUseCase.cs new file mode 100644 index 00000000..e03f82fb --- /dev/null +++ b/RackPeek.Domain/Resources/OtherHardware/DescribeOtherUseCase.cs @@ -0,0 +1,31 @@ +using RackPeek.Domain.Helpers; +using RackPeek.Domain.Persistence; + +namespace RackPeek.Domain.Resources.OtherHardware; + +public record OtherDescription( + string Name, + string? Model, + string? Description, + Dictionary Labels +); + +public class DescribeOtherUseCase(IResourceCollection repository) : IUseCase +{ + public async Task ExecuteAsync(string name) + { + name = Normalize.HardwareName(name); + ThrowIfInvalid.ResourceName(name); + + var other = await repository.GetByNameAsync(name) as Other; + if (other == null) + throw new NotFoundException($"Other hardware '{name}' not found."); + + return new OtherDescription( + other.Name, + other.Model, + other.Description, + other.Labels + ); + } +} diff --git a/RackPeek.Domain/Resources/OtherHardware/Other.cs b/RackPeek.Domain/Resources/OtherHardware/Other.cs new file mode 100644 index 00000000..31877c30 --- /dev/null +++ b/RackPeek.Domain/Resources/OtherHardware/Other.cs @@ -0,0 +1,8 @@ +namespace RackPeek.Domain.Resources.OtherHardware; + +public class Other : Hardware.Hardware +{ + public const string KindLabel = "Other"; + public string? Model { get; set; } + public string? Description { get; set; } +} diff --git a/RackPeek.Domain/Resources/OtherHardware/OtherHardwareReport.cs b/RackPeek.Domain/Resources/OtherHardware/OtherHardwareReport.cs new file mode 100644 index 00000000..c937adc9 --- /dev/null +++ b/RackPeek.Domain/Resources/OtherHardware/OtherHardwareReport.cs @@ -0,0 +1,29 @@ +using RackPeek.Domain.Persistence; + +namespace RackPeek.Domain.Resources.OtherHardware; + +public record OtherHardwareReport( + IReadOnlyList Others +); + +public record OtherHardwareRow( + string Name, + string Model, + string Description +); + +public class OtherHardwareReportUseCase(IResourceCollection repository) : IUseCase +{ + public async Task ExecuteAsync() + { + var others = await repository.GetAllOfTypeAsync(); + + var rows = others.Select(o => new OtherHardwareRow( + o.Name, + o.Model ?? "Unknown", + o.Description ?? "" + )).ToList(); + + return new OtherHardwareReport(rows); + } +} diff --git a/RackPeek.Domain/Resources/OtherHardware/UpdateOtherUseCase.cs b/RackPeek.Domain/Resources/OtherHardware/UpdateOtherUseCase.cs new file mode 100644 index 00000000..7a0bef5c --- /dev/null +++ b/RackPeek.Domain/Resources/OtherHardware/UpdateOtherUseCase.cs @@ -0,0 +1,31 @@ +using RackPeek.Domain.Helpers; +using RackPeek.Domain.Persistence; + +namespace RackPeek.Domain.Resources.OtherHardware; + +public class UpdateOtherUseCase(IResourceCollection repository) : IUseCase +{ + public async Task ExecuteAsync( + string name, + string? model = null, + string? description = null, + string? notes = null + ) + { + name = Normalize.HardwareName(name); + ThrowIfInvalid.ResourceName(name); + + var other = await repository.GetByNameAsync(name) as Other; + if (other == null) + throw new InvalidOperationException($"Other hardware '{name}' not found."); + + if (!string.IsNullOrWhiteSpace(model)) + other.Model = model; + + if (description != null) + other.Description = description; + + if (notes != null) other.Notes = notes; + await repository.UpdateAsync(other); + } +} diff --git a/RackPeek.Domain/Resources/Resource.cs b/RackPeek.Domain/Resources/Resource.cs index 9f9f3cc6..f256c9a5 100644 --- a/RackPeek.Domain/Resources/Resource.cs +++ b/RackPeek.Domain/Resources/Resource.cs @@ -7,6 +7,7 @@ using RackPeek.Domain.Resources.Services; using RackPeek.Domain.Resources.Switches; using RackPeek.Domain.Resources.SystemResources; +using RackPeek.Domain.Resources.OtherHardware; using RackPeek.Domain.Resources.UpsUnits; namespace RackPeek.Domain.Resources; @@ -14,7 +15,7 @@ namespace RackPeek.Domain.Resources; public abstract class Resource { private static readonly string[] HardwareTypes = - ["server", "switch", "firewall", "router", "accesspoint", "desktop", "laptop", "ups"]; + ["server", "switch", "firewall", "router", "accesspoint", "desktop", "laptop", "ups", "other"]; public static bool IsHardware(string kind) { @@ -52,6 +53,7 @@ public static string GetResourceUrl(string kind, string name) { "desktop", "desktops" }, { "laptop", "laptops" }, { "ups", "ups" }, + { "other", "other" }, { "system", "systems" }, { "service", "services" } }; @@ -67,6 +69,7 @@ public static string GetResourceUrl(string kind, string name) { typeof(Desktop), "Desktop" }, { typeof(Laptop), "Laptop" }, { typeof(Ups), "Ups" }, + { typeof(Other), "Other" }, { typeof(SystemResource), "System" }, { typeof(Service), "Service" } }; diff --git a/Shared.Rcl/Hardware/HardwareDetailsPage.razor b/Shared.Rcl/Hardware/HardwareDetailsPage.razor index 6bc04a65..c46d4df2 100644 --- a/Shared.Rcl/Hardware/HardwareDetailsPage.razor +++ b/Shared.Rcl/Hardware/HardwareDetailsPage.razor @@ -9,6 +9,7 @@ @using RackPeek.Domain.Resources.Servers @using RackPeek.Domain.Resources.Switches @using RackPeek.Domain.Resources.SystemResources +@using RackPeek.Domain.Resources.OtherHardware @using RackPeek.Domain.Resources.UpsUnits @using Shared.Rcl.AccessPoints @using Shared.Rcl.Desktops @@ -18,6 +19,7 @@ @using Shared.Rcl.Servers @using Shared.Rcl.Switches @using Shared.Rcl.Ups +@using Shared.Rcl.OtherHardware @using Router = RackPeek.Domain.Resources.Routers.Router @inject IResourceCollection Repo @inject GetHardwareSystemTreeUseCase GetHardwareSystemTreeUseCase @@ -77,6 +79,10 @@ { } + else if (_hardware is Other other) + { + + } else {
diff --git a/Shared.Rcl/Hardware/HardwareTreePage.razor b/Shared.Rcl/Hardware/HardwareTreePage.razor index 885335ee..8f3c85b2 100644 --- a/Shared.Rcl/Hardware/HardwareTreePage.razor +++ b/Shared.Rcl/Hardware/HardwareTreePage.razor @@ -24,6 +24,7 @@ Ups Desktops Laptops + Other @if (_tree is null) diff --git a/Shared.Rcl/OtherHardware/OtherCardComponent.razor b/Shared.Rcl/OtherHardware/OtherCardComponent.razor new file mode 100644 index 00000000..9c9c8f09 --- /dev/null +++ b/Shared.Rcl/OtherHardware/OtherCardComponent.razor @@ -0,0 +1,262 @@ +@using RackPeek.Domain.Resources.OtherHardware +@inject UpdateOtherUseCase UpdateUseCase +@inject IGetResourceByNameUseCase GetByNameUseCase +@inject IDeleteResourceUseCase DeleteUseCase +@inject IRenameResourceUseCase RenameUseCase +@inject ICloneResourceUseCase CloneUseCase +@inject NavigationManager Nav + +
+ +
+ +
+ + @Other.Name + +
+ +
+ @if (!_isEditing) + { + + + + + + + + } + else + { + + + + } +
+
+ +
+ + +
+
Model
+ + @if (_isEditing) + { + + } + else if (!string.IsNullOrWhiteSpace(Other.Model)) + { +
+ @Other.Model +
+ } +
+ + +
+
Description
+ + @if (_isEditing) + { + + } + else if (!string.IsNullOrWhiteSpace(Other.Description)) + { +
+ @Other.Description +
+ } +
+ + + + + +
+ +
Notes
+ + @if (_isEditing) + { + + } + else + { + + } +
+
+
+ + + Are you sure you want to delete @Other.Name? + + + + + + +@code { + [Parameter] [EditorRequired] public Other Other { get; set; } = default!; + + [Parameter] public EventCallback OnDeleted { get; set; } + + bool _isEditing; + bool _confirmDeleteOpen; + + OtherEditModel _edit = new(); + + void BeginEdit() + { + _edit = OtherEditModel.From(Other); + _isEditing = true; + } + + async Task Save() + { + _isEditing = false; + + await UpdateUseCase.ExecuteAsync( + Other.Name, + _edit.Model, + _edit.Description, + _edit.Notes); + + Other = await GetByNameUseCase.ExecuteAsync(Other.Name); + } + + void Cancel() + { + _isEditing = false; + } + + void ConfirmDelete() + { + _confirmDeleteOpen = true; + } + + async Task DeleteOther() + { + _confirmDeleteOpen = false; + + await DeleteUseCase.ExecuteAsync(Other.Name); + + if (OnDeleted.HasDelegate) + await OnDeleted.InvokeAsync(Other.Name); + } + + public class OtherEditModel + { + public string? Model { get; set; } + public string? Description { get; set; } + public string? Notes { get; set; } + + public static OtherEditModel From(Other other) + { + return new OtherEditModel + { + Model = other.Model, + Description = other.Description, + Notes = other.Notes + }; + } + } + +} + +@code +{ + bool _renameOpen; + + void OpenRename() + { + _renameOpen = true; + } + + async Task HandleRenameSubmit(string newName) + { + await RenameUseCase.ExecuteAsync(Other.Name, newName); + Nav.NavigateTo($"resources/hardware/{Uri.EscapeDataString(newName)}"); + } + +} + +@code +{ + bool _cloneOpen; + + void OpenClone() + { + _cloneOpen = true; + } + + async Task HandleCloneSubmit(string newName) + { + await CloneUseCase.ExecuteAsync(Other.Name, newName); + + Nav.NavigateTo($"resources/hardware/{Uri.EscapeDataString(newName)}"); + } + +} diff --git a/Shared.Rcl/OtherHardware/OtherListPage.razor b/Shared.Rcl/OtherHardware/OtherListPage.razor new file mode 100644 index 00000000..bb273986 --- /dev/null +++ b/Shared.Rcl/OtherHardware/OtherListPage.razor @@ -0,0 +1,32 @@ +@page "/other/list" +@using RackPeek.Domain.Resources.OtherHardware +@inject NavigationManager Nav + + + + + + + + + +@code { + + [Inject] IGetAllResourcesByKindUseCase GetAllUseCase { get; set; } = default!; + + private Task NavigateToNewResource(string name) + { + Nav.NavigateTo($"resources/hardware/{Uri.EscapeDataString(name)}"); + return Task.CompletedTask; + } + + private async Task Reload(string _) + { + await GetAllUseCase.ExecuteAsync(); + } + +} diff --git a/schemas/v2/schema.v2.json b/schemas/v2/schema.v2.json index 0b75c797..2285bc40 100644 --- a/schemas/v2/schema.v2.json +++ b/schemas/v2/schema.v2.json @@ -53,6 +53,7 @@ { "$ref": "#/$defs/switch" }, { "$ref": "#/$defs/accessPoint" }, { "$ref": "#/$defs/ups" }, + { "$ref": "#/$defs/other" }, { "$ref": "#/$defs/desktop" }, { "$ref": "#/$defs/laptop" }, { "$ref": "#/$defs/service" }, @@ -290,6 +291,22 @@ "unevaluatedProperties": false }, + "other": { + "allOf": [ + { "$ref": "#/$defs/resourceBase" }, + { + "type": "object", + "properties": { + "kind": { "const": "Other" }, + + "model": { "type": "string" }, + "description": { "type": "string" } + } + } + ], + "unevaluatedProperties": false + }, + "service": { "allOf": [ { "$ref": "#/$defs/resourceBase" },