Просмотр исходного кода

feat: Add generic 'Other' hardware type for user-defined devices (#244)

mavnezz 6 месяцев назад
Родитель
Сommit
34f0e66592

+ 2 - 0
RackPeek.Domain/Persistence/Yaml/RackPeekConfigMigrationDeserializer.cs

@@ -10,6 +10,7 @@ using RackPeek.Domain.Resources.Servers;
 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 class RackPeekConfigMigrationDeserializer : YamlMigrationDeserializer<Yam
                         { Laptop.KindLabel, typeof(Laptop) },
                         { AccessPoint.KindLabel, typeof(AccessPoint) },
                         { Ups.KindLabel, typeof(Ups) },
+                        { Other.KindLabel, typeof(Other) },
                         { SystemResource.KindLabel, typeof(SystemResource) },
                         { Service.KindLabel, typeof(Service) }
                     });

+ 2 - 0
RackPeek.Domain/Persistence/Yaml/YamlResourceCollection.cs

@@ -10,6 +10,7 @@ using RackPeek.Domain.Resources.Servers;
 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 @@ public sealed class YamlResourceCollection(
             Laptop => "Laptop",
             AccessPoint => "AccessPoint",
             Ups => "Ups",
+            Other => "Other",
             SystemResource => "System",
             Service => "Service",
             _ => throw new InvalidOperationException($"Unknown resource type: {resource.GetType().Name}")

+ 31 - 0
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<string, string> Labels
+);
+
+public class DescribeOtherUseCase(IResourceCollection repository) : IUseCase
+{
+    public async Task<OtherDescription> 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
+        );
+    }
+}

+ 8 - 0
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; }
+}

+ 29 - 0
RackPeek.Domain/Resources/OtherHardware/OtherHardwareReport.cs

@@ -0,0 +1,29 @@
+using RackPeek.Domain.Persistence;
+
+namespace RackPeek.Domain.Resources.OtherHardware;
+
+public record OtherHardwareReport(
+    IReadOnlyList<OtherHardwareRow> Others
+);
+
+public record OtherHardwareRow(
+    string Name,
+    string Model,
+    string Description
+);
+
+public class OtherHardwareReportUseCase(IResourceCollection repository) : IUseCase
+{
+    public async Task<OtherHardwareReport> ExecuteAsync()
+    {
+        var others = await repository.GetAllOfTypeAsync<Other>();
+
+        var rows = others.Select(o => new OtherHardwareRow(
+            o.Name,
+            o.Model ?? "Unknown",
+            o.Description ?? ""
+        )).ToList();
+
+        return new OtherHardwareReport(rows);
+    }
+}

+ 31 - 0
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);
+    }
+}

+ 4 - 1
RackPeek.Domain/Resources/Resource.cs

@@ -7,6 +7,7 @@ using RackPeek.Domain.Resources.Servers;
 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 abstract class Resource
         { "desktop", "desktops" },
         { "laptop", "laptops" },
         { "ups", "ups" },
+        { "other", "other" },
         { "system", "systems" },
         { "service", "services" }
     };
@@ -67,6 +69,7 @@ public abstract class Resource
         { typeof(Desktop), "Desktop" },
         { typeof(Laptop), "Laptop" },
         { typeof(Ups), "Ups" },
+        { typeof(Other), "Other" },
         { typeof(SystemResource), "System" },
         { typeof(Service), "Service" }
     };

+ 6 - 0
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 @@
         {
             <UpsCardComponent Ups="ups" OnDeleted="DeleteCallback"/>
         }
+        else if (_hardware is Other other)
+        {
+            <OtherCardComponent Other="other" OnDeleted="DeleteCallback"/>
+        }
         else
         {
             <div class="text-zinc-400">

+ 1 - 0
Shared.Rcl/Hardware/HardwareTreePage.razor

@@ -24,6 +24,7 @@
         <NavLink href="ups/list" data-testid="nav-ups">Ups</NavLink>
         <NavLink href="desktops/list" data-testid="nav-desktops">Desktops</NavLink>
         <NavLink href="laptops/list" data-testid="nav-laptops">Laptops</NavLink>
+        <NavLink href="other/list" data-testid="nav-other">Other</NavLink>
     </nav>
 
     @if (_tree is null)

+ 262 - 0
Shared.Rcl/OtherHardware/OtherCardComponent.razor

@@ -0,0 +1,262 @@
+@using RackPeek.Domain.Resources.OtherHardware
+@inject UpdateOtherUseCase UpdateUseCase
+@inject IGetResourceByNameUseCase<Other> GetByNameUseCase
+@inject IDeleteResourceUseCase<Other> DeleteUseCase
+@inject IRenameResourceUseCase<Other> RenameUseCase
+@inject ICloneResourceUseCase<Other> CloneUseCase
+@inject NavigationManager Nav
+
+<div class="border border-zinc-800 rounded p-4 bg-zinc-900"
+     data-testid=@($"other-item-{Other.Name.Replace(" ", "-")}")>
+
+    <div class="flex justify-between items-center mb-3">
+
+        <div class="text-zinc-100 hover:text-emerald-300">
+            <NavLink href="@($"resources/hardware/{Uri.EscapeDataString(Other.Name)}")"
+                     class="block"
+                     data-testid=@($"other-item-{Other.Name.Replace(" ", "-")}-link")>
+                @Other.Name
+            </NavLink>
+        </div>
+
+        <div class="flex gap-3 text-xs">
+            @if (!_isEditing)
+            {
+                <button data-testid="edit-other-button"
+                        class="text-zinc-400 hover:text-zinc-200"
+                        @onclick="BeginEdit">
+                    Edit
+                </button>
+
+                <button data-testid="rename-other-button"
+                        class="text-blue-400 hover:text-blue-300 transition"
+                        @onclick="OpenRename">
+                    Rename
+                </button>
+
+                <button data-testid="clone-other-button"
+                        class="text-emerald-400 hover:text-emerald-300 transition"
+                        @onclick="OpenClone">
+                    Clone
+                </button>
+
+                <button data-testid="delete-other-button"
+                        class="text-red-400 hover:text-red-300 transition"
+                        @onclick="ConfirmDelete">
+                    Delete
+                </button>
+            }
+            else
+            {
+                <button data-testid="save-other-button"
+                        class="text-emerald-400 hover:text-emerald-300"
+                        @onclick="Save">
+                    Save
+                </button>
+
+                <button data-testid="cancel-other-button"
+                        class="text-zinc-500 hover:text-zinc-300"
+                        @onclick="Cancel">
+                    Cancel
+                </button>
+            }
+        </div>
+    </div>
+
+    <div class="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
+
+        <!-- Model -->
+        <div data-testid="other-model-section">
+            <div class="text-zinc-400 mb-1">Model</div>
+
+            @if (_isEditing)
+            {
+                <input data-testid="other-model-input"
+                       class="w-full px-3 py-2 rounded-md bg-zinc-800 text-zinc-100 border border-zinc-600"
+                       @bind="_edit.Model" />
+            }
+            else if (!string.IsNullOrWhiteSpace(Other.Model))
+            {
+                <div class="text-zinc-300"
+                     data-testid="other-model-value">
+                    @Other.Model
+                </div>
+            }
+        </div>
+
+        <!-- Description -->
+        <div data-testid="other-description-section">
+            <div class="text-zinc-400 mb-1">Description</div>
+
+            @if (_isEditing)
+            {
+                <input data-testid="other-description-input"
+                       class="w-full px-3 py-2 rounded-md bg-zinc-800 text-zinc-100 border border-zinc-600"
+                       @bind="_edit.Description" />
+            }
+            else if (!string.IsNullOrWhiteSpace(Other.Description))
+            {
+                <div class="text-zinc-300"
+                     data-testid="other-description-value">
+                    @Other.Description
+                </div>
+            }
+        </div>
+
+        <ResourceTagEditor Resource="Other"
+                           TestIdPrefix="other" />
+
+        <ResourceLabelEditor Resource="Other"
+                             TestIdPrefix="other" />
+
+        <div class="md:col-span-2"
+             data-testid="other-notes-section">
+
+            <div class="text-zinc-400 mb-1">Notes</div>
+
+            @if (_isEditing)
+            {
+                <MarkdownEditor @bind-Value="_edit.Notes"
+                                ShowActionButtons="false"
+                                TestIdPrefix="other-notes-editor" />
+            }
+            else
+            {
+                <MarkdownViewer Value="@Other.Notes"
+                                ShowEditButton="false"
+                                TestIdPrefix="other-notes-viewer" />
+            }
+        </div>
+    </div>
+</div>
+
+<ConfirmModal IsOpen="_confirmDeleteOpen"
+              IsOpenChanged="v => _confirmDeleteOpen = v"
+              Title="Delete Other Hardware"
+              ConfirmText="Delete"
+              ConfirmClass="bg-red-600 hover:bg-red-500"
+              OnConfirm="DeleteOther"
+              TestIdPrefix="Other">
+    Are you sure you want to delete <strong>@Other.Name</strong>?
+</ConfirmModal>
+
+<StringValueModal IsOpen="_renameOpen"
+                  IsOpenChanged="v => _renameOpen = v"
+                  Title="Rename Other Hardware"
+                  Description="Enter a new name for this hardware"
+                  Label="New name"
+                  Value="@Other.Name"
+                  OnSubmit="HandleRenameSubmit"
+                  TestIdPrefix="other-rename" />
+
+<StringValueModal IsOpen="_cloneOpen"
+                  IsOpenChanged="v => _cloneOpen = v"
+                  Title="Clone resource"
+                  Description="Enter a name for the cloned resource"
+                  Label="New resource name"
+                  Value="@($"{Other.Name}-copy")"
+                  OnSubmit="HandleCloneSubmit"
+                  TestIdPrefix="other-clone" />
+
+@code {
+    [Parameter] [EditorRequired] public Other Other { get; set; } = default!;
+
+    [Parameter] public EventCallback<string> 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)}");
+    }
+
+}

+ 32 - 0
Shared.Rcl/OtherHardware/OtherListPage.razor

@@ -0,0 +1,32 @@
+@page "/other/list"
+@using RackPeek.Domain.Resources.OtherHardware
+@inject NavigationManager Nav
+
+<ResourcesListComponent TResource="Other"
+                        Title="Other"
+                        TestId="other"
+                        OnCreated="NavigateToNewResource">
+
+    <ItemTemplate Context="other">
+        <OtherCardComponent Other="other"
+                          OnDeleted="Reload"/>
+    </ItemTemplate>
+
+</ResourcesListComponent>
+
+@code {
+
+    [Inject] IGetAllResourcesByKindUseCase<Other> 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();
+    }
+
+}

+ 17 - 0
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" },