+ @if (IsHardwareKind)
+ {
+
+ }
+
+
+
diff --git a/Shared.Rcl/Firewalls/FirewallCardComponent.razor b/Shared.Rcl/Firewalls/FirewallCardComponent.razor
index f5c69d4c..a2271494 100644
--- a/Shared.Rcl/Firewalls/FirewallCardComponent.razor
+++ b/Shared.Rcl/Firewalls/FirewallCardComponent.razor
@@ -45,6 +45,8 @@
Clone
+
+
diff --git a/Shared.Rcl/Laptops/LaptopCardComponent.razor b/Shared.Rcl/Laptops/LaptopCardComponent.razor
index 36cf01f7..ba2a09c5 100644
--- a/Shared.Rcl/Laptops/LaptopCardComponent.razor
+++ b/Shared.Rcl/Laptops/LaptopCardComponent.razor
@@ -55,6 +55,8 @@
Clone
+
+
diff --git a/Shared.Rcl/Routers/RouterCardComponent.razor b/Shared.Rcl/Routers/RouterCardComponent.razor
index edf6ffe5..c30db462 100644
--- a/Shared.Rcl/Routers/RouterCardComponent.razor
+++ b/Shared.Rcl/Routers/RouterCardComponent.razor
@@ -47,6 +47,8 @@
Clone
+
+
diff --git a/Shared.Rcl/Servers/ServerCardComponent.razor b/Shared.Rcl/Servers/ServerCardComponent.razor
index 0989c654..24f95794 100644
--- a/Shared.Rcl/Servers/ServerCardComponent.razor
+++ b/Shared.Rcl/Servers/ServerCardComponent.razor
@@ -57,6 +57,8 @@
Clone
+
+
+
+
diff --git a/Shared.Rcl/Ups/UpsCardComponent.razor b/Shared.Rcl/Ups/UpsCardComponent.razor
index 5c60ca15..9e4f755a 100644
--- a/Shared.Rcl/Ups/UpsCardComponent.razor
+++ b/Shared.Rcl/Ups/UpsCardComponent.razor
@@ -40,6 +40,8 @@
Clone
+
+
diff --git a/Tests.E2e/PageObjectModels/ServerCardPom.cs b/Tests.E2e/PageObjectModels/ServerCardPom.cs
index 90210db5..713f0dd4 100644
--- a/Tests.E2e/PageObjectModels/ServerCardPom.cs
+++ b/Tests.E2e/PageObjectModels/ServerCardPom.cs
@@ -33,6 +33,9 @@ public ILocator CloneButton(string name)
public ILocator DeleteButton(string name)
=> ServerItem(name).GetByTestId("delete-server-button");
+ public ILocator CopyAsTemplateButton(string name)
+ => ServerItem(name).GetByTestId("server-copy-as-template-button");
+
// -------------------------------------------------
// CPU section + modal (TestIdPrefix="server-cpu")
// -------------------------------------------------
@@ -134,6 +137,14 @@ public ILocator EditCpuButton(string name, string cpuDisplayKey)
public ILocator CloneAccept => page.GetByTestId("server-clone-string-value-modal-submit");
public ILocator CloneCancel => page.GetByTestId("server-clone-string-value-modal-cancel");
+ // -------------------------------------------------
+ // Copy-as-template modal (TestIdPrefix="server-copy-template")
+ // -------------------------------------------------
+
+ public ILocator CopyTemplateModal => page.GetByTestId("server-copy-template-string-value-modal");
+ public ILocator CopyTemplateInput => page.GetByTestId("server-copy-template-string-value-modal-input");
+ public ILocator CopyTemplateSubmit => page.GetByTestId("server-copy-template-string-value-modal-submit");
+
// -------------------------------------------------
// Helpers / Common actions
// -------------------------------------------------
diff --git a/Tests.E2e/ServerCardTests.cs b/Tests.E2e/ServerCardTests.cs
index 656953a7..586c5c96 100644
--- a/Tests.E2e/ServerCardTests.cs
+++ b/Tests.E2e/ServerCardTests.cs
@@ -1,3 +1,4 @@
+using Microsoft.Playwright;
using Tests.E2e.Infra;
using Tests.E2e.PageObjectModels;
using Xunit.Abstractions;
@@ -273,4 +274,92 @@ public async Task User_Can_Edit_Label_From_Server_Card()
await context.CloseAsync();
}
}
+
+ [Fact]
+ public async Task User_Can_Copy_Server_As_Template()
+ {
+ var context = await fixture.Browser.NewContextAsync(new BrowserNewContextOptions
+ {
+ Permissions = ["clipboard-read", "clipboard-write"]
+ });
+ var page = await context.NewPageAsync();
+
+ page.Console += (_, msg) =>
+ _output.WriteLine($"[BrowserConsole] {msg.Type}: {msg.Text}");
+ page.PageError += (_, msg) =>
+ _output.WriteLine($"[PageError] {msg}");
+
+ var name = $"e2e-srv-tpl-{Guid.NewGuid():N}"[..16];
+
+ try
+ {
+ await page.GotoAsync(fixture.BaseUrl);
+
+ var layout = new MainLayoutPom(page);
+ await layout.AssertLoadedAsync();
+ await layout.GotoHardwareAsync();
+
+ var hardwareTree = new HardwareTreePom(page);
+ await hardwareTree.AssertLoadedAsync();
+ await hardwareTree.GotoServersListAsync();
+
+ var list = new ServersListPom(page);
+ await list.AssertLoadedAsync();
+
+ await list.AddServerAsync(name);
+ await page.WaitForURLAsync($"**/resources/hardware/{name}");
+
+ var card = new ServerCardPom(page);
+ await card.AssertVisibleAsync(name);
+
+ var officialName = "Official-Server-Template";
+
+ // Button should be visible
+ await Assertions.Expect(card.CopyAsTemplateButton(name)).ToBeVisibleAsync();
+ await Assertions.Expect(card.CopyAsTemplateButton(name)).ToHaveTextAsync("Copy as Template");
+
+ // Click the button — opens the template name modal
+ await card.CopyAsTemplateButton(name).ClickAsync();
+
+ // Modal should appear
+ await Assertions.Expect(card.CopyTemplateModal).ToBeVisibleAsync();
+
+ // Fill in the official hardware name and submit
+ await card.CopyTemplateInput.FillAsync(officialName);
+ await card.CopyTemplateSubmit.ClickAsync();
+
+ // Button text should change to "Copied!"
+ await Assertions.Expect(card.CopyAsTemplateButton(name)).ToHaveTextAsync("Copied!");
+
+ // Verify clipboard contains valid template YAML with the official name
+ var clipboardText = await page.EvaluateAsync("() => navigator.clipboard.readText()");
+
+ Assert.Contains("kind: Server", clipboardText);
+ Assert.Contains($"name: {officialName}", clipboardText);
+ Assert.DoesNotContain($"name: {name}", clipboardText);
+ Assert.DoesNotContain("tags:", clipboardText);
+ Assert.DoesNotContain("labels:", clipboardText);
+ Assert.DoesNotContain("runsOn:", clipboardText);
+
+ // After delay, button text should revert
+ await page.WaitForTimeoutAsync(2500);
+ await Assertions.Expect(card.CopyAsTemplateButton(name)).ToHaveTextAsync("Copy as Template");
+ }
+ catch (Exception)
+ {
+ _output.WriteLine("TEST FAILED — Capturing diagnostics");
+ _output.WriteLine($"Current URL: {page.Url}");
+
+ var html = await page.ContentAsync();
+ _output.WriteLine("==== DOM SNAPSHOT START ====");
+ _output.WriteLine(html);
+ _output.WriteLine("==== DOM SNAPSHOT END ====");
+
+ throw;
+ }
+ finally
+ {
+ await context.CloseAsync();
+ }
+ }
}
diff --git a/Tests/EndToEnd/TemplateTests/TemplateWorkflowTests.cs b/Tests/EndToEnd/TemplateTests/TemplateWorkflowTests.cs
new file mode 100644
index 00000000..a2042890
--- /dev/null
+++ b/Tests/EndToEnd/TemplateTests/TemplateWorkflowTests.cs
@@ -0,0 +1,382 @@
+using Tests.EndToEnd.Infra;
+using Xunit.Abstractions;
+
+namespace Tests.EndToEnd.TemplateTests;
+
+[Collection("Yaml CLI tests")]
+public class TemplateWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper)
+ : IClassFixture
+{
+ private async Task<(string Output, string Yaml)> ExecuteAsync(params string[] args)
+ {
+ outputHelper.WriteLine($"rpk {string.Join(" ", args)}");
+
+ var output = await YamlCliTestHost.RunAsync(
+ args,
+ fs.Root,
+ outputHelper,
+ "config.yaml"
+ );
+
+ outputHelper.WriteLine(output);
+
+ var yaml = await File.ReadAllTextAsync(Path.Combine(fs.Root, "config.yaml"));
+ return (output, yaml);
+ }
+
+ [Fact]
+ public async Task template_list__returns_bundled_templates()
+ {
+ // Arrange
+ await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), "");
+
+ // Act
+ var (output, _) = await ExecuteAsync("templates", "list");
+
+ // Assert — should contain at least some known templates
+ Assert.Contains("Switch", output);
+ Assert.Contains("UniFi-USW-Enterprise-24", output);
+ Assert.Contains("Router", output);
+ Assert.Contains("Firewall", output);
+ }
+
+ [Fact]
+ public async Task template_list__filter_by_kind__returns_only_matching()
+ {
+ // Arrange
+ await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), "");
+
+ // Act
+ var (output, _) = await ExecuteAsync("templates", "list", "--kind", "Switch");
+
+ // Assert
+ Assert.Contains("Switch", output);
+ Assert.DoesNotContain("Router", output);
+ Assert.DoesNotContain("Firewall", output);
+ }
+
+ [Fact]
+ public async Task template_show__existing_template__shows_details()
+ {
+ // Arrange
+ await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), "");
+
+ // Act
+ var (output, _) = await ExecuteAsync("templates", "show", "Switch/UniFi-USW-Enterprise-24");
+
+ // Assert
+ Assert.Contains("Switch/UniFi-USW-Enterprise-24", output);
+ Assert.Contains("Switch", output);
+ Assert.Contains("UniFi-USW-Enterprise-24", output);
+ }
+
+ [Fact]
+ public async Task template_show__nonexistent_template__returns_error()
+ {
+ // Arrange
+ await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), "");
+
+ // Act
+ var (output, _) = await ExecuteAsync("templates", "show", "Switch/DoesNotExist");
+
+ // Assert
+ Assert.Contains("not found", output, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task switch_add_with_template__creates_prefilled_switch()
+ {
+ // Arrange
+ await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), "");
+
+ // Act
+ var (output, yaml) = await ExecuteAsync(
+ "switches", "add", "core-switch", "--template", "UniFi-USW-Enterprise-24");
+
+ // Assert
+ Assert.Equal("Switch 'core-switch' added.\n", output);
+ Assert.Contains("name: core-switch", yaml);
+ Assert.Contains("model: UniFi-USW-Enterprise-24", yaml);
+ Assert.Contains("managed: true", yaml);
+ Assert.Contains("poe: true", yaml);
+ }
+
+ [Fact]
+ public async Task router_add_with_template__creates_prefilled_router()
+ {
+ // Arrange
+ await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), "");
+
+ // Act
+ var (output, yaml) = await ExecuteAsync(
+ "routers", "add", "edge-router", "--template", "Ubiquiti-ER-4");
+
+ // Assert
+ Assert.Equal("Router 'edge-router' added.\n", output);
+ Assert.Contains("name: edge-router", yaml);
+ Assert.Contains("model: Ubiquiti-ER-4", yaml);
+ Assert.Contains("managed: true", yaml);
+ }
+
+ [Fact]
+ public async Task firewall_add_with_template__creates_prefilled_firewall()
+ {
+ // Arrange
+ await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), "");
+
+ // Act
+ var (output, yaml) = await ExecuteAsync(
+ "firewalls", "add", "main-fw", "--template", "Netgate-6100");
+
+ // Assert
+ Assert.Equal("Firewall 'main-fw' added.\n", output);
+ Assert.Contains("name: main-fw", yaml);
+ Assert.Contains("model: Netgate-6100", yaml);
+ }
+
+ [Fact]
+ public async Task switch_add_with_template__describe_shows_ports()
+ {
+ // Arrange
+ await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), "");
+
+ // Act — create with template then describe
+ await ExecuteAsync("switches", "add", "test-sw", "--template", "UniFi-USW-16-PoE");
+ var (output, _) = await ExecuteAsync("switches", "describe", "test-sw");
+
+ // Assert — describe output should contain the template's port data
+ Assert.Contains("test-sw", output);
+ Assert.Contains("UniFi-USW-16-PoE", output);
+ }
+
+ [Fact]
+ public async Task switch_add_without_template__creates_blank()
+ {
+ // Arrange
+ await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), "");
+
+ // Act
+ var (output, yaml) = await ExecuteAsync("switches", "add", "blank-switch");
+
+ // Assert
+ Assert.Equal("Switch 'blank-switch' added.\n", output);
+ Assert.Contains("name: blank-switch", yaml);
+ Assert.DoesNotContain("model:", yaml);
+ }
+
+ [Fact]
+ public async Task switch_add_with_invalid_template__returns_error()
+ {
+ // Arrange
+ await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), "");
+
+ // Act
+ var (output, _) = await ExecuteAsync(
+ "switches", "add", "bad-switch", "--template", "NonExistentModel");
+
+ // Assert
+ Assert.Contains("not found", output, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task switch_add_with_template__duplicate_name__returns_error()
+ {
+ // Arrange
+ await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), "");
+ await ExecuteAsync("switches", "add", "dupe-switch");
+
+ // Act
+ var (output, _) = await ExecuteAsync(
+ "switches", "add", "dupe-switch", "--template", "UniFi-USW-Enterprise-24");
+
+ // Assert
+ Assert.Contains("already exists", output, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task server_add_with_template__creates_prefilled_server()
+ {
+ // Arrange
+ await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), "");
+
+ // Act
+ var (output, yaml) = await ExecuteAsync(
+ "servers", "add", "home-nuc", "--template", "Intel-NUC-13-Pro");
+
+ // Assert
+ Assert.Equal("Server 'home-nuc' added.\n", output);
+ Assert.Contains("name: home-nuc", yaml);
+ Assert.Contains("Intel Core i7-1360P", yaml);
+ Assert.Contains("size: 32", yaml);
+ Assert.Contains("type: nvme", yaml);
+ Assert.Contains("ipmi: false", yaml);
+ }
+
+ [Fact]
+ public async Task template_list__includes_server_templates()
+ {
+ // Arrange
+ await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), "");
+
+ // Act
+ var (output, _) = await ExecuteAsync("templates", "list");
+
+ // Assert
+ Assert.Contains("Server", output);
+ Assert.Contains("Intel-NUC-13-Pro", output);
+ }
+
+ [Fact]
+ public async Task template_show__server_template__shows_details()
+ {
+ // Arrange
+ await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), "");
+
+ // Act
+ var (output, _) = await ExecuteAsync("templates", "show", "Server/Intel-NUC-13-Pro");
+
+ // Assert
+ Assert.Contains("Server/Intel-NUC-13-Pro", output);
+ Assert.Contains("Server", output);
+ Assert.Contains("Intel-NUC-13-Pro", output);
+ Assert.Contains("IPMI", output, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("RAM", output, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("CPU", output, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task template_validate__valid_server__passes()
+ {
+ // Arrange
+ await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), "");
+ var templatePath = Path.Combine(fs.Root, "valid-server.yaml");
+ await File.WriteAllTextAsync(templatePath, """
+ kind: Server
+ name: test-server
+ cpus:
+ - model: Intel N100
+ cores: 4
+ threads: 4
+ ram:
+ size: 16
+ mts: 3200
+ drives:
+ - type: nvme
+ size: 512
+ nics:
+ - type: rj45
+ speed: 1
+ ports: 1
+ ipmi: false
+ """);
+
+ // Act
+ var (output, _) = await ExecuteAsync("templates", "validate", templatePath);
+
+ // Assert
+ Assert.Contains("Valid", output);
+ }
+
+ [Fact]
+ public async Task template_validate__valid_switch__passes()
+ {
+ // Arrange
+ await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), "");
+ var templatePath = Path.Combine(fs.Root, "valid-switch.yaml");
+ await File.WriteAllTextAsync(templatePath, """
+ kind: Switch
+ name: test-switch
+ model: Test-24
+ managed: true
+ poe: true
+ ports:
+ - type: rj45
+ speed: 1
+ count: 24
+ """);
+
+ // Act
+ var (output, _) = await ExecuteAsync("templates", "validate", templatePath);
+
+ // Assert
+ Assert.Contains("Valid", output);
+ }
+
+ [Fact]
+ public async Task template_validate__invalid_kind__reports_error()
+ {
+ // Arrange
+ await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), "");
+ var templatePath = Path.Combine(fs.Root, "bad-kind.yaml");
+ await File.WriteAllTextAsync(templatePath, """
+ kind: Toaster
+ name: not-real
+ """);
+
+ // Act
+ var (output, _) = await ExecuteAsync("templates", "validate", templatePath);
+
+ // Assert
+ Assert.Contains("Invalid", output);
+ Assert.Contains("kind", output, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task template_validate__invalid_drive_type__reports_error()
+ {
+ // Arrange
+ await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), "");
+ var templatePath = Path.Combine(fs.Root, "bad-drive.yaml");
+ await File.WriteAllTextAsync(templatePath, """
+ kind: Server
+ name: bad-drive
+ drives:
+ - type: floppy
+ size: 1
+ """);
+
+ // Act
+ var (output, _) = await ExecuteAsync("templates", "validate", templatePath);
+
+ // Assert
+ Assert.Contains("Invalid", output);
+ Assert.Contains("floppy", output);
+ }
+
+ [Fact]
+ public async Task template_validate__missing_file__reports_error()
+ {
+ // Arrange
+ await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), "");
+
+ // Act
+ var (output, _) = await ExecuteAsync(
+ "templates", "validate", Path.Combine(fs.Root, "nonexistent.yaml"));
+
+ // Assert
+ Assert.Contains("not found", output, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task template_validate__switch_missing_model__reports_error()
+ {
+ // Arrange
+ await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), "");
+ var templatePath = Path.Combine(fs.Root, "no-model.yaml");
+ await File.WriteAllTextAsync(templatePath, """
+ kind: Switch
+ name: no-model
+ ports:
+ - type: rj45
+ speed: 1
+ count: 8
+ """);
+
+ // Act
+ var (output, _) = await ExecuteAsync("templates", "validate", templatePath);
+
+ // Assert
+ Assert.Contains("Invalid", output);
+ Assert.Contains("model", output, StringComparison.OrdinalIgnoreCase);
+ }
+}
diff --git a/Tests/Tests.csproj b/Tests/Tests.csproj
index c9630a76..da50e10a 100644
--- a/Tests/Tests.csproj
+++ b/Tests/Tests.csproj
@@ -1,4 +1,4 @@
-
+
net10.0
@@ -36,5 +36,6 @@
+
\ No newline at end of file
diff --git a/Tests/Unit/Helpers/ResourceTemplateSerializerTests.cs b/Tests/Unit/Helpers/ResourceTemplateSerializerTests.cs
new file mode 100644
index 00000000..58974824
--- /dev/null
+++ b/Tests/Unit/Helpers/ResourceTemplateSerializerTests.cs
@@ -0,0 +1,290 @@
+using RackPeek.Domain.Helpers;
+using RackPeek.Domain.Resources.AccessPoints;
+using RackPeek.Domain.Resources.Desktops;
+using RackPeek.Domain.Resources.Firewalls;
+using RackPeek.Domain.Resources.Laptops;
+using RackPeek.Domain.Resources.Routers;
+using RackPeek.Domain.Resources.Servers;
+using RackPeek.Domain.Resources.SubResources;
+using RackPeek.Domain.Resources.Switches;
+using RackPeek.Domain.Resources.UpsUnits;
+
+namespace Tests.Unit.Helpers;
+
+public class ResourceTemplateSerializerTests
+{
+ [Fact]
+ public void serialize__server__includes_kind_name_cpus_ram_drives_nics()
+ {
+ var server = new Server
+ {
+ Name = "my-server",
+ Kind = "Server",
+ Ipmi = true,
+ Ram = new Ram { Size = 64, Mts = 5200 },
+ Cpus = [new Cpu { Model = "Intel i9-13900H", Cores = 14, Threads = 20 }],
+ Drives = [new Drive { Type = "nvme", Size = 1024 }],
+ Nics = [new Nic { Type = "rj45", Speed = 2.5, Ports = 2 }],
+ Gpus = [new Gpu { Model = "Intel Iris Xe", Vram = 0 }]
+ };
+
+ var yaml = ResourceTemplateSerializer.Serialize(server);
+
+ Assert.StartsWith("kind: Server", yaml);
+ Assert.Contains("name: my-server", yaml);
+ Assert.Contains("ipmi: true", yaml);
+ Assert.Contains("model: Intel i9-13900H", yaml);
+ Assert.Contains("cores: 14", yaml);
+ Assert.Contains("threads: 20", yaml);
+ Assert.Contains("size: 64", yaml);
+ Assert.Contains("mts: 5200", yaml);
+ Assert.Contains("type: nvme", yaml);
+ Assert.Contains("speed: 2.5", yaml);
+ Assert.Contains("ports: 2", yaml);
+ Assert.Contains("model: Intel Iris Xe", yaml);
+ Assert.Contains("vram: 0", yaml);
+ }
+
+ [Fact]
+ public void serialize__switch__includes_kind_model_ports_managed_poe()
+ {
+ var sw = new Switch
+ {
+ Name = "core-switch",
+ Kind = "Switch",
+ Model = "USW-Enterprise-24",
+ Managed = true,
+ Poe = true,
+ Ports =
+ [
+ new Port { Type = "rj45", Speed = 1, Count = 24 },
+ new Port { Type = "sfp+", Speed = 10, Count = 4 }
+ ]
+ };
+
+ var yaml = ResourceTemplateSerializer.Serialize(sw);
+
+ Assert.StartsWith("kind: Switch", yaml);
+ Assert.Contains("name: core-switch", yaml);
+ Assert.Contains("model: USW-Enterprise-24", yaml);
+ Assert.Contains("managed: true", yaml);
+ Assert.Contains("poe: true", yaml);
+ Assert.Contains("type: rj45", yaml);
+ Assert.Contains("count: 24", yaml);
+ Assert.Contains("type: sfp+", yaml);
+ Assert.Contains("count: 4", yaml);
+ }
+
+ [Fact]
+ public void serialize__strips_tags_labels_notes_runsOn()
+ {
+ var server = new Server
+ {
+ Name = "tagged-server",
+ Kind = "Server",
+ Tags = ["production", "rack-1"],
+ Labels = new Dictionary { ["env"] = "prod", ["dc"] = "us-east" },
+ Notes = "These are some notes about the server.",
+ RunsOn = ["host-1"],
+ Cpus = [new Cpu { Model = "AMD EPYC", Cores = 64, Threads = 128 }]
+ };
+
+ var yaml = ResourceTemplateSerializer.Serialize(server);
+
+ Assert.DoesNotContain("tags:", yaml);
+ Assert.DoesNotContain("production", yaml);
+ Assert.DoesNotContain("labels:", yaml);
+ Assert.DoesNotContain("env", yaml);
+ Assert.DoesNotContain("notes:", yaml);
+ Assert.DoesNotContain("These are some notes", yaml);
+ Assert.DoesNotContain("runsOn:", yaml);
+ Assert.DoesNotContain("host-1", yaml);
+ Assert.Contains("name: tagged-server", yaml);
+ Assert.Contains("model: AMD EPYC", yaml);
+ }
+
+ [Fact]
+ public void serialize__kind_appears_first()
+ {
+ var router = new Router
+ {
+ Name = "edge-router",
+ Kind = "Router",
+ Model = "ER-8",
+ Managed = true,
+ Ports = [new Port { Type = "rj45", Speed = 1, Count = 8 }]
+ };
+
+ var yaml = ResourceTemplateSerializer.Serialize(router);
+ var lines = yaml.Split('\n', StringSplitOptions.RemoveEmptyEntries);
+
+ Assert.StartsWith("kind:", lines[0]);
+ Assert.StartsWith("name:", lines[1]);
+ }
+
+ [Fact]
+ public void serialize__empty_collections_omitted()
+ {
+ var server = new Server
+ {
+ Name = "bare-server",
+ Kind = "Server",
+ Cpus = null,
+ Drives = null,
+ Nics = null,
+ Gpus = null,
+ Ram = null,
+ Ipmi = null
+ };
+
+ var yaml = ResourceTemplateSerializer.Serialize(server);
+
+ Assert.Contains("kind: Server", yaml);
+ Assert.Contains("name: bare-server", yaml);
+ Assert.DoesNotContain("cpus:", yaml);
+ Assert.DoesNotContain("drives:", yaml);
+ Assert.DoesNotContain("nics:", yaml);
+ Assert.DoesNotContain("gpus:", yaml);
+ Assert.DoesNotContain("ram:", yaml);
+ Assert.DoesNotContain("ipmi:", yaml);
+ }
+
+ [Fact]
+ public void serialize__firewall__includes_type_specific_fields()
+ {
+ var fw = new Firewall
+ {
+ Name = "perimeter-fw",
+ Kind = "Firewall",
+ Model = "Netgate-6100",
+ Managed = true,
+ Poe = false,
+ Ports = [new Port { Type = "sfp+", Speed = 10, Count = 2 }]
+ };
+
+ var yaml = ResourceTemplateSerializer.Serialize(fw);
+
+ Assert.StartsWith("kind: Firewall", yaml);
+ Assert.Contains("model: Netgate-6100", yaml);
+ Assert.Contains("managed: true", yaml);
+ Assert.Contains("poe: false", yaml);
+ Assert.Contains("type: sfp+", yaml);
+ }
+
+ [Fact]
+ public void serialize__accesspoint__includes_speed()
+ {
+ var ap = new AccessPoint
+ {
+ Name = "lobby-ap",
+ Kind = "AccessPoint",
+ Model = "U6-Pro",
+ Speed = 2.5
+ };
+
+ var yaml = ResourceTemplateSerializer.Serialize(ap);
+
+ Assert.StartsWith("kind: Accesspoint", yaml);
+ Assert.Contains("model: U6-Pro", yaml);
+ Assert.Contains("speed: 2.5", yaml);
+ }
+
+ [Fact]
+ public void serialize__ups__includes_va()
+ {
+ var ups = new Ups
+ {
+ Name = "rack-ups",
+ Kind = "Ups",
+ Model = "APC-2200",
+ Va = 2200
+ };
+
+ var yaml = ResourceTemplateSerializer.Serialize(ups);
+
+ Assert.StartsWith("kind: Ups", yaml);
+ Assert.Contains("model: APC-2200", yaml);
+ Assert.Contains("va: 2200", yaml);
+ }
+
+ [Fact]
+ public void serialize__desktop__includes_hardware_details()
+ {
+ var desktop = new Desktop
+ {
+ Name = "dev-desktop",
+ Kind = "Desktop",
+ Model = "OptiPlex-7090",
+ Cpus = [new Cpu { Model = "Intel i7-11700", Cores = 8, Threads = 16 }],
+ Ram = new Ram { Size = 32, Mts = 3200 }
+ };
+
+ var yaml = ResourceTemplateSerializer.Serialize(desktop);
+
+ Assert.StartsWith("kind: Desktop", yaml);
+ Assert.Contains("model: OptiPlex-7090", yaml);
+ Assert.Contains("model: Intel i7-11700", yaml);
+ Assert.Contains("size: 32", yaml);
+ }
+
+ [Fact]
+ public void serialize__laptop__includes_hardware_details()
+ {
+ var laptop = new Laptop
+ {
+ Name = "work-laptop",
+ Kind = "Laptop",
+ Model = "ThinkPad-X1",
+ Cpus = [new Cpu { Model = "Intel i7-1365U", Cores = 10, Threads = 12 }],
+ Drives = [new Drive { Type = "nvme", Size = 512 }]
+ };
+
+ var yaml = ResourceTemplateSerializer.Serialize(laptop);
+
+ Assert.StartsWith("kind: Laptop", yaml);
+ Assert.Contains("model: ThinkPad-X1", yaml);
+ Assert.Contains("type: nvme", yaml);
+ Assert.Contains("size: 512", yaml);
+ }
+
+ [Fact]
+ public void serialize__does_not_mutate_original_resource()
+ {
+ var server = new Server
+ {
+ Name = "original",
+ Kind = "Server",
+ Tags = ["keep-this"],
+ Labels = new Dictionary { ["keep"] = "this" },
+ Notes = "keep these notes",
+ RunsOn = ["host-1"],
+ Cpus = [new Cpu { Model = "Test", Cores = 4, Threads = 8 }]
+ };
+
+ ResourceTemplateSerializer.Serialize(server);
+
+ Assert.Single(server.Tags);
+ Assert.Equal("keep-this", server.Tags[0]);
+ Assert.Single(server.Labels);
+ Assert.Equal("keep these notes", server.Notes);
+ Assert.Single(server.RunsOn);
+ }
+
+ [Fact]
+ public void serialize__with_template_name__overrides_resource_name()
+ {
+ var server = new Server
+ {
+ Name = "my-personal-server",
+ Kind = "Server",
+ Cpus = [new Cpu { Model = "Intel i5-1240P", Cores = 12, Threads = 16 }]
+ };
+
+ var yaml = ResourceTemplateSerializer.Serialize(server, templateName: "Official-Model-X");
+
+ Assert.Contains("name: Official-Model-X", yaml);
+ Assert.DoesNotContain("my-personal-server", yaml);
+ Assert.Contains("kind: Server", yaml);
+ Assert.Contains("model: Intel i5-1240P", yaml);
+ }
+}
diff --git a/Tests/Unit/Templates/BundledHardwareTemplateStoreTests.cs b/Tests/Unit/Templates/BundledHardwareTemplateStoreTests.cs
new file mode 100644
index 00000000..77571361
--- /dev/null
+++ b/Tests/Unit/Templates/BundledHardwareTemplateStoreTests.cs
@@ -0,0 +1,335 @@
+using RackPeek.Domain.Resources.Servers;
+using RackPeek.Domain.Resources.Switches;
+using RackPeek.Domain.Resources.Routers;
+using RackPeek.Domain.Resources.Firewalls;
+using RackPeek.Domain.Resources.AccessPoints;
+using RackPeek.Domain.Resources.UpsUnits;
+using RackPeek.Domain.Templates;
+
+namespace Tests.Unit.Templates;
+
+public class BundledHardwareTemplateStoreTests : IDisposable
+{
+ private readonly string _tempDir;
+
+ public BundledHardwareTemplateStoreTests()
+ {
+ _tempDir = Path.Combine(Path.GetTempPath(), "rackpeek-template-tests", Guid.NewGuid().ToString());
+ Directory.CreateDirectory(_tempDir);
+ }
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_tempDir))
+ Directory.Delete(_tempDir, true);
+ }
+
+ private void WriteTemplate(string kindPlural, string fileName, string yaml)
+ {
+ var dir = Path.Combine(_tempDir, kindPlural);
+ Directory.CreateDirectory(dir);
+ File.WriteAllText(Path.Combine(dir, fileName), yaml);
+ }
+
+ [Fact]
+ public async Task get_all_async__with_templates__returns_all()
+ {
+ // Arrange
+ WriteTemplate("switches", "test-switch.yaml", """
+ kind: Switch
+ name: test-switch
+ model: TestSwitch-24
+ managed: true
+ poe: true
+ ports:
+ - type: rj45
+ speed: 1
+ count: 24
+ """);
+ WriteTemplate("routers", "test-router.yaml", """
+ kind: Router
+ name: test-router
+ model: TestRouter-4
+ managed: true
+ poe: false
+ ports:
+ - type: rj45
+ speed: 1
+ count: 4
+ """);
+ var sut = new BundledHardwareTemplateStore(_tempDir);
+
+ // Act
+ var templates = await sut.GetAllAsync();
+
+ // Assert
+ Assert.Equal(2, templates.Count);
+ }
+
+ [Fact]
+ public async Task get_all_by_kind_async__filters_by_kind()
+ {
+ // Arrange
+ WriteTemplate("switches", "sw1.yaml", """
+ kind: Switch
+ name: sw1
+ model: Switch-A
+ ports:
+ - type: rj45
+ speed: 1
+ count: 8
+ """);
+ WriteTemplate("routers", "rt1.yaml", """
+ kind: Router
+ name: rt1
+ model: Router-A
+ ports:
+ - type: rj45
+ speed: 1
+ count: 4
+ """);
+ var sut = new BundledHardwareTemplateStore(_tempDir);
+
+ // Act
+ var switches = await sut.GetAllByKindAsync("Switch");
+ var routers = await sut.GetAllByKindAsync("Router");
+
+ // Assert
+ Assert.Single(switches);
+ Assert.Equal("Switch-A", switches[0].Model);
+ Assert.Single(routers);
+ Assert.Equal("Router-A", routers[0].Model);
+ }
+
+ [Fact]
+ public async Task get_by_id_async__existing_template__returns_template()
+ {
+ // Arrange
+ WriteTemplate("firewalls", "Netgate-6100.yaml", """
+ kind: Firewall
+ name: Netgate-6100
+ model: Netgate-6100
+ managed: true
+ poe: false
+ ports:
+ - type: rj45
+ speed: 1
+ count: 4
+ - type: sfp+
+ speed: 10
+ count: 2
+ """);
+ var sut = new BundledHardwareTemplateStore(_tempDir);
+
+ // Act
+ var template = await sut.GetByIdAsync("Firewall/Netgate-6100");
+
+ // Assert
+ Assert.NotNull(template);
+ Assert.Equal("Firewall", template.Kind);
+ Assert.Equal("Netgate-6100", template.Model);
+ Assert.IsType(template.Spec);
+ var fw = (Firewall)template.Spec;
+ Assert.True(fw.Managed);
+ Assert.Equal(2, fw.Ports!.Count);
+ }
+
+ [Fact]
+ public async Task get_by_id_async__nonexistent_template__returns_null()
+ {
+ // Arrange
+ var sut = new BundledHardwareTemplateStore(_tempDir);
+
+ // Act
+ var template = await sut.GetByIdAsync("Switch/DoesNotExist");
+
+ // Assert
+ Assert.Null(template);
+ }
+
+ [Fact]
+ public async Task get_all_async__nonexistent_directory__returns_empty()
+ {
+ // Arrange
+ var sut = new BundledHardwareTemplateStore(Path.Combine(_tempDir, "no-such-dir"));
+
+ // Act
+ var templates = await sut.GetAllAsync();
+
+ // Assert
+ Assert.Empty(templates);
+ }
+
+ [Fact]
+ public async Task get_all_by_kind_async__case_insensitive()
+ {
+ // Arrange
+ WriteTemplate("switches", "sw.yaml", """
+ kind: Switch
+ name: sw
+ model: TestSwitch
+ ports:
+ - type: rj45
+ speed: 1
+ count: 4
+ """);
+ var sut = new BundledHardwareTemplateStore(_tempDir);
+
+ // Act
+ var result = await sut.GetAllByKindAsync("switch");
+
+ // Assert
+ Assert.Single(result);
+ }
+
+ [Fact]
+ public async Task switch_template__preserves_port_details()
+ {
+ // Arrange
+ WriteTemplate("switches", "usw.yaml", """
+ kind: Switch
+ name: usw
+ model: USW-Enterprise
+ managed: true
+ poe: true
+ ports:
+ - type: rj45
+ speed: 1
+ count: 12
+ - type: sfp+
+ speed: 10
+ count: 4
+ """);
+ var sut = new BundledHardwareTemplateStore(_tempDir);
+
+ // Act
+ var template = await sut.GetByIdAsync("Switch/USW-Enterprise");
+
+ // Assert
+ Assert.NotNull(template);
+ var sw = Assert.IsType(template.Spec);
+ Assert.Equal(2, sw.Ports!.Count);
+ Assert.Equal("rj45", sw.Ports[0].Type);
+ Assert.Equal(1, sw.Ports[0].Speed);
+ Assert.Equal(12, sw.Ports[0].Count);
+ Assert.Equal("sfp+", sw.Ports[1].Type);
+ Assert.Equal(10, sw.Ports[1].Speed);
+ Assert.Equal(4, sw.Ports[1].Count);
+ }
+
+ [Fact]
+ public async Task accesspoint_template__preserves_speed()
+ {
+ // Arrange
+ WriteTemplate("accesspoints", "u6.yaml", """
+ kind: AccessPoint
+ name: u6
+ model: UniFi-U6-Pro
+ speed: 2.5
+ """);
+ var sut = new BundledHardwareTemplateStore(_tempDir);
+
+ // Act
+ var template = await sut.GetByIdAsync("AccessPoint/UniFi-U6-Pro");
+
+ // Assert
+ Assert.NotNull(template);
+ var ap = Assert.IsType(template.Spec);
+ Assert.Equal(2.5, ap.Speed);
+ }
+
+ [Fact]
+ public async Task ups_template__preserves_va()
+ {
+ // Arrange
+ WriteTemplate("ups", "apc.yaml", """
+ kind: Ups
+ name: apc
+ model: APC-2200
+ va: 2200
+ """);
+ var sut = new BundledHardwareTemplateStore(_tempDir);
+
+ // Act
+ var template = await sut.GetByIdAsync("Ups/APC-2200");
+
+ // Assert
+ Assert.NotNull(template);
+ var ups = Assert.IsType(template.Spec);
+ Assert.Equal(2200, ups.Va);
+ }
+
+ [Fact]
+ public async Task server_template__preserves_cpu_ram_drives_nics_ipmi()
+ {
+ // Arrange
+ WriteTemplate("servers", "Intel-NUC-13-Pro.yaml", """
+ kind: Server
+ name: Intel-NUC-13-Pro
+ cpus:
+ - model: Intel Core i7-1360P
+ cores: 12
+ threads: 16
+ ram:
+ size: 32
+ mts: 4800
+ drives:
+ - type: nvme
+ size: 1024
+ nics:
+ - type: rj45
+ speed: 2.5
+ ports: 1
+ ipmi: false
+ """);
+ var sut = new BundledHardwareTemplateStore(_tempDir);
+
+ // Act
+ var template = await sut.GetByIdAsync("Server/Intel-NUC-13-Pro");
+
+ // Assert
+ Assert.NotNull(template);
+ Assert.Equal("Server", template.Kind);
+ Assert.Equal("Intel-NUC-13-Pro", template.Model);
+ var server = Assert.IsType(template.Spec);
+ Assert.False(server.Ipmi);
+ Assert.NotNull(server.Ram);
+ Assert.Equal(32, server.Ram.Size);
+ Assert.Equal(4800, server.Ram.Mts);
+ Assert.Single(server.Cpus!);
+ Assert.Equal("Intel Core i7-1360P", server.Cpus![0].Model);
+ Assert.Equal(12, server.Cpus[0].Cores);
+ Assert.Equal(16, server.Cpus[0].Threads);
+ Assert.Single(server.Drives!);
+ Assert.Equal("nvme", server.Drives![0].Type);
+ Assert.Equal(1024, server.Drives[0].Size);
+ Assert.Single(server.Nics!);
+ Assert.Equal("rj45", server.Nics![0].Type);
+ Assert.Equal(2.5, server.Nics[0].Speed);
+ Assert.Equal(1, server.Nics[0].Ports);
+ }
+
+ [Fact]
+ public async Task malformed_yaml__skipped_gracefully()
+ {
+ // Arrange
+ WriteTemplate("switches", "good.yaml", """
+ kind: Switch
+ name: good
+ model: Good-Switch
+ ports:
+ - type: rj45
+ speed: 1
+ count: 8
+ """);
+ WriteTemplate("switches", "bad.yaml", "this is: [not valid yaml: {{}");
+ var sut = new BundledHardwareTemplateStore(_tempDir);
+
+ // Act
+ var templates = await sut.GetAllAsync();
+
+ // Assert
+ Assert.Single(templates);
+ Assert.Equal("Good-Switch", templates[0].Model);
+ }
+}
diff --git a/Tests/Unit/Templates/TemplateValidatorTests.cs b/Tests/Unit/Templates/TemplateValidatorTests.cs
new file mode 100644
index 00000000..f3647453
--- /dev/null
+++ b/Tests/Unit/Templates/TemplateValidatorTests.cs
@@ -0,0 +1,343 @@
+using RackPeek.Domain.Templates;
+
+namespace Tests.Unit.Templates;
+
+public class TemplateValidatorTests
+{
+ private readonly TemplateValidator _sut = new();
+
+ [Fact]
+ public void validate__valid_server__returns_no_errors()
+ {
+ var yaml = """
+ kind: Server
+ name: test-server
+ cpus:
+ - model: Intel Core i7-1360P
+ cores: 12
+ threads: 16
+ ram:
+ size: 32
+ mts: 4800
+ drives:
+ - type: nvme
+ size: 1024
+ nics:
+ - type: rj45
+ speed: 2.5
+ ports: 1
+ ipmi: false
+ """;
+
+ var errors = _sut.Validate(yaml, "test-server.yaml");
+
+ Assert.Empty(errors);
+ }
+
+ [Fact]
+ public void validate__valid_switch__returns_no_errors()
+ {
+ var yaml = """
+ kind: Switch
+ name: test-switch
+ model: USW-Enterprise-24
+ managed: true
+ poe: true
+ ports:
+ - type: rj45
+ speed: 1
+ count: 24
+ """;
+
+ var errors = _sut.Validate(yaml, "test-switch.yaml");
+
+ Assert.Empty(errors);
+ }
+
+ [Fact]
+ public void validate__valid_router__returns_no_errors()
+ {
+ var yaml = """
+ kind: Router
+ name: test-router
+ model: ER-4
+ ports:
+ - type: rj45
+ speed: 1
+ count: 4
+ managed: true
+ poe: false
+ """;
+
+ var errors = _sut.Validate(yaml, "test-router.yaml");
+
+ Assert.Empty(errors);
+ }
+
+ [Fact]
+ public void validate__valid_firewall__returns_no_errors()
+ {
+ var yaml = """
+ kind: Firewall
+ name: test-fw
+ model: Netgate-6100
+ ports:
+ - type: sfp+
+ speed: 10
+ count: 2
+ managed: true
+ poe: false
+ """;
+
+ var errors = _sut.Validate(yaml, "test-fw.yaml");
+
+ Assert.Empty(errors);
+ }
+
+ [Fact]
+ public void validate__valid_accesspoint__returns_no_errors()
+ {
+ var yaml = """
+ kind: AccessPoint
+ name: test-ap
+ model: U6-Pro
+ speed: 2.5
+ """;
+
+ var errors = _sut.Validate(yaml, "test-ap.yaml");
+
+ Assert.Empty(errors);
+ }
+
+ [Fact]
+ public void validate__valid_ups__returns_no_errors()
+ {
+ var yaml = """
+ kind: Ups
+ name: test-ups
+ model: APC-2200
+ va: 2200
+ """;
+
+ var errors = _sut.Validate(yaml, "test-ups.yaml");
+
+ Assert.Empty(errors);
+ }
+
+ [Fact]
+ public void validate__empty_file__returns_error()
+ {
+ var errors = _sut.Validate("", "empty.yaml");
+
+ Assert.Single(errors);
+ Assert.Contains("empty", errors[0], StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void validate__invalid_yaml__returns_parse_error()
+ {
+ var errors = _sut.Validate("this is: [not valid: {{}}", "bad.yaml");
+
+ Assert.Single(errors);
+ Assert.Contains("YAML parsing failed", errors[0]);
+ }
+
+ [Fact]
+ public void validate__missing_kind__returns_error()
+ {
+ var yaml = """
+ name: no-kind
+ model: Something
+ """;
+
+ var errors = _sut.Validate(yaml, "no-kind.yaml");
+
+ Assert.Contains(errors, e => e.Contains("kind", StringComparison.OrdinalIgnoreCase));
+ }
+
+ [Fact]
+ public void validate__invalid_kind__returns_error()
+ {
+ var yaml = """
+ kind: Toaster
+ name: not-real
+ """;
+
+ var errors = _sut.Validate(yaml, "toaster.yaml");
+
+ Assert.Single(errors);
+ Assert.Contains("Invalid kind", errors[0]);
+ }
+
+ [Fact]
+ public void validate__missing_name__returns_error()
+ {
+ var yaml = """
+ kind: Switch
+ model: Test-Switch
+ ports:
+ - type: rj45
+ speed: 1
+ count: 8
+ """;
+
+ var errors = _sut.Validate(yaml, "no-name.yaml");
+
+ Assert.Contains(errors, e => e.Contains("name", StringComparison.OrdinalIgnoreCase));
+ }
+
+ [Fact]
+ public void validate__switch_missing_model__returns_error()
+ {
+ var yaml = """
+ kind: Switch
+ name: no-model
+ ports:
+ - type: rj45
+ speed: 1
+ count: 8
+ """;
+
+ var errors = _sut.Validate(yaml, "no-model.yaml");
+
+ Assert.Contains(errors, e => e.Contains("model", StringComparison.OrdinalIgnoreCase));
+ }
+
+ [Fact]
+ public void validate__server_invalid_drive_type__returns_error()
+ {
+ var yaml = """
+ kind: Server
+ name: bad-drive
+ drives:
+ - type: floppy
+ size: 1
+ """;
+
+ var errors = _sut.Validate(yaml, "bad-drive.yaml");
+
+ Assert.Contains(errors, e => e.Contains("floppy") && e.Contains("invalid type"));
+ }
+
+ [Fact]
+ public void validate__server_invalid_nic_type__returns_error()
+ {
+ var yaml = """
+ kind: Server
+ name: bad-nic
+ nics:
+ - type: coax
+ speed: 10
+ ports: 1
+ """;
+
+ var errors = _sut.Validate(yaml, "bad-nic.yaml");
+
+ Assert.Contains(errors, e => e.Contains("coax") && e.Contains("invalid type"));
+ }
+
+ [Fact]
+ public void validate__switch_invalid_port_type__returns_error()
+ {
+ var yaml = """
+ kind: Switch
+ name: bad-port
+ model: Bad-Switch
+ ports:
+ - type: coax
+ speed: 1
+ count: 4
+ """;
+
+ var errors = _sut.Validate(yaml, "bad-port.yaml");
+
+ Assert.Contains(errors, e => e.Contains("coax") && e.Contains("invalid type"));
+ }
+
+ [Fact]
+ public void validate__server_cpu_missing_model__returns_error()
+ {
+ var yaml = """
+ kind: Server
+ name: no-cpu-model
+ cpus:
+ - cores: 4
+ threads: 8
+ """;
+
+ var errors = _sut.Validate(yaml, "no-cpu-model.yaml");
+
+ Assert.Contains(errors, e => e.Contains("cpus[0]") && e.Contains("model"));
+ }
+
+ [Fact]
+ public void validate__server_gpu_missing_model__returns_error()
+ {
+ var yaml = """
+ kind: Server
+ name: no-gpu-model
+ gpus:
+ - vram: 8
+ """;
+
+ var errors = _sut.Validate(yaml, "no-gpu-model.yaml");
+
+ Assert.Contains(errors, e => e.Contains("gpus[0]") && e.Contains("model"));
+ }
+
+ [Fact]
+ public void validate__ups_missing_model__returns_error()
+ {
+ var yaml = """
+ kind: Ups
+ name: no-model-ups
+ va: 1500
+ """;
+
+ var errors = _sut.Validate(yaml, "no-model-ups.yaml");
+
+ Assert.Contains(errors, e => e.Contains("model", StringComparison.OrdinalIgnoreCase));
+ }
+
+ [Fact]
+ public void validate__accesspoint_missing_model__returns_error()
+ {
+ var yaml = """
+ kind: AccessPoint
+ name: no-model-ap
+ speed: 1
+ """;
+
+ var errors = _sut.Validate(yaml, "no-model-ap.yaml");
+
+ Assert.Contains(errors, e => e.Contains("model", StringComparison.OrdinalIgnoreCase));
+ }
+
+ [Fact]
+ public void validate__multiple_errors__returns_all()
+ {
+ var yaml = """
+ kind: Server
+ name: multi-error
+ cpus:
+ - cores: 4
+ drives:
+ - type: floppy
+ size: 1
+ nics:
+ - type: coax
+ speed: 1
+ ports: 1
+ gpus:
+ - vram: 4
+ """;
+
+ var errors = _sut.Validate(yaml, "multi.yaml");
+
+ Assert.True(errors.Count >= 4);
+ Assert.Contains(errors, e => e.Contains("cpus[0]"));
+ Assert.Contains(errors, e => e.Contains("drives[0]"));
+ Assert.Contains(errors, e => e.Contains("nics[0]"));
+ Assert.Contains(errors, e => e.Contains("gpus[0]"));
+ }
+}
diff --git a/Tests/Unit/UseCases/AddResourceFromTemplateUseCaseTests.cs b/Tests/Unit/UseCases/AddResourceFromTemplateUseCaseTests.cs
new file mode 100644
index 00000000..841419fe
--- /dev/null
+++ b/Tests/Unit/UseCases/AddResourceFromTemplateUseCaseTests.cs
@@ -0,0 +1,166 @@
+using System.ComponentModel.DataAnnotations;
+using NSubstitute;
+using RackPeek.Domain.Helpers;
+using RackPeek.Domain.Persistence;
+using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.Switches;
+using RackPeek.Domain.Resources.Routers;
+using RackPeek.Domain.Resources.SubResources;
+using RackPeek.Domain.Templates;
+using RackPeek.Domain.UseCases;
+
+namespace Tests.Unit.UseCases;
+
+public class AddResourceFromTemplateUseCaseTests
+{
+ private readonly IResourceCollection _repo = Substitute.For();
+ private readonly IHardwareTemplateStore _templateStore = Substitute.For();
+
+ private HardwareTemplate CreateSwitchTemplate(string model = "TestSwitch-24")
+ {
+ var spec = new Switch
+ {
+ Name = model,
+ Kind = "Switch",
+ Model = model,
+ Managed = true,
+ Poe = true,
+ Ports =
+ [
+ new Port { Type = "rj45", Speed = 1, Count = 24 }
+ ]
+ };
+ return new HardwareTemplate($"Switch/{model}", "Switch", model, spec);
+ }
+
+ [Fact]
+ public async Task execute_async__valid_template__creates_resource_with_specs()
+ {
+ // Arrange
+ var template = CreateSwitchTemplate();
+ _templateStore.GetByIdAsync("Switch/TestSwitch-24").Returns(template);
+ _repo.GetByNameAsync("my-switch").Returns((Resource?)null);
+ var sut = new AddResourceFromTemplateUseCase(_repo, _templateStore);
+
+ // Act
+ await sut.ExecuteAsync("my-switch", "Switch/TestSwitch-24");
+
+ // Assert
+ await _repo.Received(1).AddAsync(Arg.Is(s =>
+ s.Name == "my-switch" &&
+ s.Model == "TestSwitch-24" &&
+ s.Managed == true &&
+ s.Poe == true &&
+ s.Ports != null &&
+ s.Ports.Count == 1 &&
+ s.Ports[0].Type == "rj45" &&
+ s.Ports[0].Count == 24));
+ }
+
+ [Fact]
+ public async Task execute_async__duplicate_name__throws_conflict()
+ {
+ // Arrange
+ _repo.GetByNameAsync("existing").Returns(new Switch { Name = "existing" });
+ var sut = new AddResourceFromTemplateUseCase(_repo, _templateStore);
+
+ // Act & Assert
+ await Assert.ThrowsAsync(
+ () => sut.ExecuteAsync("existing", "Switch/TestSwitch-24"));
+ }
+
+ [Fact]
+ public async Task execute_async__template_not_found__throws_not_found()
+ {
+ // Arrange
+ _repo.GetByNameAsync("new-switch").Returns((Resource?)null);
+ _templateStore.GetByIdAsync("Switch/NonExistent").Returns((HardwareTemplate?)null);
+ var sut = new AddResourceFromTemplateUseCase(_repo, _templateStore);
+
+ // Act & Assert
+ var ex = await Assert.ThrowsAsync(
+ () => sut.ExecuteAsync("new-switch", "Switch/NonExistent"));
+ Assert.Contains("NonExistent", ex.Message);
+ }
+
+ [Fact]
+ public async Task execute_async__kind_mismatch__throws_validation()
+ {
+ // Arrange
+ var routerTemplate = new HardwareTemplate(
+ "Router/SomeRouter",
+ "Router",
+ "SomeRouter",
+ new Router { Name = "SomeRouter", Kind = "Router" });
+ _repo.GetByNameAsync("new-switch").Returns((Resource?)null);
+ _templateStore.GetByIdAsync("Router/SomeRouter").Returns(routerTemplate);
+ var sut = new AddResourceFromTemplateUseCase(_repo, _templateStore);
+
+ // Act & Assert
+ var ex = await Assert.ThrowsAsync(
+ () => sut.ExecuteAsync("new-switch", "Router/SomeRouter"));
+ Assert.Contains("Router", ex.Message);
+ Assert.Contains("Switch", ex.Message);
+ }
+
+ [Fact]
+ public async Task execute_async__normalizes_name()
+ {
+ // Arrange
+ var template = CreateSwitchTemplate();
+ _templateStore.GetByIdAsync("Switch/TestSwitch-24").Returns(template);
+ _repo.GetByNameAsync("trimmed-switch").Returns((Resource?)null);
+ var sut = new AddResourceFromTemplateUseCase(_repo, _templateStore);
+
+ // Act
+ await sut.ExecuteAsync(" trimmed-switch ", "Switch/TestSwitch-24");
+
+ // Assert
+ await _repo.Received(1).AddAsync(Arg.Is(s =>
+ s.Name == "trimmed-switch"));
+ }
+
+ [Fact]
+ public async Task execute_async__empty_name__throws()
+ {
+ // Arrange
+ var sut = new AddResourceFromTemplateUseCase(_repo, _templateStore);
+
+ // Act & Assert
+ await Assert.ThrowsAsync(
+ () => sut.ExecuteAsync("", "Switch/TestSwitch-24"));
+ }
+
+ [Fact]
+ public async Task execute_async__sets_empty_runs_on_when_null()
+ {
+ // Arrange
+ var template = CreateSwitchTemplate();
+ _templateStore.GetByIdAsync("Switch/TestSwitch-24").Returns(template);
+ _repo.GetByNameAsync("my-switch").Returns((Resource?)null);
+ var sut = new AddResourceFromTemplateUseCase(_repo, _templateStore);
+
+ // Act
+ await sut.ExecuteAsync("my-switch", "Switch/TestSwitch-24");
+
+ // Assert
+ await _repo.Received(1).AddAsync(Arg.Is(s =>
+ s.RunsOn != null && s.RunsOn.Count == 0));
+ }
+
+ [Fact]
+ public async Task execute_async__does_not_mutate_template_spec()
+ {
+ // Arrange
+ var template = CreateSwitchTemplate();
+ _templateStore.GetByIdAsync("Switch/TestSwitch-24").Returns(template);
+ _repo.GetByNameAsync("my-switch").Returns((Resource?)null);
+ var sut = new AddResourceFromTemplateUseCase(_repo, _templateStore);
+
+ // Act
+ await sut.ExecuteAsync("my-switch", "Switch/TestSwitch-24");
+
+ // Assert — original template spec name is unchanged
+ Assert.Equal("TestSwitch-24", template.Spec.Name);
+ }
+}
diff --git a/docs/Hardware-Templates.md b/docs/Hardware-Templates.md
new file mode 100644
index 00000000..09683603
--- /dev/null
+++ b/docs/Hardware-Templates.md
@@ -0,0 +1,347 @@
+# Hardware Templates
+
+Hardware templates provide pre-filled specifications for well-known hardware models. Instead of manually entering every port, CPU, drive, and NIC when adding a new device, you can reference a template and get an accurately populated resource in seconds.
+
+## Why templates?
+
+When you add a new UniFi USW-Enterprise-24 to your rack, you already know its exact port layout, PoE capability, and management features. Templates capture this shared knowledge so every RackPeek user benefits from accurate, consistent data without repetitive manual entry.
+
+Templates are especially valuable for:
+
+- **Network gear** (switches, routers, firewalls, access points) where port counts, speeds, and capabilities are fixed per model.
+- **Mini-PCs and commodity servers** (Minisforum, GMKtec, Beelink) where CPU, RAM, drive, and NIC configurations ship as a known specification.
+- **UPS units** where VA ratings are standardised per model.
+
+## Using templates
+
+### CLI
+
+Add a resource from a template with the `--template` (or `-t`) flag:
+
+```bash
+rpk servers add home-nuc --template Minisforum-MS-01-13900H
+rpk switches add core-switch --template UniFi-USW-Enterprise-24
+rpk routers add edge-router --template UniFi-UDM-Pro-Max
+rpk firewalls add main-fw --template Netgate-6100
+rpk accesspoints add office-ap --template UniFi-U7-Pro
+rpk ups add rack-ups --template APC-SmartUPS-2200
+```
+
+The `--template` value is the model name (the YAML filename without `.yaml`).
+
+### Web UI
+
+When adding a hardware resource through the web interface, a template dropdown appears automatically. Select a template to pre-fill all specifications, then customise the name and any fields as needed.
+
+## Browsing available templates
+
+List all bundled templates:
+
+```bash
+rpk templates list
+```
+
+Filter by kind:
+
+```bash
+rpk templates list --kind Switch
+rpk templates list --kind Server
+rpk templates list --kind Router
+```
+
+View detailed specifications for a specific template:
+
+```bash
+rpk templates show Server/Minisforum-MS-01-13900H
+rpk templates show Switch/UniFi-USW-Enterprise-24
+rpk templates show Router/UniFi-UDM-Pro-Max
+```
+
+The template ID format is `Kind/Model` (e.g. `Server/Minisforum-MS-01-13900H`).
+
+## Contributing a template
+
+We welcome community contributions of hardware templates. The more templates we have, the easier it is for everyone to document their infrastructure accurately.
+
+There are two ways to create a template: export one from a resource you have already added in the Web UI, or write the YAML file by hand.
+
+### Option A — Copy as Template from the Web UI
+
+The fastest way to contribute is to export a template directly from an existing resource:
+
+1. **Add the hardware** to your RackPeek instance through the Web UI and fill in its specifications (CPU, RAM, drives, NICs, ports, etc.).
+2. Open the resource's detail card and click the gold **Copy as Template** button (next to Rename, Clone, and Delete).
+3. A dialog asks for the **official hardware name** from the vendor (e.g. `Minisforum-MS-01`). Enter it and press Submit.
+4. The generated template YAML is copied to your clipboard.
+5. Save the clipboard contents to a new `.yaml` file in the appropriate `templates/{kind-plural}/` directory, using the hardware name as the filename.
+6. Validate and submit (see [Validate your template](#validate-your-template) and [Submit a pull request](#submit-a-pull-request) below).
+
+### Option B — Write the YAML by hand
+
+If you prefer, you can create the template file manually. Copy an existing template in the same `templates/{kind-plural}/` directory as a starting point, then update the fields to match the new hardware model's specifications from the vendor's product page.
+
+### Directory layout
+
+Templates are stored in `templates/{kind-plural}/` where `{kind-plural}` is the lowercase plural form of the resource kind:
+
+```
+templates/
+├── accesspoints/
+│ ├── UniFi-U6-Lite.yaml
+│ ├── UniFi-U6-Pro.yaml
+│ ├── UniFi-U7-Lite.yaml
+│ └── ...
+├── firewalls/
+│ ├── Netgate-1100.yaml
+│ └── Netgate-6100.yaml
+├── routers/
+│ ├── MikroTik-hEX-S.yaml
+│ ├── UniFi-UDM-Pro-Max.yaml
+│ ├── UniFi-UCG-Fiber.yaml
+│ └── ...
+├── servers/
+│ ├── Minisforum-MS-01-13900H.yaml
+│ ├── GMKtec-NucBox-K8-Plus.yaml
+│ ├── Beelink-EQ14.yaml
+│ └── ...
+├── switches/
+│ ├── UniFi-USW-Enterprise-24.yaml
+│ ├── UniFi-USW-Pro-HD-24-PoE.yaml
+│ └── ...
+└── ups/
+ └── APC-SmartUPS-2200.yaml
+```
+
+### Naming conventions
+
+- **Filename**: Use the hardware model name with hyphens instead of spaces. The filename (without `.yaml`) becomes the template's model identifier.
+ - Good: `UniFi-USW-Enterprise-24.yaml`, `Minisforum-MS-01-13900H.yaml`
+ - Bad: `unifi switch.yaml`, `my_switch.yaml`
+- **`name` field**: Set to the same value as the filename (without extension). This is a placeholder — users override it when creating a resource.
+- **`model` field**: Required for switches, routers, firewalls, and access points. For servers, the model is derived from the filename automatically.
+
+### YAML format by kind
+
+Every template requires a `kind` and `name` field. Additional fields depend on the resource kind.
+
+#### Server
+
+```yaml
+kind: Server
+name: Minisforum-MS-01-13900H
+cpus:
+ - model: Intel Core i9-13900H
+ cores: 14
+ threads: 20
+ram:
+ size: 32
+ mts: 5200
+drives:
+ - type: nvme
+ size: 1024
+nics:
+ - type: sfp+
+ speed: 10
+ ports: 2
+ - type: rj45
+ speed: 2.5
+ ports: 2
+gpus:
+ - model: Intel Iris Xe Graphics
+ vram: 0
+ipmi: true
+```
+
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| `cpus` | list | No | CPU entries with `model`, `cores`, `threads` |
+| `ram` | object | No | `size` (GB) and `mts` (memory transfer speed) |
+| `drives` | list | No | Drive entries with `type` (nvme, ssd, hdd, sas, sata, usb, sdcard, micro-sd) and `size` (GB) |
+| `nics` | list | No | NIC entries with `type` (rj45, sfp, sfp+, etc.), `speed` (Gbps), `ports` |
+| `gpus` | list | No | GPU entries with `model` and `vram` (GB) |
+| `ipmi` | boolean | No | Whether the server has IPMI/BMC management |
+
+#### Switch
+
+```yaml
+kind: Switch
+name: UniFi-USW-Enterprise-24
+model: UniFi-USW-Enterprise-24
+ports:
+ - type: rj45
+ speed: 1
+ count: 12
+ - type: rj45
+ speed: 2.5
+ count: 8
+ - type: sfp+
+ speed: 10
+ count: 4
+managed: true
+poe: true
+```
+
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| `model` | string | Yes | Hardware model identifier |
+| `ports` | list | No | Port entries with `type`, `speed` (Gbps), `count` |
+| `managed` | boolean | No | Whether the switch is managed |
+| `poe` | boolean | No | Whether the switch supports PoE |
+
+#### Router
+
+```yaml
+kind: Router
+name: UniFi-UDM-Pro-Max
+model: UniFi-UDM-Pro-Max
+ports:
+ - type: rj45
+ speed: 1
+ count: 8
+ - type: rj45
+ speed: 2.5
+ count: 1
+ - type: sfp+
+ speed: 10
+ count: 2
+managed: true
+poe: false
+```
+
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| `model` | string | Yes | Hardware model identifier |
+| `ports` | list | No | Port entries with `type`, `speed` (Gbps), `count` |
+| `managed` | boolean | No | Whether the router is managed |
+| `poe` | boolean | No | Whether the router supports PoE |
+
+#### Firewall
+
+```yaml
+kind: Firewall
+name: Netgate-6100
+model: Netgate-6100
+ports:
+ - type: rj45
+ speed: 1
+ count: 4
+ - type: sfp+
+ speed: 10
+ count: 2
+managed: true
+poe: false
+```
+
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| `model` | string | Yes | Hardware model identifier |
+| `ports` | list | No | Port entries with `type`, `speed` (Gbps), `count` |
+| `managed` | boolean | No | Whether the firewall is managed |
+| `poe` | boolean | No | Whether the firewall supports PoE |
+
+#### Access Point
+
+```yaml
+kind: AccessPoint
+name: UniFi-U7-Pro
+model: UniFi-U7-Pro
+speed: 2.5
+```
+
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| `model` | string | Yes | Hardware model identifier |
+| `speed` | number | No | Maximum link speed in Gbps |
+
+#### UPS
+
+```yaml
+kind: Ups
+name: APC-SmartUPS-2200
+model: APC-SmartUPS-2200
+va: 2200
+```
+
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| `model` | string | Yes | Hardware model identifier |
+| `va` | number | No | Volt-ampere rating |
+
+### Validate your template
+
+Before submitting, validate your template against the schema for its `kind`.
+
+**Single file:**
+
+```bash
+rpk templates validate path/to/your-template.yaml
+```
+
+**All templates at once (recommended before submitting a PR):**
+
+```bash
+just validate-templates
+```
+
+The validator checks:
+- The file is valid YAML with a recognised `kind`
+- Required fields are present (`name`, `model` where applicable)
+- Sub-resource types are valid (drive types, NIC types, port types)
+- Numeric values are non-negative
+
+A passing result looks like:
+
+```
+Valid: your-template.yaml passes all checks.
+```
+
+If there are problems, each error is listed individually so you can fix them before submitting.
+
+### Submit a pull request
+
+1. **Fork** the [RackPeek repository](https://github.com/Timmoth/RackPeek).
+2. **Create** a new YAML file in the appropriate `templates/{kind-plural}/` directory, following the naming conventions above.
+ - Use the Web UI's **Copy as Template** button to export from an existing resource, or write the YAML by hand using an existing template as reference.
+3. **Validate** your template locally:
+ ```bash
+ just build
+ rpk templates validate templates/servers/YourModel.yaml
+ just validate-templates
+ ```
+4. **Spot-check** the template loads and creates a resource correctly:
+ ```bash
+ rpk templates show Server/YourModel
+ rpk servers add test-resource --template YourModel
+ rpk servers describe test-resource
+ ```
+5. **Run tests** to ensure nothing is broken:
+ ```bash
+ just test-cli
+ ```
+6. **Open a pull request** with a clear title (e.g. "Add template: Minisforum MS-01 server").
+
+### Validation checklist
+
+Before submitting your template, verify:
+
+- [ ] `kind` matches one of: `Server`, `Switch`, `Router`, `Firewall`, `AccessPoint`, `Ups`
+- [ ] `name` matches the filename (without `.yaml`)
+- [ ] `model` is set (for all kinds except Server, where the filename is used)
+- [ ] Numeric values use correct units (GB for storage/RAM, Gbps for network speed)
+- [ ] Port types are valid: `rj45`, `sfp`, `sfp+`, `sfp28`, `qsfp+`, `qsfp28`, `qsfp-dd`, `osfp`
+- [ ] Drive types are valid: `nvme`, `ssd`, `hdd`, `sas`, `sata`, `usb`, `sdcard`, `micro-sd`
+- [ ] `rpk templates validate` passes with no errors
+- [ ] `just validate-templates` passes all templates
+- [ ] The template loads correctly with `rpk templates show Kind/Model`
+- [ ] A resource created from the template has the expected specifications
+
+### Custom template directories
+
+By default, RackPeek loads templates from the bundled `templates/` directory shipped with the application. You can also point to an additional directory of templates by setting the `RPK_TEMPLATES_DIR` environment variable:
+
+```bash
+export RPK_TEMPLATES_DIR=/path/to/my/templates
+rpk templates list
+```
+
+This is useful for organisations that maintain a private library of templates for internal hardware.
diff --git a/templates/accesspoints/UniFi-U6-Lite.yaml b/templates/accesspoints/UniFi-U6-Lite.yaml
new file mode 100644
index 00000000..4e46a98b
--- /dev/null
+++ b/templates/accesspoints/UniFi-U6-Lite.yaml
@@ -0,0 +1,4 @@
+kind: AccessPoint
+name: UniFi-U6-Lite
+model: UniFi-U6-Lite
+speed: 1
diff --git a/templates/accesspoints/UniFi-U6-Plus.yaml b/templates/accesspoints/UniFi-U6-Plus.yaml
new file mode 100644
index 00000000..fc03f0cf
--- /dev/null
+++ b/templates/accesspoints/UniFi-U6-Plus.yaml
@@ -0,0 +1,7 @@
+# UniFi Access Point U6+
+# https://eu.store.ui.com/eu/en/category/wifi-flagship/products/u6-plus
+# WiFi 6 | 4 spatial streams | Compact ceiling-mount | 1 GbE uplink
+kind: AccessPoint
+name: UniFi-U6-Plus
+model: UniFi-U6-Plus
+speed: 1
diff --git a/templates/accesspoints/UniFi-U6-Pro.yaml b/templates/accesspoints/UniFi-U6-Pro.yaml
new file mode 100644
index 00000000..ed07a79c
--- /dev/null
+++ b/templates/accesspoints/UniFi-U6-Pro.yaml
@@ -0,0 +1,4 @@
+kind: AccessPoint
+name: UniFi-U6-Pro
+model: UniFi-U6-Pro
+speed: 2.5
diff --git a/templates/accesspoints/UniFi-U7-Lite.yaml b/templates/accesspoints/UniFi-U7-Lite.yaml
new file mode 100644
index 00000000..b0a8b2ce
--- /dev/null
+++ b/templates/accesspoints/UniFi-U7-Lite.yaml
@@ -0,0 +1,7 @@
+# UniFi Access Point U7 Lite
+# https://eu.store.ui.com/eu/en/category/wifi-flagship/products/u7-lite
+# WiFi 7 | 4 spatial streams | Compact ceiling-mount | 2.5 GbE uplink
+kind: AccessPoint
+name: UniFi-U7-Lite
+model: UniFi-U7-Lite
+speed: 2.5
diff --git a/templates/accesspoints/UniFi-U7-Long-Range.yaml b/templates/accesspoints/UniFi-U7-Long-Range.yaml
new file mode 100644
index 00000000..47179335
--- /dev/null
+++ b/templates/accesspoints/UniFi-U7-Long-Range.yaml
@@ -0,0 +1,7 @@
+# UniFi Access Point U7 Long-Range
+# https://eu.store.ui.com/eu/en/category/wifi-flagship/products/u7-lr
+# WiFi 7 | 5 spatial streams | Extended range | 2.5 GbE uplink
+kind: AccessPoint
+name: UniFi-U7-Long-Range
+model: UniFi-U7-Long-Range
+speed: 2.5
diff --git a/templates/accesspoints/UniFi-U7-Pro-Max.yaml b/templates/accesspoints/UniFi-U7-Pro-Max.yaml
new file mode 100644
index 00000000..4309e7f6
--- /dev/null
+++ b/templates/accesspoints/UniFi-U7-Pro-Max.yaml
@@ -0,0 +1,7 @@
+# UniFi Access Point U7 Pro Max
+# https://eu.store.ui.com/eu/en/category/wifi-flagship/products/u7-pro-max
+# WiFi 7 | 8 spatial streams | 6 GHz | Dedicated spectral scanning engine | 2.5 GbE uplink
+kind: AccessPoint
+name: UniFi-U7-Pro-Max
+model: UniFi-U7-Pro-Max
+speed: 2.5
diff --git a/templates/accesspoints/UniFi-U7-Pro.yaml b/templates/accesspoints/UniFi-U7-Pro.yaml
new file mode 100644
index 00000000..fbe1bafa
--- /dev/null
+++ b/templates/accesspoints/UniFi-U7-Pro.yaml
@@ -0,0 +1,7 @@
+# UniFi Access Point U7 Pro
+# https://eu.store.ui.com/eu/en/category/wifi-flagship/products/u7-pro
+# WiFi 7 | 6 spatial streams | 6 GHz | Ceiling-mounted | 2.5 GbE uplink
+kind: AccessPoint
+name: UniFi-U7-Pro
+model: UniFi-U7-Pro
+speed: 2.5
diff --git a/templates/firewalls/Netgate-1100.yaml b/templates/firewalls/Netgate-1100.yaml
new file mode 100644
index 00000000..eb56f8e1
--- /dev/null
+++ b/templates/firewalls/Netgate-1100.yaml
@@ -0,0 +1,9 @@
+kind: Firewall
+name: Netgate-1100
+model: Netgate-1100
+ports:
+ - type: rj45
+ speed: 1
+ count: 3
+managed: true
+poe: false
diff --git a/templates/firewalls/Netgate-6100.yaml b/templates/firewalls/Netgate-6100.yaml
new file mode 100644
index 00000000..5c70fcb9
--- /dev/null
+++ b/templates/firewalls/Netgate-6100.yaml
@@ -0,0 +1,12 @@
+kind: Firewall
+name: Netgate-6100
+model: Netgate-6100
+ports:
+ - type: rj45
+ speed: 1
+ count: 4
+ - type: sfp+
+ speed: 10
+ count: 2
+managed: true
+poe: false
diff --git a/templates/routers/UniFi-UCG-Fiber.yaml b/templates/routers/UniFi-UCG-Fiber.yaml
new file mode 100644
index 00000000..b2345c70
--- /dev/null
+++ b/templates/routers/UniFi-UCG-Fiber.yaml
@@ -0,0 +1,19 @@
+# UniFi Cloud Gateway Fiber (30W PoE)
+# https://eu.store.ui.com/eu/en/category/cloud-gateways-compact/collections/cloud-gateway-fiber/products/ucg-fiber
+# Desktop 10G Cloud Gateway | 5 Gbps IPS | Integrated 4-port 2.5 GbE PoE switch
+# (2) 10G SFP+ + (1) 10G RJ45 + (4) 2.5G RJ45
+kind: Router
+name: UniFi-UCG-Fiber
+model: UniFi-UCG-Fiber
+ports:
+ - type: rj45
+ speed: 2.5
+ count: 4
+ - type: rj45
+ speed: 10
+ count: 1
+ - type: sfp+
+ speed: 10
+ count: 2
+managed: true
+poe: true
diff --git a/templates/routers/UniFi-UCG-Max.yaml b/templates/routers/UniFi-UCG-Max.yaml
new file mode 100644
index 00000000..9f680d4a
--- /dev/null
+++ b/templates/routers/UniFi-UCG-Max.yaml
@@ -0,0 +1,13 @@
+# UniFi Cloud Gateway Max
+# https://eu.store.ui.com/eu/en/category/cloud-gateways-compact/collections/cloud-gateway-max/products/ucg-max-ns
+# Compact 2.5G Cloud Gateway | 1.5 Gbps IPS | 30+ devices / 300+ clients
+# (5) 2.5G RJ45 (1 WAN + 4 LAN)
+kind: Router
+name: UniFi-UCG-Max
+model: UniFi-UCG-Max
+ports:
+ - type: rj45
+ speed: 2.5
+ count: 5
+managed: true
+poe: false
diff --git a/templates/routers/UniFi-UCG-Ultra.yaml b/templates/routers/UniFi-UCG-Ultra.yaml
new file mode 100644
index 00000000..df18b6b5
--- /dev/null
+++ b/templates/routers/UniFi-UCG-Ultra.yaml
@@ -0,0 +1,16 @@
+# UniFi Cloud Gateway Ultra
+# https://eu.store.ui.com/eu/en/category/cloud-gateways-compact/products/ucg-ultra
+# Compact Cloud Gateway | 1 Gbps IPS | 30+ devices / 300+ clients | USB-C powered
+# (1) 2.5G RJ45 WAN + (4) 1G RJ45 LAN
+kind: Router
+name: UniFi-UCG-Ultra
+model: UniFi-UCG-Ultra
+ports:
+ - type: rj45
+ speed: 1
+ count: 4
+ - type: rj45
+ speed: 2.5
+ count: 1
+managed: true
+poe: false
diff --git a/templates/routers/UniFi-UDM-Pro-Max.yaml b/templates/routers/UniFi-UDM-Pro-Max.yaml
new file mode 100644
index 00000000..cc696e22
--- /dev/null
+++ b/templates/routers/UniFi-UDM-Pro-Max.yaml
@@ -0,0 +1,19 @@
+# UniFi Dream Machine Pro Max
+# https://eu.store.ui.com/eu/en/category/cloud-gateways-large-scale/products/udm-pro-max
+# 1U rack-mount | 5 Gbps IPS | 200+ devices / 2,000+ clients
+# WAN: (1) 10G SFP+ + (1) 2.5G RJ45 | LAN: (8) 1G RJ45 + (1) 10G SFP+
+kind: Router
+name: UniFi-UDM-Pro-Max
+model: UniFi-UDM-Pro-Max
+ports:
+ - type: rj45
+ speed: 1
+ count: 8
+ - type: rj45
+ speed: 2.5
+ count: 1
+ - type: sfp+
+ speed: 10
+ count: 2
+managed: true
+poe: false
diff --git a/templates/routers/UniFi-UDM-Pro.yaml b/templates/routers/UniFi-UDM-Pro.yaml
new file mode 100644
index 00000000..e738d659
--- /dev/null
+++ b/templates/routers/UniFi-UDM-Pro.yaml
@@ -0,0 +1,16 @@
+# UniFi Dream Machine Pro
+# https://eu.store.ui.com/eu/en/category/cloud-gateways-large-scale/products/udm-pro
+# 1U rack-mount | 3.5 Gbps IPS | 100+ devices / 1,000+ clients
+# WAN: (1) 10G SFP+ + (1) 1G RJ45 | LAN: (8) 1G RJ45 + (1) 10G SFP+
+kind: Router
+name: UniFi-UDM-Pro
+model: UniFi-UDM-Pro
+ports:
+ - type: rj45
+ speed: 1
+ count: 9
+ - type: sfp+
+ speed: 10
+ count: 2
+managed: true
+poe: false
diff --git a/templates/routers/UniFi-UDM-SE.yaml b/templates/routers/UniFi-UDM-SE.yaml
new file mode 100644
index 00000000..e6df82c9
--- /dev/null
+++ b/templates/routers/UniFi-UDM-SE.yaml
@@ -0,0 +1,19 @@
+# UniFi Dream Machine Special Edition (180W PoE)
+# https://eu.store.ui.com/eu/en/category/cloud-gateways-large-scale/products/udm-se
+# 1U rack-mount | 3.5 Gbps IPS | 100+ devices / 1,000+ clients | Built-in PoE switching
+# WAN: (1) 10G SFP+ + (1) 2.5G RJ45 | LAN: (8) 1G RJ45 PoE + (1) 10G SFP+
+kind: Router
+name: UniFi-UDM-SE
+model: UniFi-UDM-SE
+ports:
+ - type: rj45
+ speed: 1
+ count: 8
+ - type: rj45
+ speed: 2.5
+ count: 1
+ - type: sfp+
+ speed: 10
+ count: 2
+managed: true
+poe: true
diff --git a/templates/routers/UniFi-UDR7.yaml b/templates/routers/UniFi-UDR7.yaml
new file mode 100644
index 00000000..99ab236f
--- /dev/null
+++ b/templates/routers/UniFi-UDR7.yaml
@@ -0,0 +1,16 @@
+# UniFi Dream Router 7 (15W PoE)
+# https://eu.store.ui.com/eu/en/category/cloud-gateways-wifi-integrated/products/udr7
+# Desktop 10G Cloud Gateway | Integrated WiFi 7 | PoE switch | microSD NVR
+# WAN: (1) 10G SFP+ + (1) 2.5G RJ45 | LAN: (3) 2.5G RJ45 (1 PoE)
+kind: Router
+name: UniFi-UDR7
+model: UniFi-UDR7
+ports:
+ - type: rj45
+ speed: 2.5
+ count: 4
+ - type: sfp+
+ speed: 10
+ count: 1
+managed: true
+poe: true
diff --git a/templates/routers/UniFi-UX7.yaml b/templates/routers/UniFi-UX7.yaml
new file mode 100644
index 00000000..373ad6a3
--- /dev/null
+++ b/templates/routers/UniFi-UX7.yaml
@@ -0,0 +1,16 @@
+# UniFi Express 7
+# https://eu.store.ui.com/eu/en/category/cloud-gateways-wifi-integrated/products/ux7
+# Mesh-scalable compact 10G Cloud Gateway | Integrated WiFi 7 | USB-C powered
+# (1) 10G RJ45 WAN + (1) 2.5G RJ45 LAN
+kind: Router
+name: UniFi-UX7
+model: UniFi-UX7
+ports:
+ - type: rj45
+ speed: 2.5
+ count: 1
+ - type: rj45
+ speed: 10
+ count: 1
+managed: true
+poe: false
diff --git a/templates/servers/Beelink-EQ14.yaml b/templates/servers/Beelink-EQ14.yaml
new file mode 100644
index 00000000..bb0ca1aa
--- /dev/null
+++ b/templates/servers/Beelink-EQ14.yaml
@@ -0,0 +1,24 @@
+# Beelink EQ14 Mini PC
+# https://www.bee-link.com/products/beelink-eq14-n150
+# CPU: Intel N150 (4C/4T, up to 3.6 GHz, Twin Lake, 6MB L3, TDP 25W)
+# Dual M.2 PCIe 3.0 SSD | Dual LAN (1G or 2.5G variants) | Wi-Fi 6 | BT 5.2 | Built-in PSU
+kind: Server
+name: Beelink-EQ14
+cpus:
+ - model: Intel N150
+ cores: 4
+ threads: 4
+ram:
+ size: 16
+ mts: 3200
+drives:
+ - type: sata
+ size: 500
+nics:
+ - type: rj45
+ speed: 2.5
+ ports: 2
+gpus:
+ - model: Intel UHD Graphics
+ vram: 0
+ipmi: false
diff --git a/templates/servers/Beelink-ME-Mini-N150.yaml b/templates/servers/Beelink-ME-Mini-N150.yaml
new file mode 100644
index 00000000..2a8bae47
--- /dev/null
+++ b/templates/servers/Beelink-ME-Mini-N150.yaml
@@ -0,0 +1,24 @@
+# Beelink ME mini 6-Slot NAS Mini PC (N150 variant)
+# https://www.bee-link.com/products/beelink-me-mini-n150?variant=48027613921522
+# CPU: Intel N150 (4C/4T, up to 3.6 GHz)
+# 6x M.2 SSD slots (max 24TB) | Dual 2.5G RJ45 (Intel I226-V) | Wi-Fi 6 | NAS-oriented
+kind: Server
+name: Beelink-ME-Mini-N150
+cpus:
+ - model: Intel N150
+ cores: 4
+ threads: 4
+ram:
+ size: 16
+ mts: 4800
+drives:
+ - type: nvme
+ size: 1024
+nics:
+ - type: rj45
+ speed: 2.5
+ ports: 2
+gpus:
+ - model: Intel UHD Graphics
+ vram: 0
+ipmi: false
diff --git a/templates/servers/Beelink-ME-Mini-N95.yaml b/templates/servers/Beelink-ME-Mini-N95.yaml
new file mode 100644
index 00000000..cf36fa1d
--- /dev/null
+++ b/templates/servers/Beelink-ME-Mini-N95.yaml
@@ -0,0 +1,24 @@
+# Beelink ME mini 6-Slot NAS Mini PC (N95 variant)
+# https://www.bee-link.com/products/beelink-me-mini-n150?variant=48678160204018
+# CPU: Intel N95 (4C/4T, up to 3.4 GHz)
+# 6x M.2 SSD slots (max 24TB) | Dual 2.5G RJ45 (Intel I226-V) | 64GB eMMC | NAS-oriented
+kind: Server
+name: Beelink-ME-Mini-N95
+cpus:
+ - model: Intel N95
+ cores: 4
+ threads: 4
+ram:
+ size: 16
+ mts: 4800
+drives:
+ - type: ssd
+ size: 64
+nics:
+ - type: rj45
+ speed: 2.5
+ ports: 2
+gpus:
+ - model: Intel UHD Graphics
+ vram: 0
+ipmi: false
diff --git a/templates/servers/GMKtec-NucBox-G10.yaml b/templates/servers/GMKtec-NucBox-G10.yaml
new file mode 100644
index 00000000..33b875e8
--- /dev/null
+++ b/templates/servers/GMKtec-NucBox-G10.yaml
@@ -0,0 +1,24 @@
+# GMKtec NucBox G10 Mini PC
+# https://de.gmktec.com/products/gmktec-nucbox-g10-amd-ryzen-5-3500u-mini-pc-black
+# CPU: AMD Ryzen 5 3500U (4C/8T, up to 3.7 GHz, 12nm)
+# 1x 2.5G RJ45 | Wi-Fi 5 | BT 5.0 | Triple 4K display (HDMI 2.1 + DP 1.4 + Type-C)
+kind: Server
+name: GMKtec-NucBox-G10
+cpus:
+ - model: AMD Ryzen 5 3500U
+ cores: 4
+ threads: 8
+ram:
+ size: 16
+ mts: 2400
+drives:
+ - type: nvme
+ size: 256
+nics:
+ - type: rj45
+ speed: 2.5
+ ports: 1
+gpus:
+ - model: AMD Radeon Vega 8
+ vram: 0
+ipmi: false
diff --git a/templates/servers/GMKtec-NucBox-G3-Plus.yaml b/templates/servers/GMKtec-NucBox-G3-Plus.yaml
new file mode 100644
index 00000000..937dd0ef
--- /dev/null
+++ b/templates/servers/GMKtec-NucBox-G3-Plus.yaml
@@ -0,0 +1,24 @@
+# GMKtec NucBox G3 Plus Mini PC
+# https://de.gmktec.com/products/gmktec-nucbox-g3-plus-intel-twin-lake-n150
+# CPU: Intel N150 (4C/4T, up to 3.6 GHz, 7nm, TDP 6W)
+# 1x 2.5G RJ45 | Wi-Fi 6 | BT 5.2 | Dual HDMI 4K
+kind: Server
+name: GMKtec-NucBox-G3-Plus
+cpus:
+ - model: Intel N150
+ cores: 4
+ threads: 4
+ram:
+ size: 8
+ mts: 3200
+drives:
+ - type: nvme
+ size: 256
+nics:
+ - type: rj45
+ speed: 2.5
+ ports: 1
+gpus:
+ - model: Intel UHD Graphics
+ vram: 0
+ipmi: false
diff --git a/templates/servers/GMKtec-NucBox-G3.yaml b/templates/servers/GMKtec-NucBox-G3.yaml
new file mode 100644
index 00000000..b8091c81
--- /dev/null
+++ b/templates/servers/GMKtec-NucBox-G3.yaml
@@ -0,0 +1,24 @@
+# GMKtec NucBox G3 Mini PC
+# https://de.gmktec.com/products/gmktec-nucbox-g3-intel-alder-lake-n100
+# CPU: Intel N100 (4C/4T, up to 3.4 GHz, 7nm, TDP 6W)
+# 1x 2.5G RJ45 | Wi-Fi 6 | BT 5.2 | Dual HDMI 4K
+kind: Server
+name: GMKtec-NucBox-G3
+cpus:
+ - model: Intel N100
+ cores: 4
+ threads: 4
+ram:
+ size: 8
+ mts: 3200
+drives:
+ - type: nvme
+ size: 256
+nics:
+ - type: rj45
+ speed: 2.5
+ ports: 1
+gpus:
+ - model: Intel UHD Graphics
+ vram: 0
+ipmi: false
diff --git a/templates/servers/GMKtec-NucBox-K11.yaml b/templates/servers/GMKtec-NucBox-K11.yaml
new file mode 100644
index 00000000..93c06c20
--- /dev/null
+++ b/templates/servers/GMKtec-NucBox-K11.yaml
@@ -0,0 +1,24 @@
+# GMKtec NucBox K11 Mini PC
+# https://de.gmktec.com/en/products/gmktec-nucbox-k11-amd-ryzen-9-8945hs
+# CPU: AMD Ryzen 9 8945HS (8C/16T, 4.0–5.2 GHz, TSMC 4nm)
+# Dual PCIe 4.0 SSD | Dual 2.5G RJ45 (Intel I226V) | OCuLink | Dual USB4
+kind: Server
+name: GMKtec-NucBox-K11
+cpus:
+ - model: AMD Ryzen 9 8945HS
+ cores: 8
+ threads: 16
+ram:
+ size: 16
+ mts: 5600
+drives:
+ - type: nvme
+ size: 1024
+nics:
+ - type: rj45
+ speed: 2.5
+ ports: 2
+gpus:
+ - model: AMD Radeon 780M
+ vram: 0
+ipmi: false
diff --git a/templates/servers/GMKtec-NucBox-K15.yaml b/templates/servers/GMKtec-NucBox-K15.yaml
new file mode 100644
index 00000000..a53dfadf
--- /dev/null
+++ b/templates/servers/GMKtec-NucBox-K15.yaml
@@ -0,0 +1,24 @@
+# GMKtec NucBox K15 Mini PC
+# https://de.gmktec.com/products/gmktec-nucbox-k15-intel-core-ultra-5-125u
+# CPU: Intel Core Ultra 5 125U (12C/14T, up to 4.3 GHz, Intel 4)
+# 3x M.2 PCIe 4.0 SSD | Dual 2.5G RJ45 | OCuLink | USB4 | Wi-Fi 6E | NPU 11 TOPS
+kind: Server
+name: GMKtec-NucBox-K15
+cpus:
+ - model: Intel Core Ultra 5 125U
+ cores: 12
+ threads: 14
+ram:
+ size: 16
+ mts: 4800
+drives:
+ - type: nvme
+ size: 1024
+nics:
+ - type: rj45
+ speed: 2.5
+ ports: 2
+gpus:
+ - model: Intel Graphics
+ vram: 0
+ipmi: false
diff --git a/templates/servers/GMKtec-NucBox-K8-Plus.yaml b/templates/servers/GMKtec-NucBox-K8-Plus.yaml
new file mode 100644
index 00000000..63b5b3c1
--- /dev/null
+++ b/templates/servers/GMKtec-NucBox-K8-Plus.yaml
@@ -0,0 +1,24 @@
+# GMKtec NucBox K8 Plus Mini PC
+# https://de.gmktec.com/en/products/gmktec-nucbox-k8-plus-amd-ryzen-7-8845hs-mini-pc
+# CPU: AMD Ryzen 7 8845HS (8C/16T, up to 5.1 GHz, TSMC 4nm)
+# Dual PCIe 4.0 SSD | Dual 2.5G RJ45 (Intel I226V) | OCuLink | Dual USB4
+kind: Server
+name: GMKtec-NucBox-K8-Plus
+cpus:
+ - model: AMD Ryzen 7 8845HS
+ cores: 8
+ threads: 16
+ram:
+ size: 32
+ mts: 5600
+drives:
+ - type: nvme
+ size: 1024
+nics:
+ - type: rj45
+ speed: 2.5
+ ports: 2
+gpus:
+ - model: AMD Radeon 780M
+ vram: 0
+ipmi: false
diff --git a/templates/servers/GMKtec-NucBox-M5-Plus.yaml b/templates/servers/GMKtec-NucBox-M5-Plus.yaml
new file mode 100644
index 00000000..0dfa668d
--- /dev/null
+++ b/templates/servers/GMKtec-NucBox-M5-Plus.yaml
@@ -0,0 +1,24 @@
+# GMKtec NucBox M5 Plus Mini PC
+# https://de.gmktec.com/en/products/gmktec-nucbox-m5-plus-amd-ryzen-7-5825u-mini-pc
+# CPU: AMD Ryzen 7 5825U (8C/16T, 2.0–4.5 GHz, 7nm)
+# Dual 2.5G RJ45 | Wi-Fi 6E | BT 5.2 | Triple 4K display
+kind: Server
+name: GMKtec-NucBox-M5-Plus
+cpus:
+ - model: AMD Ryzen 7 5825U
+ cores: 8
+ threads: 16
+ram:
+ size: 16
+ mts: 3200
+drives:
+ - type: nvme
+ size: 512
+nics:
+ - type: rj45
+ speed: 2.5
+ ports: 2
+gpus:
+ - model: AMD Radeon Graphics
+ vram: 0
+ipmi: false
diff --git a/templates/servers/GMKtec-NucBox-M5-Ultra.yaml b/templates/servers/GMKtec-NucBox-M5-Ultra.yaml
new file mode 100644
index 00000000..00971001
--- /dev/null
+++ b/templates/servers/GMKtec-NucBox-M5-Ultra.yaml
@@ -0,0 +1,24 @@
+# GMKtec NucBox M5 Ultra Mini PC
+# https://de.gmktec.com/en/products/gmktec-nucbox-m5-ultra-amd-ryzen-7-7730u
+# CPU: AMD Ryzen 7 7730U (8C/16T, 2.0–4.5 GHz, Zen 3, 7nm)
+# Dual M.2 SSD | Dual 2.5G RJ45 | Wi-Fi 6E | BT 5.2 | Up to 8K display
+kind: Server
+name: GMKtec-NucBox-M5-Ultra
+cpus:
+ - model: AMD Ryzen 7 7730U
+ cores: 8
+ threads: 16
+ram:
+ size: 16
+ mts: 3200
+drives:
+ - type: nvme
+ size: 512
+nics:
+ - type: rj45
+ speed: 2.5
+ ports: 2
+gpus:
+ - model: AMD Radeon Graphics
+ vram: 0
+ipmi: false
diff --git a/templates/servers/GMKtec-NucBox-M7-Ultra.yaml b/templates/servers/GMKtec-NucBox-M7-Ultra.yaml
new file mode 100644
index 00000000..77dd2fd5
--- /dev/null
+++ b/templates/servers/GMKtec-NucBox-M7-Ultra.yaml
@@ -0,0 +1,24 @@
+# GMKtec Nucbox M7 Ultra Mini PC
+# https://de.gmktec.com/en/products/gmktec-nucboxm7-ultra-amd-ryzen-7-pro-6850u
+# CPU: AMD Ryzen 7 PRO 6850U (8C/16T, 2.7–4.7 GHz, TSMC 6nm)
+# Dual PCIe 4.0 SSD | Dual 2.5G RJ45 (Intel I226V) | OCuLink | Dual USB4 | Wi-Fi 6E
+kind: Server
+name: GMKtec-NucBox-M7-Ultra
+cpus:
+ - model: AMD Ryzen 7 PRO 6850U
+ cores: 8
+ threads: 16
+ram:
+ size: 16
+ mts: 4800
+drives:
+ - type: nvme
+ size: 512
+nics:
+ - type: rj45
+ speed: 2.5
+ ports: 2
+gpus:
+ - model: AMD Radeon 680M
+ vram: 0
+ipmi: false
diff --git a/templates/servers/GMKtec-NucBox-M8.yaml b/templates/servers/GMKtec-NucBox-M8.yaml
new file mode 100644
index 00000000..5b5ad06f
--- /dev/null
+++ b/templates/servers/GMKtec-NucBox-M8.yaml
@@ -0,0 +1,24 @@
+# GMKtec Nucbox M8 Mini PC
+# https://de.gmktec.com/en/products/gmktec-nucbox-m8-amd-ryzen-5-pro-6650h-mini-pc
+# CPU: AMD Ryzen 5 PRO 6650H (6C/12T, 3.3–4.5 GHz, TSMC 6nm)
+# Dual PCIe 4.0 SSD | Dual 2.5G RJ45 (RTL8125BG) | OCuLink | USB4 | Wi-Fi 6E
+kind: Server
+name: GMKtec-NucBox-M8
+cpus:
+ - model: AMD Ryzen 5 PRO 6650H
+ cores: 6
+ threads: 12
+ram:
+ size: 16
+ mts: 6400
+drives:
+ - type: nvme
+ size: 512
+nics:
+ - type: rj45
+ speed: 2.5
+ ports: 2
+gpus:
+ - model: AMD Radeon 660M
+ vram: 0
+ipmi: false
diff --git a/templates/servers/MinisForum-UM790-Pro.yaml b/templates/servers/MinisForum-UM790-Pro.yaml
new file mode 100644
index 00000000..462602ae
--- /dev/null
+++ b/templates/servers/MinisForum-UM790-Pro.yaml
@@ -0,0 +1,20 @@
+kind: Server
+name: MinisForum-UM790-Pro
+cpus:
+ - model: AMD Ryzen 9 7940HS
+ cores: 8
+ threads: 16
+ram:
+ size: 32
+ mts: 5600
+drives:
+ - type: nvme
+ size: 1024
+nics:
+ - type: rj45
+ speed: 2.5
+ ports: 1
+gpus:
+ - model: AMD Radeon 780M
+ vram: 0
+ipmi: false
diff --git a/templates/servers/Minisforum-AI-X1.yaml b/templates/servers/Minisforum-AI-X1.yaml
new file mode 100644
index 00000000..fbdf8e92
--- /dev/null
+++ b/templates/servers/Minisforum-AI-X1.yaml
@@ -0,0 +1,24 @@
+# Minisforum AI X1 Mini PC
+# https://www.minisforum.uk/products/minisforum-ai-x1-mini-pc
+# CPU options: Ryzen AI 9 HX 470 (12C/24T) | Ryzen 7 255 (8C/16T) | Ryzen 7 260 (8C/16T)
+# Radeon 890M (HX470) / 780M (others) | 2.5G RJ45 | OCuLink | Wi-Fi 7
+kind: Server
+name: Minisforum-AI-X1
+cpus:
+ - model: AMD Ryzen AI 9 HX 470
+ cores: 12
+ threads: 24
+ram:
+ size: 32
+ mts: 5600
+drives:
+ - type: nvme
+ size: 1024
+nics:
+ - type: rj45
+ speed: 2.5
+ ports: 1
+gpus:
+ - model: AMD Radeon 890M
+ vram: 0
+ipmi: false
diff --git a/templates/servers/Minisforum-MS-01-12600H.yaml b/templates/servers/Minisforum-MS-01-12600H.yaml
new file mode 100644
index 00000000..1b30b8e0
--- /dev/null
+++ b/templates/servers/Minisforum-MS-01-12600H.yaml
@@ -0,0 +1,26 @@
+# Minisforum MS-01 Mini Workstation
+# https://www.minisforum.uk/products/minisforum-ms-01
+# Dual 10G SFP+ | Dual 2.5G RJ45 | Intel vPro (AMT remote KVM)
+kind: Server
+name: Minisforum-MS-01
+cpus:
+ - model: Intel Core i5-12600H
+ cores: 14
+ threads: 20
+ram:
+ size: 32
+ mts: 5200
+drives:
+ - type: nvme
+ size: 1024
+nics:
+ - type: sfp+
+ speed: 10
+ ports: 2
+ - type: rj45
+ speed: 2.5
+ ports: 2
+gpus:
+ - model: Intel Iris Xe Graphics
+ vram: 0
+ipmi: true
diff --git a/templates/servers/Minisforum-MS-01-12900H.yaml b/templates/servers/Minisforum-MS-01-12900H.yaml
new file mode 100644
index 00000000..7a5940e9
--- /dev/null
+++ b/templates/servers/Minisforum-MS-01-12900H.yaml
@@ -0,0 +1,26 @@
+# Minisforum MS-01 Mini Workstation
+# https://www.minisforum.uk/products/minisforum-ms-01
+# Dual 10G SFP+ | Dual 2.5G RJ45 | Intel vPro (AMT remote KVM)
+kind: Server
+name: Minisforum-MS-01
+cpus:
+ - model: Intel Core i9-12900H
+ cores: 14
+ threads: 20
+ram:
+ size: 32
+ mts: 5200
+drives:
+ - type: nvme
+ size: 1024
+nics:
+ - type: sfp+
+ speed: 10
+ ports: 2
+ - type: rj45
+ speed: 2.5
+ ports: 2
+gpus:
+ - model: Intel Iris Xe Graphics
+ vram: 0
+ipmi: true
diff --git a/templates/servers/Minisforum-MS-01-13900H.yaml b/templates/servers/Minisforum-MS-01-13900H.yaml
new file mode 100644
index 00000000..7f1d8e8e
--- /dev/null
+++ b/templates/servers/Minisforum-MS-01-13900H.yaml
@@ -0,0 +1,27 @@
+# Minisforum MS-01 Mini Workstation
+# https://www.minisforum.uk/products/minisforum-ms-01
+# CPU options: i9-13900H (14C/20T) | i9-12900H (14C/20T) | i5-12600H
+# Dual 10G SFP+ | Dual 2.5G RJ45 | Intel vPro (AMT remote KVM)
+kind: Server
+name: Minisforum-MS-01
+cpus:
+ - model: Intel Core i9-13900H
+ cores: 14
+ threads: 20
+ram:
+ size: 32
+ mts: 5200
+drives:
+ - type: nvme
+ size: 1024
+nics:
+ - type: sfp+
+ speed: 10
+ ports: 2
+ - type: rj45
+ speed: 2.5
+ ports: 2
+gpus:
+ - model: Intel Iris Xe Graphics
+ vram: 0
+ipmi: true
diff --git a/templates/servers/Minisforum-MS-02-Ultra-275HX.yaml b/templates/servers/Minisforum-MS-02-Ultra-275HX.yaml
new file mode 100644
index 00000000..bd38f7ca
--- /dev/null
+++ b/templates/servers/Minisforum-MS-02-Ultra-275HX.yaml
@@ -0,0 +1,26 @@
+# Minisforum MS-02 Ultra Mini Workstation
+# https://www.minisforum.uk/products/minisforum-ms-02-ultra
+# 275HX: 10GbE + 2.5GbE RJ45 | Intel vPro | ECC
+kind: Server
+name: Minisforum-MS-02-Ultra
+cpus:
+ - model: Intel Core Ultra 9 275HX
+ cores: 24
+ threads: 24
+ram:
+ size: 32
+ mts: 4800
+drives:
+ - type: nvme
+ size: 1024
+nics:
+ - type: rj45
+ speed: 10
+ ports: 1
+ - type: rj45
+ speed: 2.5
+ ports: 1
+gpus:
+ - model: Intel Graphics
+ vram: 0
+ipmi: true
diff --git a/templates/servers/Minisforum-MS-02-Ultra-285HX copy.yaml b/templates/servers/Minisforum-MS-02-Ultra-285HX copy.yaml
new file mode 100644
index 00000000..374743d0
--- /dev/null
+++ b/templates/servers/Minisforum-MS-02-Ultra-285HX copy.yaml
@@ -0,0 +1,26 @@
+# Minisforum MS-02 Ultra Mini Workstation
+# https://www.minisforum.uk/products/minisforum-ms-02-ultra
+# 235HX: 10GbE + 2.5GbE RJ45 | Intel vPro | ECC
+kind: Server
+name: Minisforum-MS-02-Ultra
+cpus:
+ - model: Intel Core Ultra 5 235HX
+ cores: 14
+ threads: 14
+ram:
+ size: 32
+ mts: 4800
+drives:
+ - type: nvme
+ size: 1024
+nics:
+ - type: rj45
+ speed: 10
+ ports: 1
+ - type: rj45
+ speed: 2.5
+ ports: 1
+gpus:
+ - model: Intel Graphics
+ vram: 0
+ipmi: true
diff --git a/templates/servers/Minisforum-MS-02-Ultra-285HX.yaml b/templates/servers/Minisforum-MS-02-Ultra-285HX.yaml
new file mode 100644
index 00000000..25c3ba90
--- /dev/null
+++ b/templates/servers/Minisforum-MS-02-Ultra-285HX.yaml
@@ -0,0 +1,30 @@
+# Minisforum MS-02 Ultra Mini Workstation
+# https://www.minisforum.uk/products/minisforum-ms-02-ultra
+# CPU options: Ultra 9 285HX (24C/24T) | Ultra 9 275HX (24C/24T) | Ultra 5 235HX (14C/14T)
+# 285HX: Dual 25GbE SFP+ | 10GbE + 2.5GbE RJ45 | Intel vPro | ECC
+kind: Server
+name: Minisforum-MS-02-Ultra
+cpus:
+ - model: Intel Core Ultra 9 285HX
+ cores: 24
+ threads: 24
+ram:
+ size: 32
+ mts: 4800
+drives:
+ - type: nvme
+ size: 1024
+nics:
+ - type: sfp28
+ speed: 25
+ ports: 2
+ - type: rj45
+ speed: 10
+ ports: 1
+ - type: rj45
+ speed: 2.5
+ ports: 1
+gpus:
+ - model: Intel Graphics
+ vram: 0
+ipmi: true
diff --git a/templates/servers/Minisforum-MS-A2-7945HX.yaml b/templates/servers/Minisforum-MS-A2-7945HX.yaml
new file mode 100644
index 00000000..c325967d
--- /dev/null
+++ b/templates/servers/Minisforum-MS-A2-7945HX.yaml
@@ -0,0 +1,26 @@
+# Minisforum MS-A2 Mini Workstation
+# https://www.minisforum.uk/products/minisforum-ms-a2
+# Dual 10G SFP+ | Dual 2.5G RJ45 | PCIe x16 slot
+kind: Server
+name: Minisforum-MS-A2
+cpus:
+ - model: AMD Ryzen 9 7945HX
+ cores: 16
+ threads: 32
+ram:
+ size: 32
+ mts: 5600
+drives:
+ - type: nvme
+ size: 1024
+nics:
+ - type: sfp+
+ speed: 10
+ ports: 2
+ - type: rj45
+ speed: 2.5
+ ports: 2
+gpus:
+ - model: AMD Radeon 610M
+ vram: 0
+ipmi: false
diff --git a/templates/servers/Minisforum-MS-A2-995HX5.yaml b/templates/servers/Minisforum-MS-A2-995HX5.yaml
new file mode 100644
index 00000000..a2e7a88d
--- /dev/null
+++ b/templates/servers/Minisforum-MS-A2-995HX5.yaml
@@ -0,0 +1,27 @@
+# Minisforum MS-A2 Mini Workstation
+# https://www.minisforum.uk/products/minisforum-ms-a2
+# CPU options: Ryzen 9 9955HX (16C/32T) | Ryzen 9 7945HX (16C/32T)
+# Dual 10G SFP+ | Dual 2.5G RJ45 | PCIe x16 slot
+kind: Server
+name: Minisforum-MS-A2
+cpus:
+ - model: AMD Ryzen 9 9955HX
+ cores: 16
+ threads: 32
+ram:
+ size: 32
+ mts: 5600
+drives:
+ - type: nvme
+ size: 1024
+nics:
+ - type: sfp+
+ speed: 10
+ ports: 2
+ - type: rj45
+ speed: 2.5
+ ports: 2
+gpus:
+ - model: AMD Radeon 610M
+ vram: 0
+ipmi: false
diff --git a/templates/servers/Minisforum-MS-R1.yaml b/templates/servers/Minisforum-MS-R1.yaml
new file mode 100644
index 00000000..1dab4ba0
--- /dev/null
+++ b/templates/servers/Minisforum-MS-R1.yaml
@@ -0,0 +1,24 @@
+# Minisforum MS-R1 ARM Mini Workstation
+# https://www.minisforum.uk/products/minisforum-ms-r1
+# World's first ARM mini workstation with UEFI | CIX CP8180 (12C/12T, 2.6GHz, 28W)
+# Dual 10GbE RJ45 | PCIe x16 slot | LPDDR5 5500 ECC
+kind: Server
+name: Minisforum-MS-R1
+cpus:
+ - model: CIX CP8180
+ cores: 12
+ threads: 12
+ram:
+ size: 64
+ mts: 5500
+drives:
+ - type: nvme
+ size: 1024
+nics:
+ - type: rj45
+ speed: 10
+ ports: 2
+gpus:
+ - model: Arm Immortalis-G720 MC10
+ vram: 0
+ipmi: false
diff --git a/templates/servers/Minisforum-UM890-Pro.yaml b/templates/servers/Minisforum-UM890-Pro.yaml
new file mode 100644
index 00000000..7e4c6a65
--- /dev/null
+++ b/templates/servers/Minisforum-UM890-Pro.yaml
@@ -0,0 +1,23 @@
+# Minisforum UM890 Pro Mini PC
+# https://www.minisforum.uk/products/minisforum-um890pro
+# AMD Ryzen 9 8945HS (8C/16T) | Radeon 780M | Dual 2.5G RJ45 | OCuLink
+kind: Server
+name: Minisforum-UM890-Pro
+cpus:
+ - model: AMD Ryzen 9 8945HS
+ cores: 8
+ threads: 16
+ram:
+ size: 32
+ mts: 5600
+drives:
+ - type: nvme
+ size: 1024
+nics:
+ - type: rj45
+ speed: 2.5
+ ports: 2
+gpus:
+ - model: AMD Radeon 780M
+ vram: 0
+ipmi: false
diff --git a/templates/switches/UniFi-USW-16-PoE.yaml b/templates/switches/UniFi-USW-16-PoE.yaml
new file mode 100644
index 00000000..71da4f56
--- /dev/null
+++ b/templates/switches/UniFi-USW-16-PoE.yaml
@@ -0,0 +1,16 @@
+# UniFi Switch 16 PoE (42W)
+# https://eu.store.ui.com/eu/en/category/switching-standard/products/usw-16-poe
+# 16-port Layer 2 PoE switch | Fanless silent cooling
+# (16) 1 GbE RJ45 + (2) 1G SFP
+kind: Switch
+name: UniFi-USW-16-PoE
+model: UniFi-USW-16-PoE
+ports:
+ - type: rj45
+ speed: 1
+ count: 16
+ - type: sfp
+ speed: 1
+ count: 2
+managed: true
+poe: true
diff --git a/templates/switches/UniFi-USW-24-PoE.yaml b/templates/switches/UniFi-USW-24-PoE.yaml
new file mode 100644
index 00000000..43f53017
--- /dev/null
+++ b/templates/switches/UniFi-USW-24-PoE.yaml
@@ -0,0 +1,16 @@
+# UniFi Switch 24 PoE (95W)
+# https://eu.store.ui.com/eu/en/category/switching-standard/products/usw-24-poe
+# 24-port Layer 2 PoE switch | Fanless cooling
+# (24) 1 GbE RJ45 + (2) 1G SFP
+kind: Switch
+name: UniFi-USW-24-PoE
+model: UniFi-USW-24-PoE
+ports:
+ - type: rj45
+ speed: 1
+ count: 24
+ - type: sfp
+ speed: 1
+ count: 2
+managed: true
+poe: true
diff --git a/templates/switches/UniFi-USW-24.yaml b/templates/switches/UniFi-USW-24.yaml
new file mode 100644
index 00000000..656b2add
--- /dev/null
+++ b/templates/switches/UniFi-USW-24.yaml
@@ -0,0 +1,16 @@
+# UniFi Switch 24
+# https://eu.store.ui.com/eu/en/category/switching-standard/products/usw-24
+# 24-port Layer 2 switch | Fanless silent cooling
+# (24) 1 GbE RJ45 + (2) 1G SFP
+kind: Switch
+name: UniFi-USW-24
+model: UniFi-USW-24
+ports:
+ - type: rj45
+ speed: 1
+ count: 24
+ - type: sfp
+ speed: 1
+ count: 2
+managed: true
+poe: false
diff --git a/templates/switches/UniFi-USW-Enterprise-24.yaml b/templates/switches/UniFi-USW-Enterprise-24.yaml
new file mode 100644
index 00000000..db6952e0
--- /dev/null
+++ b/templates/switches/UniFi-USW-Enterprise-24.yaml
@@ -0,0 +1,15 @@
+kind: Switch
+name: UniFi-USW-Enterprise-24
+model: UniFi-USW-Enterprise-24
+ports:
+ - type: rj45
+ speed: 1
+ count: 12
+ - type: rj45
+ speed: 2.5
+ count: 8
+ - type: sfp+
+ speed: 10
+ count: 4
+managed: true
+poe: true
diff --git a/templates/switches/UniFi-USW-Flex-2.5G-PoE.yaml b/templates/switches/UniFi-USW-Flex-2.5G-PoE.yaml
new file mode 100644
index 00000000..49ad8117
--- /dev/null
+++ b/templates/switches/UniFi-USW-Flex-2.5G-PoE.yaml
@@ -0,0 +1,15 @@
+# UniFi Switch Flex 2.5G PoE (196W)
+# https://eu.store.ui.com/eu/en/category/switching-utility/products/usw-flex-2-5g-8-poe
+# Flexible Layer 2 PoE++ switch: (8) 2.5 GbE PoE++ + (1) 10G combo uplink
+kind: Switch
+name: UniFi-USW-Flex-2.5G-PoE
+model: UniFi-USW-Flex-2.5G-PoE
+ports:
+ - type: rj45
+ speed: 2.5
+ count: 8
+ - type: sfp+
+ speed: 10
+ count: 1
+managed: true
+poe: true
diff --git a/templates/switches/UniFi-USW-Flex-2.5G.yaml b/templates/switches/UniFi-USW-Flex-2.5G.yaml
new file mode 100644
index 00000000..40445603
--- /dev/null
+++ b/templates/switches/UniFi-USW-Flex-2.5G.yaml
@@ -0,0 +1,15 @@
+# UniFi Switch Flex 2.5G
+# https://eu.store.ui.com/eu/en/category/switching-utility/products/usw-flex-2-5g-8
+# Flexible Layer 2 switch: (8) 2.5 GbE + (1) 10G combo uplink, USB-C or PoE+ powered
+kind: Switch
+name: UniFi-USW-Flex-2.5G
+model: UniFi-USW-Flex-2.5G
+ports:
+ - type: rj45
+ speed: 2.5
+ count: 8
+ - type: sfp+
+ speed: 10
+ count: 1
+managed: true
+poe: false
diff --git a/templates/switches/UniFi-USW-Flex-XG.yaml b/templates/switches/UniFi-USW-Flex-XG.yaml
new file mode 100644
index 00000000..56f7170e
--- /dev/null
+++ b/templates/switches/UniFi-USW-Flex-XG.yaml
@@ -0,0 +1,15 @@
+# UniFi Switch Flex XG (10 GbE)
+# https://eu.store.ui.com/eu/en/category/switching-utility/products/usw-flex-xg
+# Compact Layer 2 switch: (4) 10 GbE RJ45 + (1) 10G SFP+, PoE or USB-C powered
+kind: Switch
+name: UniFi-USW-Flex-XG
+model: UniFi-USW-Flex-XG
+ports:
+ - type: rj45
+ speed: 10
+ count: 4
+ - type: sfp+
+ speed: 10
+ count: 1
+managed: true
+poe: false
diff --git a/templates/switches/UniFi-USW-Lite-16-PoE.yaml b/templates/switches/UniFi-USW-Lite-16-PoE.yaml
new file mode 100644
index 00000000..6e416af0
--- /dev/null
+++ b/templates/switches/UniFi-USW-Lite-16-PoE.yaml
@@ -0,0 +1,12 @@
+# UniFi Switch Lite 16 PoE (45W)
+# https://eu.store.ui.com/eu/en/category/switching-utility/products/usw-lite-16-poe
+# Wall-mountable, Layer 2 PoE switch, fanless: (16) 1 GbE (8 PoE)
+kind: Switch
+name: UniFi-USW-Lite-16-PoE
+model: UniFi-USW-Lite-16-PoE
+ports:
+ - type: rj45
+ speed: 1
+ count: 16
+managed: true
+poe: true
diff --git a/templates/switches/UniFi-USW-Lite-8-PoE.yaml b/templates/switches/UniFi-USW-Lite-8-PoE.yaml
new file mode 100644
index 00000000..3d7f7ad9
--- /dev/null
+++ b/templates/switches/UniFi-USW-Lite-8-PoE.yaml
@@ -0,0 +1,12 @@
+# UniFi Switch Lite 8 PoE (52W)
+# https://eu.store.ui.com/eu/en/category/switching-utility/products/usw-lite-8-poe
+# Layer 2 PoE switch, fanless: (8) 1 GbE (4 PoE)
+kind: Switch
+name: UniFi-USW-Lite-8-PoE
+model: UniFi-USW-Lite-8-PoE
+ports:
+ - type: rj45
+ speed: 1
+ count: 8
+managed: true
+poe: true
diff --git a/templates/switches/UniFi-USW-Pro-48-PoE.yaml b/templates/switches/UniFi-USW-Pro-48-PoE.yaml
new file mode 100644
index 00000000..b8008b15
--- /dev/null
+++ b/templates/switches/UniFi-USW-Pro-48-PoE.yaml
@@ -0,0 +1,12 @@
+kind: Switch
+name: UniFi-USW-Pro-48-PoE
+model: UniFi-USW-Pro-48-PoE
+ports:
+ - type: rj45
+ speed: 1
+ count: 48
+ - type: sfp+
+ speed: 10
+ count: 4
+managed: true
+poe: true
diff --git a/templates/switches/UniFi-USW-Pro-HD-24-PoE.yaml b/templates/switches/UniFi-USW-Pro-HD-24-PoE.yaml
new file mode 100644
index 00000000..3e7df93e
--- /dev/null
+++ b/templates/switches/UniFi-USW-Pro-HD-24-PoE.yaml
@@ -0,0 +1,18 @@
+# UniFi Switch Pro HD 24 PoE (600W)
+# https://eu.store.ui.com/eu/en/category/switching-professional-max-xg/products/usw-pro-hd-24-poe
+# Layer 3 Etherlighting switch: (22) 2.5 GbE PoE++ + (2) 10 GbE PoE++ + (4) 10G SFP+
+kind: Switch
+name: UniFi-USW-Pro-HD-24-PoE
+model: UniFi-USW-Pro-HD-24-PoE
+ports:
+ - type: rj45
+ speed: 2.5
+ count: 22
+ - type: rj45
+ speed: 10
+ count: 2
+ - type: sfp+
+ speed: 10
+ count: 4
+managed: true
+poe: true
diff --git a/templates/switches/UniFi-USW-Pro-HD-24.yaml b/templates/switches/UniFi-USW-Pro-HD-24.yaml
new file mode 100644
index 00000000..4ba11aad
--- /dev/null
+++ b/templates/switches/UniFi-USW-Pro-HD-24.yaml
@@ -0,0 +1,18 @@
+# UniFi Switch Pro HD 24
+# https://eu.store.ui.com/eu/en/category/switching-professional-max-xg/products/usw-pro-hd-24
+# Layer 3 Etherlighting switch: (22) 2.5 GbE + (2) 10 GbE + (4) 10G SFP+
+kind: Switch
+name: UniFi-USW-Pro-HD-24
+model: UniFi-USW-Pro-HD-24
+ports:
+ - type: rj45
+ speed: 2.5
+ count: 22
+ - type: rj45
+ speed: 10
+ count: 2
+ - type: sfp+
+ speed: 10
+ count: 4
+managed: true
+poe: false
diff --git a/templates/switches/UniFi-USW-Pro-Max-16.yaml b/templates/switches/UniFi-USW-Pro-Max-16.yaml
new file mode 100644
index 00000000..46b44fd4
--- /dev/null
+++ b/templates/switches/UniFi-USW-Pro-Max-16.yaml
@@ -0,0 +1,15 @@
+# UniFi Switch Pro Max 16
+# https://eu.store.ui.com/eu/en/category/switching-professional-max-xg/products/usw-pro-max-16
+# Layer 3 Etherlighting switch: (16) 2.5 GbE + (2) 10G SFP+
+kind: Switch
+name: UniFi-USW-Pro-Max-16
+model: UniFi-USW-Pro-Max-16
+ports:
+ - type: rj45
+ speed: 2.5
+ count: 16
+ - type: sfp+
+ speed: 10
+ count: 2
+managed: true
+poe: false
diff --git a/templates/switches/UniFi-USW-Pro-XG-10-PoE.yaml b/templates/switches/UniFi-USW-Pro-XG-10-PoE.yaml
new file mode 100644
index 00000000..ffde30bc
--- /dev/null
+++ b/templates/switches/UniFi-USW-Pro-XG-10-PoE.yaml
@@ -0,0 +1,15 @@
+# UniFi Switch Pro XG 10 PoE (400W)
+# https://eu.store.ui.com/eu/en/category/switching-professional-max-xg/products/usw-pro-xg-10-poe
+# 1U Layer 3 Etherlighting PoE+++ switch: (10) 10 GbE + (2) 10G SFP+
+kind: Switch
+name: UniFi-USW-Pro-XG-10-PoE
+model: UniFi-USW-Pro-XG-10-PoE
+ports:
+ - type: rj45
+ speed: 10
+ count: 10
+ - type: sfp+
+ speed: 10
+ count: 2
+managed: true
+poe: true
diff --git a/templates/switches/UniFi-USW-Pro-XG-8-PoE.yaml b/templates/switches/UniFi-USW-Pro-XG-8-PoE.yaml
new file mode 100644
index 00000000..d05585b7
--- /dev/null
+++ b/templates/switches/UniFi-USW-Pro-XG-8-PoE.yaml
@@ -0,0 +1,16 @@
+# UniFi Switch Pro XG 8 PoE (155W)
+# https://eu.store.ui.com/eu/en/category/switching-utility/products/usw-pro-xg-8-poe
+# Compact desktop/wall-mount Layer 3 Etherlighting PoE++ switch
+# (8) 10 GbE RJ45 + (2) 10G SFP+
+kind: Switch
+name: UniFi-USW-Pro-XG-8-PoE
+model: UniFi-USW-Pro-XG-8-PoE
+ports:
+ - type: rj45
+ speed: 10
+ count: 8
+ - type: sfp+
+ speed: 10
+ count: 2
+managed: true
+poe: true
diff --git a/templates/ups/APC-SmartUPS-2200.yaml b/templates/ups/APC-SmartUPS-2200.yaml
new file mode 100644
index 00000000..35fe2b87
--- /dev/null
+++ b/templates/ups/APC-SmartUPS-2200.yaml
@@ -0,0 +1,4 @@
+kind: Ups
+name: APC-SmartUPS-2200
+model: APC-SmartUPS-2200
+va: 2200
diff --git a/vhs/sample_config/config.yaml b/vhs/sample_config/config.yaml
index 255f187e..8185719d 100644
--- a/vhs/sample_config/config.yaml
+++ b/vhs/sample_config/config.yaml
@@ -1,513 +1,438 @@
+version: 1
resources:
- # ------------------------
- # Servers
- # ------------------------
- - kind: Server
- name: proxmox-node01
- cpus:
- - model: AMD EPYC 7302P
- cores: 16
- threads: 32
- ram:
- size: 128gb
- mts: 3200
- drives:
- - type: ssd
- size: 1tb
- - type: ssd
- size: 1tb
- nics:
- - type: rj45
- speed: 1gb
- ports: 2
- - type: sfp+
- speed: 10gb
- ports: 2
- ipmi: true
-
- - kind: Server
- name: proxmox-node02
- cpus:
- - model: Intel Xeon Silver 4210
- cores: 10
- threads: 20
- ram:
- size: 96gb
- mts: 2666
- drives:
- - type: ssd
- size: 1tb
- - type: hdd
- size: 4tb
- nics:
- - type: rj45
- speed: 1gb
- ports: 2
- - type: sfp+
- speed: 10gb
- ports: 1
- ipmi: true
-
- - kind: Server
- name: truenas-storage
- cpus:
- - model: Intel Xeon E-2236
- cores: 6
- threads: 12
- ram:
- size: 64gb
- mts: 2666
- drives:
- - type: hdd
- size: 8tb
- - type: hdd
- size: 8tb
- - type: hdd
- size: 8tb
- - type: hdd
- size: 8tb
- nics:
- - type: rj45
- speed: 1gb
- ports: 1
- - type: sfp+
- speed: 10gb
- ports: 1
- ipmi: true
-
- # ------------------------
- # Network
- # ------------------------
- - kind: Firewall
- name: pfsense-fw
- model: Netgate-6100
- ports:
- - type: rj45
- speed: 1gb
- count: 4
- - type: sfp+
- speed: 10gb
- count: 2
- managed: true
- poe: false
-
- - kind: Router
- name: core-router
- model: Ubiquiti-ER-4
- ports:
- - type: rj45
- speed: 1gb
- count: 4
- - type: sfp
- speed: 10gb
- count: 1
- managed: true
- poe: false
-
- - kind: Switch
- name: core-switch
- model: UniFi-USW-Enterprise-24
- ports:
- - type: rj45
- speed: 1gb
- count: 12
- - type: rj45
- speed: 2.5gb
- count: 8
- - type: sfp+
- speed: 10gb
- count: 4
- managed: true
- poe: true
-
- - kind: Switch
- name: access-switch
- model: UniFi-USW-16-PoE
- ports:
- - type: rj45
- speed: 1gb
- count: 16
- - type: sfp
- speed: 1gb
- count: 2
- managed: true
- poe: true
-
- - kind: AccessPoint
- name: lounge-ap
- model: UniFi-U6-Pro
- speed: 2.5gb
-
- # ------------------------
- # Power
- # ------------------------
- - kind: Ups
- name: rack-ups
- model: APC-SmartUPS-2200
- va: 2200
-
- # ------------------------
- # Desktops
- # ------------------------
- - kind: Desktop
- name: workstation-linux
- cpus:
- - model: AMD Ryzen 9 5900X
- cores: 12
- threads: 24
- ram:
- size: 64gb
- mts: 3600
- drives:
- - type: ssd
- size: 1tb
- - type: ssd
- size: 2tb
- nics:
- - type: rj45
- speed: 1gb
- ports: 1
- gpus:
- - model: NVIDIA RTX 3080
- vram: 10gb
-
- - kind: Desktop
- name: gaming-pc
- cpus:
- - model: Intel Core i7-12700K
- cores: 12
- threads: 20
- ram:
- size: 32gb
- mts: 3200
- drives:
- - type: ssd
- size: 1tb
- nics:
- - type: rj45
- speed: 1gb
- ports: 1
- gpus:
- - model: NVIDIA RTX 3070
- vram: 8gb
-
- # ------------------------
- # Laptop
- # ------------------------
- - kind: Laptop
- name: dev-laptop
- cpus:
- - model: Intel Core i7-1260P
- cores: 12
- threads: 16
- ram:
- size: 32gb
- mts: 5200
- drives:
- - type: ssd
- size: 1tb
- # --------------------------------------------------
- # Smart Home
- # --------------------------------------------------
- - kind: Service
- name: home-assistant
- network:
- ip: 192.168.0.10
- port: 8123
- protocol: TCP
- url: http://homeassistant.lan:8123
- runsOn: vm-home-assistant
-
- # --------------------------------------------------
- # Media & Photos
- # --------------------------------------------------
- - kind: Service
- name: plex
- network:
- ip: 192.168.0.20
- port: 32400
- protocol: TCP
- url: http://plex.lan:32400
- runsOn: vm-media-server
-
- - kind: Service
- name: jellyfin
- network:
- ip: 192.168.0.21
- port: 8096
- protocol: TCP
- url: http://jellyfin.lan:8096
- runsOn: vm-media-server
-
- - kind: Service
- name: immich
- network:
- ip: 192.168.0.22
- port: 8080
- protocol: TCP
- url: http://immich.lan:8080
- runsOn: vm-media-server
-
- # --------------------------------------------------
- # Storage & Backup
- # --------------------------------------------------
- - kind: Service
- name: truenas-webui
- network:
- ip: 192.168.0.30
- port: 443
- protocol: TCP
- url: https://truenas.lan
- runsOn: truenas-core-os
-
- - kind: Service
- name: minio
- network:
- ip: 192.168.0.31
- port: 9000
- protocol: TCP
- url: http://minio.lan:9000
- runsOn: vm-media-server
-
- # --------------------------------------------------
- # Monitoring & Ops
- # --------------------------------------------------
- - kind: Service
- name: prometheus
- network:
- ip: 192.168.0.40
- port: 9090
- protocol: TCP
- url: http://prometheus.lan:9090
- runsOn: vm-monitoring
-
- - kind: Service
- name: grafana
- network:
- ip: 192.168.0.41
- port: 3000
- protocol: TCP
- url: http://grafana.lan:3000
- runsOn: vm-monitoring
-
- - kind: Service
- name: alertmanager
- network:
- ip: 192.168.0.42
- port: 9093
- protocol: TCP
- url: http://alertmanager.lan:9093
- runsOn: vm-monitoring
-
- # --------------------------------------------------
- # Dev & Internal Tools
- # --------------------------------------------------
- - kind: Service
- name: gitea
- network:
- ip: 192.168.0.50
- port: 3001
- protocol: TCP
- url: http://git.lan:3001
- runsOn: vm-monitoring
-
- - kind: Service
- name: docker-registry
- network:
- ip: 192.168.0.51
- port: 5000
- protocol: TCP
- url: http://registry.lan:5000
- runsOn: vm-monitoring
-
- - kind: Service
- name: portainer
- network:
- ip: 192.168.0.52
- port: 9000
- protocol: TCP
- url: http://portainer.lan:9000
- runsOn: vm-monitoring
-
- # --------------------------------------------------
- # Network Services
- # --------------------------------------------------
- - kind: Service
- name: pihole
- network:
- ip: 192.168.0.53
- port: 80
- protocol: TCP
- url: http://pihole.lan
- runsOn: vm-monitoring
-
- - kind: Service
- name: firewall-webui
- network:
- ip: 192.168.0.1
- port: 443
- protocol: TCP
- url: https://firewall.lan
- runsOn: firewall-os
-
- - kind: Service
- name: router-webui
- network:
- ip: 192.168.0.254
- port: 443
- protocol: TCP
- url: https://router.lan
- runsOn: router-os
- # --------------------------------------------------
- # Hypervisors (Bare Metal)
- # --------------------------------------------------
- - kind: System
- type: Hypervisor
- name: proxmox-cluster-node01
- os: proxmox
+- kind: Server
+ ram:
+ size: 128
+ mts: 3200
+ ipmi: true
+ cpus:
+ - model: AMD EPYC 7302P
cores: 16
- ram: 128gb
- drives:
- - size: 1tb
- - size: 1tb
- runsOn: proxmox-node01
-
- - kind: System
- type: Hypervisor
- name: proxmox-cluster-node02
- os: proxmox
+ threads: 32
+ drives:
+ - type: ssd
+ size: 1024
+ - type: ssd
+ size: 1024
+ nics:
+ - type: rj45
+ speed: 1
+ ports: 2
+ - type: sfp+
+ speed: 10
+ ports: 2
+ name: proxmox-node01
+ labels:
+ env: production
+- kind: Server
+ ram:
+ size: 96
+ mts: 2666
+ ipmi: true
+ cpus:
+ - model: Intel Xeon Silver 4210
cores: 10
- ram: 96gb
- drives:
- - size: 1tb
- - size: 4tb
- runsOn: proxmox-node02
-
- # --------------------------------------------------
- # Storage OS (Bare Metal)
- # --------------------------------------------------
- - kind: System
- type: Baremetal
- name: truenas-core-os
- os: truenas
+ threads: 20
+ drives:
+ - type: ssd
+ size: 1024
+ - type: hdd
+ size: 4096
+ nics:
+ - type: rj45
+ speed: 1
+ ports: 2
+ - type: sfp+
+ speed: 10
+ ports: 1
+ name: proxmox-node02
+ labels:
+ env: production
+- kind: Server
+ ram:
+ size: 64
+ mts: 2666
+ ipmi: true
+ cpus:
+ - model: Intel Xeon E-2236
cores: 6
- ram: 64gb
- drives:
- - size: 8tb
- - size: 8tb
- - size: 8tb
- - size: 8tb
- runsOn: truenas-storage
-
- # --------------------------------------------------
- # IPMI / BMC Management
- # --------------------------------------------------
- - kind: System
- type: Baremetal
- name: ipmi-proxmox-node01
- os: idrac
- cores: 1
- ram: 1gb
- runsOn: proxmox-node01
-
- - kind: System
- type: Baremetal
- name: ipmi-proxmox-node02
- os: ipmi
- cores: 1
- ram: 1gb
- runsOn: proxmox-node02
-
- - kind: System
- type: Baremetal
- name: ipmi-truenas-storage
- os: ipmi
- cores: 1
- ram: 1gb
- runsOn: truenas-storage
-
- # --------------------------------------------------
- # Core Network Systems
- # --------------------------------------------------
- - kind: System
- type: Baremetal
- name: firewall-os
- os: pfsense
- cores: 4
- ram: 8gb
- drives:
- - size: 32gb
- runsOn: pfsense-fw
-
- - kind: System
- type: Baremetal
- name: router-os
- os: edgeos
- cores: 4
- ram: 4gb
- drives:
- - size: 4gb
- runsOn: core-router
-
- - kind: System
- type: Baremetal
- name: unifi-core-switch-os
- os: unifi-os
- cores: 2
- ram: 2gb
- drives:
- - size: 8gb
- runsOn: core-switch
-
- - kind: System
- type: Baremetal
- name: unifi-access-switch-os
- os: unifi-os
- cores: 2
- ram: 2gb
- drives:
- - size: 8gb
- runsOn: access-switch
-
- - kind: System
- type: Baremetal
- name: unifi-lounge-ap-os
- os: unifi-firmware
- cores: 2
- ram: 1gb
- drives:
- - size: 4gb
- runsOn: lounge-ap
-
- # --------------------------------------------------
- # Virtual Machines
- # --------------------------------------------------
- - kind: System
- type: VM
- name: vm-home-assistant
- os: hassos
- cores: 2
- ram: 4gb
- drives:
- - size: 64gb
- runsOn: proxmox-node01
-
- - kind: System
- type: VM
- name: vm-media-server
- os: ubuntu-22.04
- cores: 4
- ram: 8gb
- drives:
- - size: 500gb
- runsOn: proxmox-node02
-
- - kind: System
- type: VM
- name: vm-monitoring
- os: debian-12
- cores: 2
- ram: 4gb
- drives:
- - size: 64gb
- runsOn: proxmox-node01
\ No newline at end of file
+ threads: 12
+ drives:
+ - type: hdd
+ size: 8192
+ - type: hdd
+ size: 8192
+ - type: hdd
+ size: 8192
+ - type: hdd
+ size: 8192
+ nics:
+ - type: rj45
+ speed: 1
+ ports: 1
+ - type: sfp+
+ speed: 10
+ ports: 1
+ name: truenas-storage
+- kind: Firewall
+ model: Netgate-6100
+ managed: true
+ poe: false
+ ports:
+ - type: rj45
+ speed: 1
+ count: 4
+ - type: sfp+
+ speed: 10
+ count: 2
+ name: pfsense-fw
+- kind: Router
+ model: Ubiquiti-ER-4
+ managed: true
+ poe: false
+ ports:
+ - type: rj45
+ speed: 1
+ count: 4
+ - type: sfp
+ speed: 10
+ count: 1
+ name: core-router
+- kind: Switch
+ model: UniFi-USW-Enterprise-24
+ managed: true
+ poe: true
+ ports:
+ - type: rj45
+ speed: 1
+ count: 12
+ - type: rj45
+ speed: 2.5
+ count: 8
+ - type: sfp+
+ speed: 10
+ count: 4
+ name: core-switch
+ labels:
+ location: rack-a
+- kind: Switch
+ model: UniFi-USW-16-PoE
+ managed: true
+ poe: true
+ ports:
+ - type: rj45
+ speed: 1
+ count: 16
+ - type: sfp
+ speed: 1
+ count: 2
+ name: access-switch
+- kind: AccessPoint
+ model: UniFi-U6-Pro
+ speed: 2.5
+ name: lounge-ap
+- kind: Ups
+ model: APC-SmartUPS-2200
+ va: 2200
+ name: rack-ups
+- kind: Desktop
+ ram:
+ size: 64
+ mts: 3600
+ cpus:
+ - model: AMD Ryzen 9 5900X
+ cores: 12
+ threads: 24
+ drives:
+ - type: ssd
+ size: 1024
+ - type: ssd
+ size: 2048
+ gpus:
+ - model: NVIDIA RTX 3080
+ vram: 10
+ nics:
+ - type: rj45
+ speed: 1
+ ports: 1
+ name: workstation-linux
+- kind: Desktop
+ ram:
+ size: 32
+ mts: 3200
+ cpus:
+ - model: Intel Core i7-12700K
+ cores: 12
+ threads: 20
+ drives:
+ - type: ssd
+ size: 1024
+ gpus:
+ - model: NVIDIA RTX 3070
+ vram: 8
+ nics:
+ - type: rj45
+ speed: 1
+ ports: 1
+ name: gaming-pc
+- kind: Laptop
+ ram:
+ size: 32
+ mts: 5200
+ cpus:
+ - model: Intel Core i7-1260P
+ cores: 12
+ threads: 16
+ drives:
+ - type: ssd
+ size: 1024
+ name: dev-laptop
+- kind: Service
+ network:
+ ip: 192.168.0.10
+ port: 8123
+ protocol: TCP
+ url: http://homeassistant.lan:8123
+ name: home-assistant
+ runsOn: vm-home-assistant
+- kind: Service
+ network:
+ ip: 192.168.0.20
+ port: 32400
+ protocol: TCP
+ url: http://plex.lan:32400
+ name: plex
+ runsOn: vm-media-server
+- kind: Service
+ network:
+ ip: 192.168.0.21
+ port: 8096
+ protocol: TCP
+ url: http://jellyfin.lan:8096
+ name: jellyfin
+ runsOn: vm-media-server
+- kind: Service
+ network:
+ ip: 192.168.0.22
+ port: 8080
+ protocol: TCP
+ url: http://immich.lan:8080
+ name: immich
+ runsOn: vm-media-server
+- kind: Service
+ network:
+ ip: 192.168.0.30
+ port: 443
+ protocol: TCP
+ url: https://truenas.lan
+ name: truenas-webui
+ runsOn: truenas-core-os
+- kind: Service
+ network:
+ ip: 192.168.0.31
+ port: 9000
+ protocol: TCP
+ url: http://minio.lan:9000
+ name: minio
+ runsOn: vm-media-server
+- kind: Service
+ network:
+ ip: 192.168.0.40
+ port: 9090
+ protocol: TCP
+ url: http://prometheus.lan:9090
+ name: prometheus
+ labels:
+ team: monitoring
+ runsOn: vm-monitoring
+- kind: Service
+ network:
+ ip: 192.168.0.41
+ port: 3000
+ protocol: TCP
+ url: http://grafana.lan:3000
+ name: grafana
+ runsOn: vm-monitoring
+- kind: Service
+ network:
+ ip: 192.168.0.42
+ port: 9093
+ protocol: TCP
+ url: http://alertmanager.lan:9093
+ name: alertmanager
+ runsOn: vm-monitoring
+- kind: Service
+ network:
+ ip: 192.168.0.50
+ port: 3001
+ protocol: TCP
+ url: http://git.lan:3001
+ name: gitea
+ runsOn: vm-monitoring
+- kind: Service
+ network:
+ ip: 192.168.0.51
+ port: 5000
+ protocol: TCP
+ url: http://registry.lan:5000
+ name: docker-registry
+ runsOn: vm-monitoring
+- kind: Service
+ network:
+ ip: 192.168.0.52
+ port: 9000
+ protocol: TCP
+ url: http://portainer.lan:9000
+ name: portainer
+ runsOn: vm-monitoring
+- kind: Service
+ network:
+ ip: 192.168.0.53
+ port: 80
+ protocol: TCP
+ url: http://pihole.lan
+ name: pihole
+ runsOn: vm-monitoring
+- kind: Service
+ network:
+ ip: 192.168.0.1
+ port: 443
+ protocol: TCP
+ url: https://firewall.lan
+ name: firewall-webui
+ runsOn: firewall-os
+- kind: Service
+ network:
+ ip: 192.168.0.254
+ port: 443
+ protocol: TCP
+ url: https://router.lan
+ name: router-webui
+ runsOn: router-os
+- kind: System
+ type: Hypervisor
+ os: proxmox
+ cores: 16
+ ram: 128
+ drives:
+ - size: 1024
+ - size: 1024
+ name: proxmox-cluster-node01
+ runsOn: proxmox-node01
+- kind: System
+ type: Hypervisor
+ os: proxmox
+ cores: 10
+ ram: 96
+ drives:
+ - size: 1024
+ - size: 4096
+ name: proxmox-cluster-node02
+ runsOn: proxmox-node02
+- kind: System
+ type: Baremetal
+ os: truenas
+ cores: 6
+ ram: 64
+ drives:
+ - size: 8192
+ - size: 8192
+ - size: 8192
+ - size: 8192
+ name: truenas-core-os
+ runsOn: truenas-storage
+- kind: System
+ type: Baremetal
+ os: idrac
+ cores: 1
+ ram: 1
+ name: ipmi-proxmox-node01
+ runsOn: proxmox-node01
+- kind: System
+ type: Baremetal
+ os: ipmi
+ cores: 1
+ ram: 1
+ name: ipmi-proxmox-node02
+ runsOn: proxmox-node02
+- kind: System
+ type: Baremetal
+ os: ipmi
+ cores: 1
+ ram: 1
+ name: ipmi-truenas-storage
+ runsOn: truenas-storage
+- kind: System
+ type: Baremetal
+ os: pfsense
+ cores: 4
+ ram: 8
+ drives:
+ - size: 32
+ name: firewall-os
+ runsOn: pfsense-fw
+- kind: System
+ type: Baremetal
+ os: edgeos
+ cores: 4
+ ram: 4
+ drives:
+ - size: 4
+ name: router-os
+ runsOn: core-router
+- kind: System
+ type: Baremetal
+ os: unifi-os
+ cores: 2
+ ram: 2
+ drives:
+ - size: 8
+ name: unifi-core-switch-os
+ runsOn: core-switch
+- kind: System
+ type: Baremetal
+ os: unifi-os
+ cores: 2
+ ram: 2
+ drives:
+ - size: 8
+ name: unifi-access-switch-os
+ runsOn: access-switch
+- kind: System
+ type: Baremetal
+ os: unifi-firmware
+ cores: 2
+ ram: 1
+ drives:
+ - size: 4
+ name: unifi-lounge-ap-os
+ runsOn: lounge-ap
+- kind: System
+ type: VM
+ os: hassos
+ cores: 2
+ ram: 4
+ drives:
+ - size: 64
+ name: vm-home-assistant
+ runsOn: proxmox-node01
+- kind: System
+ type: VM
+ os: ubuntu-22.04
+ cores: 4
+ ram: 8
+ drives:
+ - size: 500
+ name: vm-media-server
+ runsOn: proxmox-node02
+- kind: System
+ type: VM
+ os: debian-12
+ cores: 2
+ ram: 4
+ drives:
+ - size: 64
+ name: vm-monitoring
+ labels:
+ backup: daily
+ runsOn: proxmox-node01