Explorar o código

Added ansible inventory generator

Tim Jones hai 1 mes
pai
achega
600bf7f1b8
Modificáronse 37 ficheiros con 2203 adicións e 343 borrados
  1. 9 25
      README.md
  2. 207 0
      RackPeek.Domain/UseCases/Ansible/AnsibleInventoryGenerator.cs
  3. 15 0
      RackPeek.Domain/UseCases/Ansible/AnsibleInventoryGeneratorUseCase.cs
  4. 65 20
      RackPeek.Web.Viewer/Pages/Home.razor
  5. 0 0
      RackPeek.Web.Viewer/wwwroot/schemas/v1/schema.v1.json
  6. 64 20
      RackPeek.Web/Components/Pages/Home.razor
  7. 265 0
      RackPeek.Web/wwwroot/schemas/v1/schema.v1.json
  8. 13 0
      Shared.Rcl/CliBootstrap.cs
  9. 114 0
      Shared.Rcl/Commands/Ansible/GenerateAnsibleInventoryCommand.cs
  10. 171 0
      Shared.Rcl/Components/AnsibleInventory.razor
  11. 0 121
      Shared.Rcl/Components/DocsPage.razor
  12. 273 0
      Shared.Rcl/Docs/DocsHomePage.razor
  13. 1 1
      Shared.Rcl/Layout/MainLayout.razor
  14. 15 2
      Shared.Rcl/YamlFilePage.razor
  15. 0 142
      Shared.Rcl/wwwroot/raw_docs/CommandIndex.md
  16. 326 0
      Shared.Rcl/wwwroot/raw_docs/ansible-generator-guide.md
  17. 142 0
      Shared.Rcl/wwwroot/raw_docs/cli-commands-index.md
  18. 0 0
      Shared.Rcl/wwwroot/raw_docs/cli-commands.md
  19. 7 0
      Shared.Rcl/wwwroot/raw_docs/docs-index.json
  20. 181 0
      Shared.Rcl/wwwroot/raw_docs/install-guide.md
  21. 87 0
      Shared.Rcl/wwwroot/raw_docs/overview.md
  22. 72 0
      Tests.E2e/AnsibleInventoryTests.cs
  23. 74 0
      Tests.E2e/PageObjectModels/AnsibleInventoryPagePom.cs
  24. 90 0
      Tests/EndToEnd/AnsibleTests/AnsibleInventoryWorkflowTests.cs
  25. 0 0
      Tests/EndToEnd/Labels/LabelsWorkflowTests.cs
  26. 1 1
      Tests/EndToEnd/LaptopTests/LaptopCommandTests.cs
  27. 1 1
      Tests/EndToEnd/LaptopTests/LaptopErrorTests.cs
  28. 1 1
      Tests/EndToEnd/LaptopTests/LaptopWorkflowTests.cs
  29. 1 1
      Tests/EndToEnd/RouterTests/RouterCommandTests.cs
  30. 1 1
      Tests/EndToEnd/RouterTests/RouterErrorTests.cs
  31. 1 1
      Tests/EndToEnd/RouterTests/RouterWorkflowTests.cs
  32. 1 1
      Tests/EndToEnd/ServerTests/ServerCommandTests.cs
  33. 1 1
      Tests/EndToEnd/ServerTests/ServerErrorTests.cs
  34. 1 1
      Tests/EndToEnd/ServerTests/ServerWorkflowTests.cs
  35. 1 1
      Tests/EndToEnd/ServiceTests/ServiceCommandTests.cs
  36. 1 1
      Tests/EndToEnd/ServiceTests/ServiceErrorTests.cs
  37. 1 1
      Tests/EndToEnd/ServiceTests/ServiceWorkflowTests.cs

+ 9 - 25
README.md

@@ -60,35 +60,19 @@ volumes:
 
 ```
 
-```bash
-docker compose up -d
-```
-
-## Installing on Linux 
-
-```bash
-# Download the RackPeek binary
-wget https://github.com/Timmoth/RackPeek/releases/download/RackPeek-0.0.3/rackpeek_0_0_3_linux-x64 -O rackpeek
+## Docs
 
-# Or with curl:
-curl -L https://github.com/Timmoth/RackPeek/releases/download/RackPeek-0.0.3/rackpeek_0_0_3_linux-x64 -o rackpeek
+* 
+  [**Overview**](https://timmoth.github.io/RackPeek/docs/overview)
 
-# Make the binary executable
-chmod +x rackpeek
+* 
+  [**Installation Guide**](https://timmoth.github.io/RackPeek/docs/install-guide)
 
-# Move RackPeek into your PATH
-sudo mv rackpeek /usr/local/bin/rpk
+* 
+  [**Ansible Inventory Generator Guide**](https://timmoth.github.io/RackPeek/docs/ansible-generator-guide)
 
-# Create the global config directory
-# RackPeek expects a `config` folder **next to the binary**, so create it in `/usr/local/bin`:
-sudo mkdir -p /usr/local/bin/config
-
-# Create the empty `config.yaml`
-sudo touch /usr/local/bin/config/config.yaml
-
-# Test the installation
-rpk --help
-```
+* 
+  [**CLI Commands Reference**](https://timmoth.github.io/RackPeek/docs/cli-commands)
 
 ## Release Status
 ```

+ 207 - 0
RackPeek.Domain/UseCases/Ansible/AnsibleInventoryGenerator.cs

@@ -0,0 +1,207 @@
+namespace RackPeek.Domain.Ansible;
+
+using System.Text;
+using Resources;
+
+public sealed record InventoryOptions
+{
+    /// <summary>
+    /// If set, create groups based on these tags.
+    /// Example: ["env", "site"] -> [env], [site]
+    /// </summary>
+    public IReadOnlyList<string> GroupByTags { get; init; } = [];
+
+    /// <summary>
+    /// If set, create groups based on these label keys.
+    /// Example: ["env", "site"] -> [env_prod], [site_london]
+    /// </summary>
+    public IReadOnlyList<string> GroupByLabelKeys { get; init; } = [];
+
+    /// <summary>
+    /// If set, emitted under [all:vars].
+    /// </summary>
+    public IDictionary<string, string> GlobalVars { get; init; } = new Dictionary<string, string>();
+}
+
+public sealed record InventoryResult(string InventoryText, IReadOnlyList<string> Warnings);
+
+public static class AnsibleInventoryGenerator
+{
+    /// <summary>
+    /// Generate an Ansible inventory in INI format from RackPeek resources.
+    /// </summary>
+    public static InventoryResult ToAnsibleInventoryIni(
+        this IReadOnlyList<Resource> resources,
+        InventoryOptions? options = null)
+    {
+        options ??= new InventoryOptions();
+
+        var warnings = new List<string>();
+        var hosts = new List<HostEntry>();
+
+        // Build host entries (only resources that look addressable)
+        foreach (var r in resources)
+        {
+            var address = GetAddress(r);
+
+            if (string.IsNullOrWhiteSpace(address))
+            {
+                continue;
+            }
+
+            var hostVars = BuildHostVars(r, address);
+            hosts.Add(new HostEntry(r, hostVars));
+        }
+
+        // Groups: kind + tags + label-based
+        var groupToHosts = new Dictionary<string, List<HostEntry>>(StringComparer.OrdinalIgnoreCase);
+
+        void AddToGroup(string groupName, HostEntry h)
+        {
+            if (string.IsNullOrWhiteSpace(groupName)) return;
+            groupName = SanitizeGroup(groupName);
+
+            if (!groupToHosts.TryGetValue(groupName, out var list))
+                groupToHosts[groupName] = list = new List<HostEntry>();
+
+            // avoid duplicates if multiple rules add the same host
+            if (!list.Any(x => string.Equals(x.Resource.Name, h.Resource.Name, StringComparison.OrdinalIgnoreCase)))
+                list.Add(h);
+        }
+
+        foreach (var h in hosts)
+        {
+            // Tag groups
+            var tags = options.GroupByTags.Intersect(h.Resource.Tags).ToArray();
+            foreach (var tag in tags)
+            {
+                if (string.IsNullOrWhiteSpace(tag)) continue;
+                AddToGroup(tag, h);
+            }
+
+            // Label-based groups: e.g. env=prod -> [env_prod]
+            foreach (var key in options.GroupByLabelKeys)
+            {
+                if (string.IsNullOrWhiteSpace(key)) continue;
+
+                if (h.Resource.Labels.TryGetValue(key, out var val) && !string.IsNullOrWhiteSpace(val))
+                {
+                    AddToGroup($"{key}_{val}", h);
+                }
+            }
+        }
+
+        // Build output
+        var sb = new StringBuilder();
+
+        // [all:vars]
+        if (options.GlobalVars.Count > 0)
+        {
+            sb.AppendLine("[all:vars]");
+            foreach (var kvp in options.GlobalVars.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase))
+                sb.AppendLine($"{kvp.Key}={EscapeIniValue(kvp.Value)}");
+            sb.AppendLine();
+        }
+
+        // Emit groups sorted, hosts sorted
+        foreach (var group in groupToHosts.Keys.OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
+        {
+            sb.AppendLine($"[{group}]");
+
+            foreach (var h in groupToHosts[group].OrderBy(x => x.Resource.Name, StringComparer.OrdinalIgnoreCase))
+            {
+                sb.Append(h.Resource.Name);
+
+                // host vars (inline)
+                foreach (var kvp in h.HostVars.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase))
+                    sb.Append($" {kvp.Key}={EscapeIniValue(kvp.Value)}");
+
+                sb.AppendLine();
+            }
+
+            sb.AppendLine();
+        }
+
+        return new InventoryResult(sb.ToString().TrimEnd(), warnings);
+    }
+
+    // ---------- helpers ----------
+
+    private sealed record HostEntry(Resource Resource, Dictionary<string, string> HostVars);
+
+    private static string? GetAddress(Resource r)
+    {
+        // Preferred: ansible_host, else ip, else hostname
+        if (r.Labels.TryGetValue("ansible_host", out var ah) && !string.IsNullOrWhiteSpace(ah))
+            return ah;
+
+        if (r.Labels.TryGetValue("ip", out var ip) && !string.IsNullOrWhiteSpace(ip))
+            return ip;
+
+        if (r.Labels.TryGetValue("hostname", out var hn) && !string.IsNullOrWhiteSpace(hn))
+            return hn;
+
+        return null;
+    }
+
+    private static Dictionary<string, string> BuildHostVars(Resource r, string address)
+    {
+        var vars = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
+        {
+            ["ansible_host"] = address
+        };
+
+        // Copy any labels prefixed with ansible_
+        foreach (var (k, v) in r.Labels)
+        {
+            if (string.IsNullOrWhiteSpace(k) || string.IsNullOrWhiteSpace(v)) continue;
+
+            if (k.StartsWith("ansible_", StringComparison.OrdinalIgnoreCase))
+            {
+                // don't overwrite ansible_host we already derived unless explicitly present
+                if (string.Equals(k, "ansible_host", StringComparison.OrdinalIgnoreCase))
+                    vars["ansible_host"] = v;
+                else
+                    vars[k] = v;
+            }
+        }
+
+        // Record your relationship info if present
+        if (!string.IsNullOrWhiteSpace(r.RunsOn))
+            vars["rackpeek_runs_on"] = r.RunsOn!;
+
+        // If you want tags/labels available to playbooks, export them too:
+        // vars["rackpeek_kind"] = r.Kind;
+        // vars["rackpeek_tags"] = string.Join(",", r.Tags ?? Array.Empty<string>());
+
+        return vars;
+    }
+
+    private static string SanitizeGroup(string s)
+    {
+        // Ansible group names: keep it simple: letters/digits/underscore
+        var sb = new StringBuilder(s.Length);
+        foreach (var ch in s.Trim().ToLowerInvariant())
+        {
+            if (char.IsLetterOrDigit(ch) || ch == '_')
+                sb.Append(ch);
+            else if (ch == '-' || ch == '.' || ch == ' ')
+                sb.Append('_');
+            // drop everything else
+        }
+
+        var result = sb.ToString();
+        return string.IsNullOrWhiteSpace(result) ? "group" : result;
+    }
+
+    private static string EscapeIniValue(string value)
+    {
+        // Keep simple: quote if it contains spaces or special chars
+        if (string.IsNullOrEmpty(value)) return "\"\"";
+
+        var needsQuotes = value.Any(ch => char.IsWhiteSpace(ch) || ch is '"' or '\'' or '=');
+        if (!needsQuotes) return value;
+
+        return "\"" + value.Replace("\"", "\\\"") + "\"";
+    }
+}

+ 15 - 0
RackPeek.Domain/UseCases/Ansible/AnsibleInventoryGeneratorUseCase.cs

@@ -0,0 +1,15 @@
+using RackPeek.Domain.Ansible;
+using RackPeek.Domain.Helpers;
+using RackPeek.Domain.Persistence;
+using RackPeek.Domain.Resources.SubResources;
+
+namespace RackPeek.Domain.Resources.Desktops;
+
+public class AnsibleInventoryGeneratorUseCase(IResourceCollection repository) : IUseCase
+{
+    public async Task<InventoryResult?> ExecuteAsync(InventoryOptions options)
+    {
+        var resources = await repository.GetAllOfTypeAsync<Resource>();
+        return resources.ToAnsibleInventoryIni(options);
+    }
+}

+ 65 - 20
RackPeek.Web.Viewer/Pages/Home.razor

@@ -17,33 +17,78 @@
     }
     else
     {
-        <!-- Totals -->
-        <div class="mb-10 max-w-md">
-            <div class="border border-zinc-800 rounded-md p-4">
-                <div class="text-xs text-zinc-500 uppercase tracking-wider mb-3">
-                    Totals
-                </div>
+<!-- Totals + Tools -->
+<div class="mb-10 grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl">
 
-                <div class="grid grid-cols-2 gap-y-2 ">
+    <!-- Totals -->
+    <div class="border border-zinc-800 rounded-md p-4">
+        <div class="text-xs text-zinc-500 uppercase tracking-wider mb-3">
+            Totals
+        </div>
 
-                    <div class="hover:text-emerald-300">
-                        <NavLink href="@("hardware/tree")">Hardware</NavLink>
-                    </div>
-                    <div class="text-right">@_hardware!.TotalHardware</div>
+        <div class="grid grid-cols-2 gap-y-2">
 
-                    <div class="hover:text-emerald-300">
-                        <NavLink href="@("systems/list")">Systems</NavLink>
-                    </div>
-                    <div class="text-right">@_system!.TotalSystems</div>
+            <div class="hover:text-emerald-300">
+                <NavLink href="@("/hardware/tree")">→ Hardware</NavLink>
+            </div>
+            <div class="text-right">@_hardware!.TotalHardware</div>
 
-                    <div class="hover:text-emerald-300">
-                        <NavLink href="@("services/list")">Services</NavLink>
-                    </div>
-                    <div class="text-right">@_service!.TotalServices</div>
-                </div>
+            <div class="hover:text-emerald-300">
+                <NavLink href="@("/systems/list")">→ Systems</NavLink>
             </div>
+            <div class="text-right">@_system!.TotalSystems</div>
+
+            <div class="hover:text-emerald-300">
+                <NavLink href="@("/services/list")">→ Services</NavLink>
+            </div>
+            <div class="text-right">@_service!.TotalServices</div>
         </div>
+    </div>
 
+    <!-- Tools -->
+    <div class="border border-zinc-800 rounded-md p-4">
+        <div class="text-xs text-zinc-500 uppercase tracking-wider mb-3">
+            Tools
+        </div>
+
+        <ul class="space-y-2 text-sm">
+
+            <li>
+                <NavLink href="cli"
+                         class="block hover:text-emerald-300"
+                         data-testid="home-tool-cli">
+                    → CLI Emulator
+                </NavLink>
+            </li>
+
+            <li>
+                <NavLink href="yaml"
+                         class="block hover:text-emerald-300"
+                         data-testid="home-tool-yaml">
+                    → YAML Editor
+                </NavLink>
+            </li>
+
+            <li>
+                <NavLink href="ansible/inventory"
+                         class="block hover:text-emerald-300"
+                         data-testid="home-tool-ansible">
+                    → Ansible Inventory Generator
+                </NavLink>
+            </li>
+
+            <li>
+                <NavLink href="docs"
+                         class="block hover:text-emerald-300"
+                         data-testid="home-tool-docs">
+                    → Documentation
+                </NavLink>
+            </li>
+
+        </ul>
+    </div>
+</div>
+ 
         <div class="space-y-10 mb-10">
             <TagListComponent/>
         </div>

+ 0 - 0
Shared.Rcl/wwwroot/schemas/v1/schema.v1.json → RackPeek.Web.Viewer/wwwroot/schemas/v1/schema.v1.json


+ 64 - 20
RackPeek.Web/Components/Pages/Home.razor

@@ -18,33 +18,77 @@
     }
     else
     {
-        <!-- Totals -->
-        <div class="mb-10 max-w-md">
-            <div class="border border-zinc-800 rounded-md p-4">
-                <div class="text-xs text-zinc-500 uppercase tracking-wider mb-3">
-                    Totals
-                </div>
+<!-- Totals + Tools -->
+<div class="mb-10 grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl">
 
-                <div class="grid grid-cols-2 gap-y-2 ">
+    <!-- Totals -->
+    <div class="border border-zinc-800 rounded-md p-4">
+        <div class="text-xs text-zinc-500 uppercase tracking-wider mb-3">
+            Totals
+        </div>
 
-                    <div class="hover:text-emerald-300">
-                        <NavLink href="@("/hardware/tree")">Hardware</NavLink>
-                    </div>
-                    <div class="text-right">@_hardware!.TotalHardware</div>
+        <div class="grid grid-cols-2 gap-y-2">
 
-                    <div class="hover:text-emerald-300">
-                        <NavLink href="@("/systems/list")">Systems</NavLink>
-                    </div>
-                    <div class="text-right">@_system!.TotalSystems</div>
+            <div class="hover:text-emerald-300">
+                <NavLink href="@("/hardware/tree")">→ Hardware</NavLink>
+            </div>
+            <div class="text-right">@_hardware!.TotalHardware</div>
 
-                    <div class="hover:text-emerald-300">
-                        <NavLink href="@("/services/list")">Services</NavLink>
-                    </div>
-                    <div class="text-right">@_service!.TotalServices</div>
-                </div>
+            <div class="hover:text-emerald-300">
+                <NavLink href="@("/systems/list")">→ Systems</NavLink>
             </div>
+            <div class="text-right">@_system!.TotalSystems</div>
+
+            <div class="hover:text-emerald-300">
+                <NavLink href="@("/services/list")">→ Services</NavLink>
+            </div>
+            <div class="text-right">@_service!.TotalServices</div>
         </div>
+    </div>
 
+    <!-- Tools -->
+    <div class="border border-zinc-800 rounded-md p-4">
+        <div class="text-xs text-zinc-500 uppercase tracking-wider mb-3">
+            Tools
+        </div>
+
+        <ul class="space-y-2 text-sm">
+
+            <li>
+                <NavLink href="cli"
+                         class="block hover:text-emerald-300"
+                         data-testid="home-tool-cli">
+                    → CLI Emulator
+                </NavLink>
+            </li>
+
+            <li>
+                <NavLink href="yaml"
+                         class="block hover:text-emerald-300"
+                         data-testid="home-tool-yaml">
+                    → YAML Editor
+                </NavLink>
+            </li>
+
+            <li>
+                <NavLink href="ansible/inventory"
+                         class="block hover:text-emerald-300"
+                         data-testid="home-tool-ansible">
+                    → Ansible Inventory Generator
+                </NavLink>
+            </li>
+
+            <li>
+                <NavLink href="docs"
+                         class="block hover:text-emerald-300"
+                         data-testid="home-tool-docs">
+                    → Documentation
+                </NavLink>
+            </li>
+
+        </ul>
+    </div>
+</div>
         <div class="space-y-10 mb-10">
             <TagListComponent/>
         </div>

+ 265 - 0
RackPeek.Web/wwwroot/schemas/v1/schema.v1.json

@@ -0,0 +1,265 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema#",
+  "$id": "https://timmoth.github.io/RackPeek/schemas/v1/schema.v1.json",
+  "title": "RackPeek Infrastructure Specification",
+  "type": "object",
+  "additionalProperties": false,
+  "required": ["version", "resources"],
+  "properties": {
+    "version": {
+      "type": "integer",
+      "const": 1
+    },
+    "resources": {
+      "type": "array",
+      "items": {
+        "oneOf": [
+          { "$ref": "#/$defs/server" },
+          { "$ref": "#/$defs/firewall" },
+          { "$ref": "#/$defs/router" },
+          { "$ref": "#/$defs/switch" },
+          { "$ref": "#/$defs/accessPoint" },
+          { "$ref": "#/$defs/ups" },
+          { "$ref": "#/$defs/desktop" },
+          { "$ref": "#/$defs/laptop" },
+          { "$ref": "#/$defs/service" },
+          { "$ref": "#/$defs/system" }
+        ]
+      }
+    }
+  },
+  "$defs": {
+    "ram": {
+      "type": "object",
+      "required": ["size"],
+      "additionalProperties": false,
+      "properties": {
+        "size": { "type": "number", "minimum": 0 },
+        "mts": { "type": "integer", "minimum": 0 }
+      }
+    },
+    "cpu": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "model": { "type": "string" },
+        "cores": { "type": "integer", "minimum": 1 },
+        "threads": { "type": "integer", "minimum": 1 }
+      }
+    },
+    "drive": {
+      "type": "object",
+      "required": ["size"],
+      "additionalProperties": false,
+      "properties": {
+        "type": {
+          "type": "string",
+          "enum": ["nvme","ssd","hdd","sas","sata","usb","sdcard","micro-sd"]
+        },
+        "size": { "type": "number", "minimum": 1 }
+      }
+    },
+    "gpu": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "model": { "type": "string" },
+        "vram": { "type": "number", "minimum": 0 }
+      }
+    },
+    "nic": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "type": {
+          "type": "string",
+          "enum": [
+            "rj45","sfp","sfp+","sfp28","sfp56",
+            "qsfp+","qsfp28","qsfp56","qsfp-dd",
+            "osfp","xfp","cx4","mgmt"
+          ]
+        },
+        "speed": { "type": "number", "minimum": 0 },
+        "ports": { "type": "integer", "minimum": 1 }
+      }
+    },
+    "port": {
+      "type": "object",
+      "required": ["type","speed","count"],
+      "additionalProperties": false,
+      "properties": {
+        "type": { "type": "string" },
+        "speed": { "type": "number", "minimum": 0 },
+        "count": { "type": "integer", "minimum": 1 }
+      }
+    },
+    "network": {
+      "type": "object",
+      "required": ["ip","port","protocol"],
+      "additionalProperties": false,
+      "properties": {
+        "ip": {
+          "type": "string",
+          "pattern": "^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\\.|$)){4}$"
+        },
+        "port": { "type": "integer", "minimum": 1, "maximum": 65535 },
+        "protocol": { "type": "string", "enum": ["TCP","UDP"] },
+        "url": { "type": "string" }
+      }
+    },
+
+    "server": {
+      "type": "object",
+      "required": ["kind","name"],
+      "additionalProperties": false,
+      "properties": {
+        "kind": { "const": "Server" },
+        "name": { "type": "string" },
+        "tags": { "type": "array", "items": { "type": "string" } },
+        "notes": { "type": "string" },
+        "runsOn": { "type": "string" },
+        "ram": { "$ref": "#/$defs/ram" },
+        "ipmi": { "type": "boolean" },
+        "cpus": { "type": "array", "items": { "$ref": "#/$defs/cpu" } },
+        "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } },
+        "gpus": { "type": "array", "items": { "$ref": "#/$defs/gpu" } },
+        "nics": { "type": "array", "items": { "$ref": "#/$defs/nic" } }
+      }
+    },
+
+    "desktop": {
+      "type": "object",
+      "required": ["kind","name"],
+      "additionalProperties": false,
+      "properties": {
+        "kind": { "const": "Desktop" },
+        "name": { "type": "string" },
+        "notes": { "type": "string" },
+        "ram": { "$ref": "#/$defs/ram" },
+        "cpus": { "type": "array", "items": { "$ref": "#/$defs/cpu" } },
+        "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } },
+        "gpus": { "type": "array", "items": { "$ref": "#/$defs/gpu" } },
+        "nics": { "type": "array", "items": { "$ref": "#/$defs/nic" } }
+      }
+    },
+
+    "laptop": {
+      "type": "object",
+      "required": ["kind","name"],
+      "additionalProperties": false,
+      "properties": {
+        "kind": { "const": "Laptop" },
+        "name": { "type": "string" },
+        "notes": { "type": "string" },
+        "ram": { "$ref": "#/$defs/ram" },
+        "cpus": { "type": "array", "items": { "$ref": "#/$defs/cpu" } },
+        "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } }
+      }
+    },
+
+    "firewall": {
+      "type": "object",
+      "required": ["kind","name","ports"],
+      "additionalProperties": false,
+      "properties": {
+        "kind": { "const": "Firewall" },
+        "name": { "type": "string" },
+        "model": { "type": "string" },
+        "managed": { "type": "boolean" },
+        "poe": { "type": "boolean" },
+        "ports": { "type": "array", "items": { "$ref": "#/$defs/port" } }
+      }
+    },
+
+    "router": {
+      "type": "object",
+      "required": ["kind","name","ports"],
+      "additionalProperties": false,
+      "properties": {
+        "kind": { "const": "Router" },
+        "name": { "type": "string" },
+        "model": { "type": "string" },
+        "managed": { "type": "boolean" },
+        "poe": { "type": "boolean" },
+        "ports": { "type": "array", "items": { "$ref": "#/$defs/port" } }
+      }
+    },
+
+    "switch": {
+      "type": "object",
+      "required": ["kind","name","ports"],
+      "additionalProperties": false,
+      "properties": {
+        "kind": { "const": "Switch" },
+        "name": { "type": "string" },
+        "model": { "type": "string" },
+        "managed": { "type": "boolean" },
+        "poe": { "type": "boolean" },
+        "ports": { "type": "array", "items": { "$ref": "#/$defs/port" } }
+      }
+    },
+
+    "accessPoint": {
+      "type": "object",
+      "required": ["kind","name"],
+      "additionalProperties": false,
+      "properties": {
+        "kind": { "const": "AccessPoint" },
+        "name": { "type": "string" },
+        "tags": { "type": "array", "items": { "type": "string" } },
+        "model": { "type": "string" },
+        "speed": { "type": "number" }
+      }
+    },
+
+    "ups": {
+      "type": "object",
+      "required": ["kind","name"],
+      "additionalProperties": false,
+      "properties": {
+        "kind": { "const": "Ups" },
+        "name": { "type": "string" },
+        "tags": { "type": "array", "items": { "type": "string" } },
+        "model": { "type": "string" },
+        "va": { "type": "integer", "minimum": 1 }
+      }
+    },
+
+    "service": {
+      "type": "object",
+      "required": ["kind","name","network","runsOn"],
+      "additionalProperties": false,
+      "properties": {
+        "kind": { "const": "Service" },
+        "name": { "type": "string" },
+        "runsOn": { "type": "string" },
+        "network": { "$ref": "#/$defs/network" }
+      }
+    },
+
+    "system": {
+      "type": "object",
+      "required": ["kind","name","type","os","cores","ram"],
+      "additionalProperties": false,
+      "properties": {
+        "kind": { "const": "System" },
+        "name": { "type": "string" },
+        "notes": { "type": "string" },
+        "runsOn": { "type": "string" },
+        "type": {
+          "type": "string",
+          "enum": [
+            "baremetal","Baremetal",
+            "hypervisor","Hypervisor",
+            "vm","VM",
+            "container","embedded","cloud","other"
+          ]
+        },
+        "os": { "type": "string" },
+        "cores": { "type": "integer", "minimum": 1 },
+        "ram": { "type": "number", "minimum": 0 },
+        "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } }
+      }
+    }
+  }
+}

+ 13 - 0
Shared.Rcl/CliBootstrap.cs

@@ -12,6 +12,7 @@ using RackPeek.Domain.Persistence.Yaml;
 using Shared.Rcl.Commands;
 using Shared.Rcl.Commands.AccessPoints;
 using Shared.Rcl.Commands.AccessPoints.Labels;
+using Shared.Rcl.Commands.Ansible;
 using Shared.Rcl.Commands.Desktops;
 using Shared.Rcl.Commands.Desktops.Labels;
 using Shared.Rcl.Commands.Desktops.Cpus;
@@ -582,6 +583,18 @@ public static class CliBootstrap
                     label.AddCommand<ServiceLabelRemoveCommand>("remove").WithDescription("Remove a label from a service.");
                 });
             });
+            
+            // ----------------------------
+            // Ansible
+            // ----------------------------
+            config.AddBranch("ansible", ansible =>
+            {
+                ansible.SetDescription("Generate and manage Ansible inventory.");
+
+                ansible.AddCommand<GenerateAnsibleInventoryCommand>("inventory")
+                    .WithDescription("Generate an Ansible inventory.");
+            });
+            
         });
     }
 

+ 114 - 0
Shared.Rcl/Commands/Ansible/GenerateAnsibleInventoryCommand.cs

@@ -0,0 +1,114 @@
+using RackPeek.Domain.Resources.Desktops;
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.Ansible;
+using Spectre.Console;
+using Spectre.Console.Cli;
+using System.ComponentModel;
+
+namespace Shared.Rcl.Commands.Ansible;
+
+public sealed class GenerateAnsibleInventorySettings : CommandSettings
+{
+    [CommandOption("--group-tags")]
+    [Description("Comma-separated list of tags to group by (e.g. prod,staging)")]
+    public string? GroupTags { get; init; }
+
+    [CommandOption("--group-labels")]
+    [Description("Comma-separated list of label keys to group by (e.g. env,site)")]
+    public string? GroupLabels { get; init; }
+
+    [CommandOption("--global-var")]
+    [Description("Global variable (repeatable). Format: key=value")]
+    public string[] GlobalVars { get; init; } = [];
+
+    [CommandOption("-o|--output")]
+    [Description("Write inventory to file instead of stdout")]
+    public string? OutputPath { get; init; }
+}
+
+
+public sealed class GenerateAnsibleInventoryCommand(IServiceProvider provider)
+    : AsyncCommand<GenerateAnsibleInventorySettings>
+{
+    public override async Task<int> ExecuteAsync(
+        CommandContext context,
+        GenerateAnsibleInventorySettings settings,
+        CancellationToken cancellationToken)
+    {
+        using var scope = provider.CreateScope();
+
+        var useCase = scope.ServiceProvider
+            .GetRequiredService<AnsibleInventoryGeneratorUseCase>();
+
+        var options = new InventoryOptions
+        {
+            GroupByTags = ParseCsv(settings.GroupTags),
+            GroupByLabelKeys = ParseCsv(settings.GroupLabels),
+            GlobalVars = ParseGlobalVars(settings.GlobalVars),
+        };
+
+        var result = await useCase.ExecuteAsync(options);
+
+        if (result is null)
+        {
+            AnsiConsole.MarkupLine("[red]Inventory generation returned null.[/]");
+            return -1;
+        }
+
+        if (result.Warnings.Any())
+        {
+            AnsiConsole.MarkupLine("[yellow]Warnings:[/]");
+            foreach (var warning in result.Warnings)
+                AnsiConsole.MarkupLine($"[yellow]- {Markup.Escape(warning)}[/]");
+            AnsiConsole.WriteLine();
+        }
+
+        if (!string.IsNullOrWhiteSpace(settings.OutputPath))
+        {
+            await File.WriteAllTextAsync(settings.OutputPath, result.InventoryText, cancellationToken);
+
+            AnsiConsole.MarkupLine(
+                $"[green]Inventory written to:[/] {Markup.Escape(settings.OutputPath)}");
+        }
+        else
+        {
+            AnsiConsole.MarkupLine("[green]Generated Inventory:[/]");
+            AnsiConsole.WriteLine();
+            AnsiConsole.Write(result.InventoryText);
+        }
+
+        return 0;
+    }
+
+    // ------------------------
+
+    private static IReadOnlyList<string> ParseCsv(string? raw)
+    {
+        if (string.IsNullOrWhiteSpace(raw))
+            return [];
+
+        return raw.Split(',', StringSplitOptions.RemoveEmptyEntries)
+                  .Select(x => x.Trim())
+                  .Where(x => !string.IsNullOrWhiteSpace(x))
+                  .ToArray();
+    }
+
+    private static IDictionary<string, string> ParseGlobalVars(string[] vars)
+    {
+        var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
+
+        foreach (var entry in vars ?? [])
+        {
+            var parts = entry.Split('=', 2);
+            if (parts.Length != 2) continue;
+
+            var key = parts[0].Trim();
+            var value = parts[1].Trim();
+
+            if (!string.IsNullOrWhiteSpace(key))
+                dict[key] = value;
+        }
+
+        return dict;
+    }
+}

+ 171 - 0
Shared.Rcl/Components/AnsibleInventory.razor

@@ -0,0 +1,171 @@
+@page "/ansible/inventory"
+
+@using RackPeek.Domain.Ansible
+@using RackPeek.Domain.Resources.Desktops
+@inject AnsibleInventoryGeneratorUseCase InventoryUseCase
+
+<div class="border border-zinc-800 rounded p-4 bg-zinc-900 max-w-5xl mx-auto"
+     data-testid="ansible-inventory-page">
+
+    <div class="flex justify-between items-center mb-4">
+        <div class="text-zinc-100 text-lg">
+            Ansible Inventory Generator
+        </div>
+
+        <button class="text-sm bg-emerald-600 hover:bg-emerald-500 px-3 py-1 rounded text-white transition"
+                data-testid="generate-inventory-button"
+                @onclick="GenerateInventory">
+            Generate
+        </button>
+    </div>
+
+    <!-- Options -->
+    <div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
+
+        <!-- Tags -->
+        <div>
+            <div class="text-zinc-400 mb-1">Group By Tags</div>
+            <input class="w-full bg-zinc-800 text-zinc-200 p-2 rounded border border-zinc-700"
+                   placeholder="prod, staging, dmz"
+                   data-testid="group-by-tags-input"
+                   @bind="_groupByTagsRaw" />
+            <div class="text-xs text-zinc-500 mt-1">
+                Comma-separated tag names
+            </div>
+        </div>
+
+        <!-- Labels -->
+        <div>
+            <div class="text-zinc-400 mb-1">Group By Labels</div>
+            <input class="w-full bg-zinc-800 text-zinc-200 p-2 rounded border border-zinc-700"
+                   placeholder="env, site"
+                   data-testid="group-by-labels-input"
+                   @bind="_groupByLabelsRaw" />
+            <div class="text-xs text-zinc-500 mt-1">
+                Creates groups like env_prod, site_london
+            </div>
+        </div>
+
+        <!-- Global Vars -->
+        <div class="md:col-span-2">
+            <div class="text-zinc-400 mb-1">Global Variables</div>
+            <textarea class="w-full bg-zinc-800 text-zinc-200 p-2 rounded border border-zinc-700 font-mono text-sm"
+                      rows="4"
+                      placeholder="ansible_user=ansible&#10;ansible_python_interpreter=/usr/bin/python3"
+                      data-testid="global-vars-input"
+                      @bind="_globalVarsRaw">
+            </textarea>
+            <div class="text-xs text-zinc-500 mt-1">
+                One per line: key=value
+            </div>
+        </div>
+    </div>
+
+    <!-- Errors / Warnings -->
+    @if (_warnings.Any())
+    {
+        <div class="border border-red-700 bg-red-900/40 text-red-300 p-3 rounded mb-4"
+             data-testid="inventory-warnings">
+            <div class="font-semibold mb-1">Warnings</div>
+            <ul class="list-disc ml-5 text-sm">
+                @foreach (var warning in _warnings)
+                {
+                    <li>@warning</li>
+                }
+            </ul>
+        </div>
+    }
+
+    <!-- Output -->
+    <div>
+        <div class="text-zinc-400 mb-1">Generated Inventory</div>
+        <textarea class="w-full bg-black text-emerald-400 p-3 rounded border border-zinc-800 font-mono text-sm"
+                  rows="18"
+                  readonly
+                  data-testid="inventory-output">
+@_inventoryText
+        </textarea>
+    </div>
+
+</div>
+
+@code {
+
+    private string _groupByTagsRaw = "prod, staging";
+    private string _groupByLabelsRaw = "env, site";
+    private string _globalVarsRaw =
+@"ansible_user=ansible
+ansible_python_interpreter=/usr/bin/python3";
+    
+    private string _inventoryText = string.Empty;
+    private List<string> _warnings = new();
+
+    private async Task GenerateInventory()
+    {
+        try
+        {
+            _warnings.Clear();
+            _inventoryText = string.Empty;
+
+            var options = new InventoryOptions
+            {
+                GroupByTags = ParseCsv(_groupByTagsRaw),
+                GroupByLabelKeys = ParseCsv(_groupByLabelsRaw),
+                GlobalVars = ParseGlobalVars(_globalVarsRaw)
+            };
+
+            var result = await InventoryUseCase.ExecuteAsync(options);
+            if (result is null)
+            {
+                _warnings.Add("Inventory generation returned null.");
+                return;
+            }
+
+            _inventoryText = result.InventoryText;
+            _warnings = result.Warnings.ToList();
+        }
+        catch (Exception ex)
+        {
+            _warnings.Clear();
+            _warnings.Add($"Unexpected error: {ex.Message}");
+        }
+    }
+
+    private static IReadOnlyList<string> ParseCsv(string raw)
+    {
+        if (string.IsNullOrWhiteSpace(raw))
+            return [];
+
+        return raw.Split(',', StringSplitOptions.RemoveEmptyEntries)
+                  .Select(x => x.Trim())
+                  .Where(x => !string.IsNullOrWhiteSpace(x))
+                  .ToArray();
+    }
+
+    private static IDictionary<string, string> ParseGlobalVars(string raw)
+    {
+        var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
+
+        if (string.IsNullOrWhiteSpace(raw))
+            return dict;
+
+        var lines = raw.Split('\n', StringSplitOptions.RemoveEmptyEntries);
+
+        foreach (var line in lines)
+        {
+            var trimmed = line.Trim();
+            if (string.IsNullOrWhiteSpace(trimmed)) continue;
+
+            var parts = trimmed.Split('=', 2);
+            if (parts.Length != 2) continue;
+
+            var key = parts[0].Trim();
+            var value = parts[1].Trim();
+
+            if (!string.IsNullOrWhiteSpace(key))
+                dict[key] = value;
+        }
+
+        return dict;
+    }
+}

+ 0 - 121
Shared.Rcl/Components/DocsPage.razor

@@ -1,121 +0,0 @@
-@page "/docs/{Page}"
-@inject HttpClient Http
-@inject NavigationManager Nav
-@inject IJSRuntime JS
-@using Markdig
-@implements IDisposable
-
-<PageTitle>Docs: @Page</PageTitle>
-
-<div class="min-h-screen bg-zinc-950 text-zinc-200 font-mono p-6 space-y-6">
-
-    <!-- Header -->
-    <div class="space-y-2">
-        <h1 class="text-lg text-zinc-100">
-            Docs:
-            <span class="text-emerald-400">@Page</span>
-        </h1>
-    </div>
-
-    @if (_isLoading)
-    {
-        <div class="text-zinc-500">loading documentation…</div>
-    }
-    else if (_notFound)
-    {
-        <div class="text-zinc-500">document not found</div>
-    }
-    else
-    {
-        <div class="markdown">
-            @((MarkupString)_htmlContent!)
-        </div>
-    }
-
-</div>
-
-@code {
-
-    [Parameter] public string Page { get; set; } = string.Empty;
-
-    private string? _htmlContent;
-    private bool _isLoading = true;
-    private bool _notFound;
-
-    private static readonly MarkdownPipeline Pipeline =
-        new MarkdownPipelineBuilder()
-            .UseAdvancedExtensions()
-            .UseAutoIdentifiers()
-            .Build();
-
-    private bool _pendingScroll;
-
-    protected override void OnInitialized()
-    {
-        Nav.LocationChanged += HandleLocationChanged;
-    }
-
-    private void HandleLocationChanged(object? sender, LocationChangedEventArgs e)
-    {
-        // Trigger re-render so OnAfterRenderAsync runs again
-        _pendingScroll = true;
-        InvokeAsync(StateHasChanged);
-    }
-
-    protected override async Task OnParametersSetAsync()
-    {
-        _isLoading = true;
-        _notFound = false;
-        _pendingScroll = true;
-
-        try
-        {
-            var decoded = Uri.UnescapeDataString(Page);
-
-            if (!decoded.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
-                decoded += ".md";
-
-            var url = $"_content/Shared.Rcl/raw_docs/{decoded}";
-
-            var markdown = await Http.GetStringAsync(url);
-
-            _htmlContent = Markdown.ToHtml(markdown, Pipeline);
-        }
-        catch
-        {
-            _notFound = true;
-            _htmlContent = null;
-        }
-        finally
-        {
-            _isLoading = false;
-        }
-    }
-
-    protected override async Task OnAfterRenderAsync(bool firstRender)
-    {
-        if (_pendingScroll && !_isLoading && !_notFound)
-        {
-            _pendingScroll = false;
-            await ScrollToFragmentAsync();
-        }
-    }
-
-    private async Task ScrollToFragmentAsync()
-    {
-        var uri = new Uri(Nav.Uri);
-        var fragment = uri.Fragment;
-
-        if (!string.IsNullOrWhiteSpace(fragment))
-        {
-            var anchor = fragment.TrimStart('#');
-            await JS.InvokeVoidAsync("scrollToAnchor", anchor);
-        }
-    }
-
-    public void Dispose()
-    {
-        Nav.LocationChanged -= HandleLocationChanged;
-    }
-
-}

+ 273 - 0
Shared.Rcl/Docs/DocsHomePage.razor

@@ -0,0 +1,273 @@
+@page "/docs"
+@page "/docs/{*Page}"
+
+@inject HttpClient Http
+@inject NavigationManager Nav
+@inject IJSRuntime JS
+@using Markdig
+@implements IDisposable
+
+<PageTitle>Docs@(!string.IsNullOrWhiteSpace(_activeTitle) ? $": {_activeTitle}" : "")</PageTitle>
+
+<div class="min-h-screen bg-zinc-950 text-zinc-200 font-mono p-6">
+
+    <!-- Header -->
+    <div class="flex items-center justify-between mb-6">
+        <div class="space-y-1">
+            <div class="text-xs text-zinc-500 uppercase tracking-wider">
+                Knowledge Base
+            </div>
+            <h1 class="text-lg text-zinc-100">
+                Docs
+                @if (!string.IsNullOrWhiteSpace(_activeTitle))
+                {
+                    <span class="text-zinc-500">:</span>
+                    <span class="text-emerald-400">@_activeTitle</span>
+                }
+            </h1>
+        </div>
+
+        <NavLink href="/docs"
+                 Match="NavLinkMatch.All"
+                 class="text-sm text-emerald-400 hover:text-emerald-300 transition"
+                 data-testid="docs-home-link">
+            → Overview
+        </NavLink>
+    </div>
+
+    <div class="grid grid-cols-1 md:grid-cols-12 gap-6">
+
+        <!-- Sidebar -->
+        <aside class="md:col-span-4 lg:col-span-3">
+            <div class="border border-zinc-800 rounded-md p-4 bg-zinc-900/40">
+
+                <div class="text-xs text-zinc-500 uppercase tracking-wider mb-3">
+                    Pages
+                </div>
+
+                <input class="w-full bg-zinc-800 text-zinc-200 p-2 rounded border border-zinc-700 text-sm"
+                       placeholder="Search docs…"
+                       data-testid="docs-search-input"
+                       @bind="_filter" />
+
+                <div class="mt-3 space-y-1 max-h-[60vh] overflow-auto pr-1">
+
+                    @if (_indexLoading)
+                    {
+                        <div class="text-zinc-500 text-sm">loading index…</div>
+                    }
+                    else if (_indexNotFound)
+                    {
+                        <div class="text-zinc-500 text-sm">
+                            docs index not found
+                        </div>
+                    }
+                    else
+                    {
+                        foreach (var item in FilteredIndex())
+                        {
+                            <NavLink href="@($"/docs/{ToRouteSlug(item)}")"
+                                     class="block text-sm text-zinc-300 hover:text-emerald-300 transition"
+                                     activeClass="text-emerald-400 font-semibold"
+                                     data-testid="@($"docs-index-link-{Sanitize(item)}")">
+                                @DisplayName(item)
+                            </NavLink>
+                        }
+                    }
+                </div>
+            </div>
+        </aside>
+
+        <!-- Content -->
+        <main class="md:col-span-8 lg:col-span-9">
+            <div class="border border-zinc-800 rounded-md p-4 bg-zinc-900/30"
+                 data-testid="docs-viewer">
+
+                @if (_isLoading)
+                {
+                    <div class="text-zinc-500">loading documentation…</div>
+                }
+                else if (_notFound)
+                {
+                    <div class="text-zinc-500">
+                        document not found
+                        <div class="text-xs text-zinc-600 mt-1">
+                            Tried: <span class="text-zinc-400">@_lastFetchUrl</span>
+                        </div>
+                    </div>
+                }
+                else
+                {
+                    <div class="markdown">
+                        @((MarkupString)_htmlContent!)
+                    </div>
+                }
+
+            </div>
+        </main>
+
+    </div>
+</div>
+
+@code {
+
+    [Parameter] public string? Page { get; set; }
+
+    private string? _htmlContent;
+    private bool _isLoading = true;
+    private bool _notFound;
+    private bool _pendingScroll;
+
+    private string _filter = "";
+    private string _activeTitle = "";
+    private string? _lastFetchUrl;
+
+    private bool _indexLoading = true;
+    private bool _indexNotFound;
+    private List<string> _docsIndex = new();
+
+    private static readonly MarkdownPipeline Pipeline =
+        new MarkdownPipelineBuilder()
+            .UseAdvancedExtensions()
+            .UseAutoIdentifiers()
+            .Build();
+
+    protected override void OnInitialized()
+    {
+        Nav.LocationChanged += HandleLocationChanged;
+    }
+
+    protected override async Task OnInitializedAsync()
+    {
+        await LoadIndexAsync();
+    }
+
+    protected override async Task OnParametersSetAsync()
+    {
+        _isLoading = true;
+        _notFound = false;
+        _pendingScroll = true;
+        _activeTitle = "";
+
+        try
+        {
+            // DEFAULT TO overview.md
+            var target = string.IsNullOrWhiteSpace(Page)
+                ? "overview"
+                : Page;
+
+            var decoded = Uri.UnescapeDataString(target);
+
+            if (!decoded.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
+                decoded += ".md";
+
+            var url = $"_content/Shared.Rcl/raw_docs/{decoded}";
+            _lastFetchUrl = url;
+
+            var markdown = await Http.GetStringAsync(url);
+            _htmlContent = Markdown.ToHtml(markdown, Pipeline);
+
+            _activeTitle = DisplayName(decoded);
+        }
+        catch
+        {
+            _notFound = true;
+            _htmlContent = null;
+            _activeTitle = DisplayName(Page ?? "overview");
+        }
+        finally
+        {
+            _isLoading = false;
+        }
+    }
+
+    protected override async Task OnAfterRenderAsync(bool firstRender)
+    {
+        if (_pendingScroll && !_isLoading && !_notFound)
+        {
+            _pendingScroll = false;
+            await ScrollToFragmentAsync();
+        }
+    }
+
+    private void HandleLocationChanged(object? sender, LocationChangedEventArgs e)
+    {
+        _pendingScroll = true;
+        InvokeAsync(StateHasChanged);
+    }
+
+    private async Task ScrollToFragmentAsync()
+    {
+        var uri = new Uri(Nav.Uri);
+        var fragment = uri.Fragment;
+
+        if (!string.IsNullOrWhiteSpace(fragment))
+        {
+            var anchor = fragment.TrimStart('#');
+            await JS.InvokeVoidAsync("scrollToAnchor", anchor);
+        }
+    }
+
+    private async Task LoadIndexAsync()
+    {
+        _indexLoading = true;
+        _indexNotFound = false;
+
+        try
+        {
+            var url = "_content/Shared.Rcl/raw_docs/docs-index.json";
+            var items = await Http.GetFromJsonAsync<List<string>>(url);
+
+            _docsIndex = (items ?? new List<string>())
+                .Where(x => !string.IsNullOrWhiteSpace(x))
+                .Select(x => x.Replace('\\', '/').Trim())
+                .Distinct(StringComparer.OrdinalIgnoreCase)
+                .OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
+                .ToList();
+        }
+        catch
+        {
+            _indexNotFound = true;
+            _docsIndex = new();
+        }
+        finally
+        {
+            _indexLoading = false;
+        }
+    }
+
+    private IEnumerable<string> FilteredIndex()
+    {
+        if (string.IsNullOrWhiteSpace(_filter))
+            return _docsIndex;
+
+        return _docsIndex.Where(x =>
+            x.Contains(_filter, StringComparison.OrdinalIgnoreCase));
+    }
+
+    private static string ToRouteSlug(string path)
+        => path.EndsWith(".md", StringComparison.OrdinalIgnoreCase)
+            ? path[..^3]
+            : path;
+
+    private static string DisplayName(string path)
+    {
+        var p = path.Replace('\\', '/').Trim();
+        if (p.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
+            p = p[..^3];
+
+        return p.Split('/', StringSplitOptions.RemoveEmptyEntries)
+                .LastOrDefault() ?? p;
+    }
+
+    private static string Sanitize(string value)
+        => value.Replace(" ", "-")
+                .Replace("/", "-")
+                .Replace("\\", "-")
+                .Replace(".", "-");
+
+    public void Dispose()
+    {
+        Nav.LocationChanged -= HandleLocationChanged;
+    }
+}

+ 1 - 1
Shared.Rcl/Layout/MainLayout.razor

@@ -60,7 +60,7 @@
                 Services
             </NavLink>
 
-            <NavLink href="docs/CommandIndex"
+            <NavLink href="docs"
                      class="hover:text-emerald-400"
                      activeClass="text-emerald-400 font-semibold"
                      data-testid="nav-docs">

+ 15 - 2
Shared.Rcl/YamlFilePage.razor

@@ -3,11 +3,24 @@
 
 <div class="min-h-screen bg-zinc-950 text-zinc-200 font-mono p-6">
 
-    <YamlFileComponent Path="config/config.yaml">
+    <!-- Top Actions -->
+    <div class="mb-4 flex justify-between items-center">
+        <div class="text-zinc-400 text-sm">
+            Raw YAML Configuration
+        </div>
+
+        <NavLink href="/ansible/inventory"
+                 class="text-sm text-emerald-400 hover:text-emerald-300 transition"
+                 data-testid="yaml-to-ansible-link">
+            → Open Ansible Inventory Generator
+        </NavLink>
+    </div>
 
+    <YamlFileComponent Path="config/config.yaml">
     </YamlFileComponent>
+
 </div>
 
 @code {
 
-}
+}

+ 0 - 142
Shared.Rcl/wwwroot/raw_docs/CommandIndex.md

@@ -1,142 +0,0 @@
-- [rpk](docs/Commands.md#rpk)
-    - [summary](docs/Commands.md#rpk-summary) - Show a summarized report of all resources in the system
-    - [servers](docs/Commands.md#rpk-servers) - Manage servers and their components
-        - [summary](docs/Commands.md#rpk-servers-summary) - Show a summarized hardware report for all servers
-        - [add](docs/Commands.md#rpk-servers-add) - Add a new server to the inventory
-        - [get](docs/Commands.md#rpk-servers-get) - List all servers or retrieve a specific server by name
-        - [describe](docs/Commands.md#rpk-servers-describe) - Display detailed information about a specific server
-        - [set](docs/Commands.md#rpk-servers-set) - Update properties of an existing server
-        - [del](docs/Commands.md#rpk-servers-del) - Delete a server from the inventory
-        - [tree](docs/Commands.md#rpk-servers-tree) - Display the dependency tree of a server
-        - [cpu](docs/Commands.md#rpk-servers-cpu) - Manage CPUs attached to a server
-            - [add](docs/Commands.md#rpk-servers-cpu-add) - Add a CPU to a specific server
-            - [set](docs/Commands.md#rpk-servers-cpu-set) - Update configuration of a server CPU
-            - [del](docs/Commands.md#rpk-servers-cpu-del) - Remove a CPU from a server
-        - [drive](docs/Commands.md#rpk-servers-drive) - Manage drives attached to a server
-            - [add](docs/Commands.md#rpk-servers-drive-add) - Add a storage drive to a server
-            - [set](docs/Commands.md#rpk-servers-drive-set) - Update properties of a server drive
-            - [del](docs/Commands.md#rpk-servers-drive-del) - Remove a drive from a server
-        - [gpu](docs/Commands.md#rpk-servers-gpu) - Manage GPUs attached to a server
-            - [add](docs/Commands.md#rpk-servers-gpu-add) - Add a GPU to a server
-            - [set](docs/Commands.md#rpk-servers-gpu-set) - Update properties of a server GPU
-            - [del](docs/Commands.md#rpk-servers-gpu-del) - Remove a GPU from a server
-        - [nic](docs/Commands.md#rpk-servers-nic) - Manage network interface cards (NICs) for a server
-            - [add](docs/Commands.md#rpk-servers-nic-add) - Add a NIC to a server
-            - [set](docs/Commands.md#rpk-servers-nic-set) - Update properties of a server NIC
-            - [del](docs/Commands.md#rpk-servers-nic-del) - Remove a NIC from a server
-    - [switches](docs/Commands.md#rpk-switches) - Manage network switches
-        - [summary](docs/Commands.md#rpk-switches-summary) - Show a hardware report for all switches
-        - [add](docs/Commands.md#rpk-switches-add) - Add a new network switch to the inventory
-        - [list](docs/Commands.md#rpk-switches-list) - List all switches in the system
-        - [get](docs/Commands.md#rpk-switches-get) - Retrieve details of a specific switch by name
-        - [describe](docs/Commands.md#rpk-switches-describe) - Show detailed information about a switch
-        - [set](docs/Commands.md#rpk-switches-set) - Update properties of a switch
-        - [del](docs/Commands.md#rpk-switches-del) - Delete a switch from the inventory
-        - [port](docs/Commands.md#rpk-switches-port) - Manage ports on a network switch
-            - [add](docs/Commands.md#rpk-switches-port-add) - Add a port to a switch
-            - [set](docs/Commands.md#rpk-switches-port-set) - Update a switch port
-            - [del](docs/Commands.md#rpk-switches-port-del) - Remove a port from a switch
-    - [routers](docs/Commands.md#rpk-routers) - Manage network routers
-        - [summary](docs/Commands.md#rpk-routers-summary) - Show a hardware report for all routers
-        - [add](docs/Commands.md#rpk-routers-add) - Add a new network router to the inventory
-        - [list](docs/Commands.md#rpk-routers-list) - List all routers in the system
-        - [get](docs/Commands.md#rpk-routers-get) - Retrieve details of a specific router by name
-        - [describe](docs/Commands.md#rpk-routers-describe) - Show detailed information about a router
-        - [set](docs/Commands.md#rpk-routers-set) - Update properties of a router
-        - [del](docs/Commands.md#rpk-routers-del) - Delete a router from the inventory
-        - [port](docs/Commands.md#rpk-routers-port) - Manage ports on a router
-            - [add](docs/Commands.md#rpk-routers-port-add) - Add a port to a router
-            - [set](docs/Commands.md#rpk-routers-port-set) - Update a router port
-            - [del](docs/Commands.md#rpk-routers-port-del) - Remove a port from a router
-    - [firewalls](docs/Commands.md#rpk-firewalls) - Manage firewalls
-        - [summary](docs/Commands.md#rpk-firewalls-summary) - Show a hardware report for all firewalls
-        - [add](docs/Commands.md#rpk-firewalls-add) - Add a new firewall to the inventory
-        - [list](docs/Commands.md#rpk-firewalls-list) - List all firewalls in the system
-        - [get](docs/Commands.md#rpk-firewalls-get) - Retrieve details of a specific firewall by name
-        - [describe](docs/Commands.md#rpk-firewalls-describe) - Show detailed information about a firewall
-        - [set](docs/Commands.md#rpk-firewalls-set) - Update properties of a firewall
-        - [del](docs/Commands.md#rpk-firewalls-del) - Delete a firewall from the inventory
-        - [port](docs/Commands.md#rpk-firewalls-port) - Manage ports on a firewall
-            - [add](docs/Commands.md#rpk-firewalls-port-add) - Add a port to a firewall
-            - [set](docs/Commands.md#rpk-firewalls-port-set) - Update a firewall port
-            - [del](docs/Commands.md#rpk-firewalls-port-del) - Remove a port from a firewall
-    - [systems](docs/Commands.md#rpk-systems) - Manage systems and their dependencies
-        - [summary](docs/Commands.md#rpk-systems-summary) - Show a summary report for all systems
-        - [add](docs/Commands.md#rpk-systems-add) - Add a new system to the inventory
-        - [list](docs/Commands.md#rpk-systems-list) - List all systems
-        - [get](docs/Commands.md#rpk-systems-get) - Retrieve a system by name
-        - [describe](docs/Commands.md#rpk-systems-describe) - Display detailed information about a system
-        - [set](docs/Commands.md#rpk-systems-set) - Update properties of a system
-        - [del](docs/Commands.md#rpk-systems-del) - Delete a system from the inventory
-        - [tree](docs/Commands.md#rpk-systems-tree) - Display the dependency tree for a system
-    - [accesspoints](docs/Commands.md#rpk-accesspoints) - Manage access points
-        - [summary](docs/Commands.md#rpk-accesspoints-summary) - Show a hardware report for all access points
-        - [add](docs/Commands.md#rpk-accesspoints-add) - Add a new access point
-        - [list](docs/Commands.md#rpk-accesspoints-list) - List all access points
-        - [get](docs/Commands.md#rpk-accesspoints-get) - Retrieve an access point by name
-        - [describe](docs/Commands.md#rpk-accesspoints-describe) - Show detailed information about an access point
-        - [set](docs/Commands.md#rpk-accesspoints-set) - Update properties of an access point
-        - [del](docs/Commands.md#rpk-accesspoints-del) - Delete an access point
-    - [ups](docs/Commands.md#rpk-ups) - Manage UPS units
-        - [summary](docs/Commands.md#rpk-ups-summary) - Show a hardware report for all UPS units
-        - [add](docs/Commands.md#rpk-ups-add) - Add a new UPS unit
-        - [list](docs/Commands.md#rpk-ups-list) - List all UPS units
-        - [get](docs/Commands.md#rpk-ups-get) - Retrieve a UPS unit by name
-        - [describe](docs/Commands.md#rpk-ups-describe) - Show detailed information about a UPS unit
-        - [set](docs/Commands.md#rpk-ups-set) - Update properties of a UPS unit
-        - [del](docs/Commands.md#rpk-ups-del) - Delete a UPS unit
-    - [desktops](docs/Commands.md#rpk-desktops) - Manage desktop computers and their components
-        - [add](docs/Commands.md#rpk-desktops-add) - Add a new desktop
-        - [list](docs/Commands.md#rpk-desktops-list) - List all desktops
-        - [get](docs/Commands.md#rpk-desktops-get) - Retrieve a desktop by name
-        - [describe](docs/Commands.md#rpk-desktops-describe) - Show detailed information about a desktop
-        - [set](docs/Commands.md#rpk-desktops-set) - Update properties of a desktop
-        - [del](docs/Commands.md#rpk-desktops-del) - Delete a desktop from the inventory
-        - [summary](docs/Commands.md#rpk-desktops-summary) - Show a summarized hardware report for all desktops
-        - [tree](docs/Commands.md#rpk-desktops-tree) - Display the dependency tree for a desktop
-        - [cpu](docs/Commands.md#rpk-desktops-cpu) - Manage CPUs attached to desktops
-            - [add](docs/Commands.md#rpk-desktops-cpu-add) - Add a CPU to a desktop
-            - [set](docs/Commands.md#rpk-desktops-cpu-set) - Update a desktop CPU
-            - [del](docs/Commands.md#rpk-desktops-cpu-del) - Remove a CPU from a desktop
-        - [drive](docs/Commands.md#rpk-desktops-drive) - Manage storage drives attached to desktops
-            - [add](docs/Commands.md#rpk-desktops-drive-add) - Add a drive to a desktop
-            - [set](docs/Commands.md#rpk-desktops-drive-set) - Update a desktop drive
-            - [del](docs/Commands.md#rpk-desktops-drive-del) - Remove a drive from a desktop
-        - [gpu](docs/Commands.md#rpk-desktops-gpu) - Manage GPUs attached to desktops
-            - [add](docs/Commands.md#rpk-desktops-gpu-add) - Add a GPU to a desktop
-            - [set](docs/Commands.md#rpk-desktops-gpu-set) - Update a desktop GPU
-            - [del](docs/Commands.md#rpk-desktops-gpu-del) - Remove a GPU from a desktop
-        - [nic](docs/Commands.md#rpk-desktops-nic) - Manage network interface cards (NICs) for desktops
-            - [add](docs/Commands.md#rpk-desktops-nic-add) - Add a NIC to a desktop
-            - [set](docs/Commands.md#rpk-desktops-nic-set) - Update a desktop NIC
-            - [del](docs/Commands.md#rpk-desktops-nic-del) - Remove a NIC from a desktop
-    - [laptops](docs/Commands.md#rpk-laptops) - Manage Laptop computers and their components
-        - [add](docs/Commands.md#rpk-laptops-add) - Add a new Laptop
-        - [list](docs/Commands.md#rpk-laptops-list) - List all Laptops
-        - [get](docs/Commands.md#rpk-laptops-get) - Retrieve a Laptop by name
-        - [describe](docs/Commands.md#rpk-laptops-describe) - Show detailed information about a Laptop
-        - [del](docs/Commands.md#rpk-laptops-del) - Delete a Laptop from the inventory
-        - [summary](docs/Commands.md#rpk-laptops-summary) - Show a summarized hardware report for all Laptops
-        - [tree](docs/Commands.md#rpk-laptops-tree) - Display the dependency tree for a Laptop
-        - [cpu](docs/Commands.md#rpk-laptops-cpu) - Manage CPUs attached to Laptops
-            - [add](docs/Commands.md#rpk-laptops-cpu-add) - Add a CPU to a Laptop
-            - [set](docs/Commands.md#rpk-laptops-cpu-set) - Update a Laptop CPU
-            - [del](docs/Commands.md#rpk-laptops-cpu-del) - Remove a CPU from a Laptop
-        - [drives](docs/Commands.md#rpk-laptops-drives) - Manage storage drives attached to Laptops
-            - [add](docs/Commands.md#rpk-laptops-drives-add) - Add a drive to a Laptop
-            - [set](docs/Commands.md#rpk-laptops-drives-set) - Update a Laptop drive
-            - [del](docs/Commands.md#rpk-laptops-drives-del) - Remove a drive from a Laptop
-        - [gpu](docs/Commands.md#rpk-laptops-gpu) - Manage GPUs attached to Laptops
-            - [add](docs/Commands.md#rpk-laptops-gpu-add) - Add a GPU to a Laptop
-            - [set](docs/Commands.md#rpk-laptops-gpu-set) - Update a Laptop GPU
-            - [del](docs/Commands.md#rpk-laptops-gpu-del) - Remove a GPU from a Laptop
-    - [services](docs/Commands.md#rpk-services) - Manage services and their configurations
-        - [summary](docs/Commands.md#rpk-services-summary) - Show a summary report for all services
-        - [add](docs/Commands.md#rpk-services-add) - Add a new service
-        - [list](docs/Commands.md#rpk-services-list) - List all services
-        - [get](docs/Commands.md#rpk-services-get) - Retrieve a service by name
-        - [describe](docs/Commands.md#rpk-services-describe) - Show detailed information about a service
-        - [set](docs/Commands.md#rpk-services-set) - Update properties of a service
-        - [del](docs/Commands.md#rpk-services-del) - Delete a service
-        - [subnets](docs/Commands.md#rpk-services-subnets) - List subnets associated with a service, optionally filtered
-          by CIDR

+ 326 - 0
Shared.Rcl/wwwroot/raw_docs/ansible-generator-guide.md

@@ -0,0 +1,326 @@
+# Ansible Inventory Generator User Guide
+
+RackPeek can generate production-ready Ansible inventory directly from your modeled infrastructure.
+
+# 1. Making a Resource Ansible-Ready
+
+A resource becomes an Ansible host when it has an address label.
+
+## Required Label
+
+At minimum, set:
+
+```yaml
+labels:
+  ansible_host: 192.168.1.10
+```
+
+Without this, the resource will not appear in inventory.
+
+---
+
+## Recommended Labels
+
+```yaml
+labels:
+  ansible_host: 192.168.1.10
+  ansible_user: ubuntu
+  ansible_port: 22
+  ansible_ssh_private_key_file: ~/.ssh/id_rsa
+  env: prod
+  role: web
+```
+
+### What these do
+
+| Label                        | Purpose           |
+| ---------------------------- | ----------------- |
+| ansible_host                 | IP or DNS target  |
+| ansible_user                 | SSH user          |
+| ansible_port                 | SSH port          |
+| ansible_ssh_private_key_file | SSH key           |
+| env                          | Used for grouping |
+| role                         | Used for grouping |
+
+---
+
+# 2. Using Tags for Grouping
+
+Tags are simple grouping mechanisms.
+
+Example:
+
+```yaml
+tags:
+- prod
+- web
+- ansible
+```
+
+If you generate inventory with:
+
+```
+--group-tags prod,web
+```
+
+You will get:
+
+```ini
+[prod]
+vm-web01 ...
+
+[web]
+vm-web01 ...
+```
+
+---
+
+# 3. Using Labels for Structured Groups
+
+Labels allow structured grouping.
+
+Example:
+
+```yaml
+labels:
+  env: prod
+  role: database
+```
+
+Generating with:
+
+```
+--group-labels env,role
+```
+
+Produces:
+
+```ini
+[env_prod]
+vm-db01 ...
+
+[role_database]
+vm-db01 ...
+```
+
+This is cleaner and more scalable than raw tags.
+
+---
+
+# 4. Example Resource
+
+```yaml
+- kind: System
+  type: vm
+  os: ubuntu-22.04
+  cores: 4
+  ram: 8
+  name: vm-web01
+  tags:
+  - prod
+  - web
+  labels:
+    ansible_host: 192.168.1.10
+    ansible_user: ubuntu
+    env: prod
+    role: web
+```
+
+---
+
+# 5. Generating Inventory
+
+## CLI
+
+```
+rpk ansible inventory \
+  --group-tags prod,web \
+  --group-labels env,role \
+  --global-var ansible_user=ansible \
+  --global-var ansible_python_interpreter=/usr/bin/python3
+```
+
+## Web UI
+
+Navigate to:
+
+```
+/ansible/inventory
+```
+
+Set:
+
+* Group By Tags
+* Group By Labels
+* Global Variables
+
+Click **Generate**.
+
+---
+
+# 6. Example Generated Inventory
+
+```ini
+[all:vars]
+ansible_python_interpreter=/usr/bin/python3
+ansible_user=ansible
+
+[env_prod]
+vm-web01 ansible_host=192.168.1.10 ansible_user=ubuntu
+
+[role_web]
+vm-web01 ansible_host=192.168.1.10 ansible_user=ubuntu
+
+[prod]
+vm-web01 ansible_host=192.168.1.10 ansible_user=ubuntu
+```
+
+---
+
+# 7. Writing Playbooks Against RackPeek Inventory
+
+## Example 1 – Ping Production
+
+```yaml
+- name: Test production connectivity
+  hosts: env_prod
+  gather_facts: false
+
+  tasks:
+    - name: Ping hosts
+      ansible.builtin.ping:
+```
+
+Run:
+
+```
+ansible-playbook -i inventory.ini ping.yml
+```
+
+---
+
+## Example 2 – Deploy Web Servers
+
+```yaml
+- name: Configure web servers
+  hosts: role_web
+  become: true
+
+  tasks:
+    - name: Install nginx
+      ansible.builtin.apt:
+        name: nginx
+        state: present
+        update_cache: true
+
+    - name: Ensure nginx running
+      ansible.builtin.service:
+        name: nginx
+        state: started
+        enabled: true
+```
+
+---
+
+## Example 3 – Database Setup
+
+```yaml
+- name: Configure database servers
+  hosts: role_database
+  become: true
+
+  tasks:
+    - name: Install PostgreSQL
+      ansible.builtin.apt:
+        name: postgresql
+        state: present
+        update_cache: true
+```
+
+---
+
+# 8. Best Practices
+
+### 1. Use Labels for Structure
+
+Prefer:
+
+```
+env: prod
+role: web
+```
+
+Over raw tags when designing larger infrastructure.
+
+---
+
+### 2. Keep Global Vars Minimal
+
+Use:
+
+```ini
+[all:vars]
+ansible_user=ansible
+ansible_python_interpreter=/usr/bin/python3
+```
+
+Override per host only when needed.
+
+---
+
+### 3. Separate Infrastructure and Services
+
+Model:
+
+* Systems → Ansible hosts
+* Services → Applications running on systems
+
+Deploy against systems, not services.
+
+---
+
+### 4. Keep Inventory Deterministic
+
+Avoid:
+
+* Missing ansible_host
+* Mixed case group names
+* Unstructured tags
+
+---
+
+# 9. Advanced Pattern (Recommended)
+
+Use both:
+
+* `env`
+* `role`
+
+Then your structure becomes:
+
+```
+env_prod
+env_staging
+role_web
+role_database
+```
+
+This allows extremely flexible play targeting:
+
+```
+ansible-playbook site.yml -l env_prod
+ansible-playbook site.yml -l role_web
+ansible-playbook site.yml -l env_prod:&role_web
+```
+
+---
+
+# 10. Summary
+
+To use RackPeek effectively with Ansible:
+
+1. Add `ansible_host` label
+2. Add `env` and `role` labels
+3. Optionally add tags
+4. Generate inventory
+5. Write playbooks targeting groups

+ 142 - 0
Shared.Rcl/wwwroot/raw_docs/cli-commands-index.md

@@ -0,0 +1,142 @@
+- [rpk](docs/cli-commands.md#rpk)
+    - [summary](docs/cli-commands.md#rpk-summary) - Show a summarized report of all resources in the system
+    - [servers](docs/cli-commands.md#rpk-servers) - Manage servers and their components
+        - [summary](docs/cli-commands.md#rpk-servers-summary) - Show a summarized hardware report for all servers
+        - [add](docs/cli-commands.md#rpk-servers-add) - Add a new server to the inventory
+        - [get](docs/cli-commands.md#rpk-servers-get) - List all servers or retrieve a specific server by name
+        - [describe](docs/cli-commands.md#rpk-servers-describe) - Display detailed information about a specific server
+        - [set](docs/cli-commands.md#rpk-servers-set) - Update properties of an existing server
+        - [del](docs/cli-commands.md#rpk-servers-del) - Delete a server from the inventory
+        - [tree](docs/cli-commands.md#rpk-servers-tree) - Display the dependency tree of a server
+        - [cpu](docs/cli-commands.md#rpk-servers-cpu) - Manage CPUs attached to a server
+            - [add](docs/cli-commands.md#rpk-servers-cpu-add) - Add a CPU to a specific server
+            - [set](docs/cli-commands.md#rpk-servers-cpu-set) - Update configuration of a server CPU
+            - [del](docs/cli-commands.md#rpk-servers-cpu-del) - Remove a CPU from a server
+        - [drive](docs/cli-commands.md#rpk-servers-drive) - Manage drives attached to a server
+            - [add](docs/cli-commands.md#rpk-servers-drive-add) - Add a storage drive to a server
+            - [set](docs/cli-commands.md#rpk-servers-drive-set) - Update properties of a server drive
+            - [del](docs/cli-commands.md#rpk-servers-drive-del) - Remove a drive from a server
+        - [gpu](docs/cli-commands.md#rpk-servers-gpu) - Manage GPUs attached to a server
+            - [add](docs/cli-commands.md#rpk-servers-gpu-add) - Add a GPU to a server
+            - [set](docs/cli-commands.md#rpk-servers-gpu-set) - Update properties of a server GPU
+            - [del](docs/cli-commands.md#rpk-servers-gpu-del) - Remove a GPU from a server
+        - [nic](docs/cli-commands.md#rpk-servers-nic) - Manage network interface cards (NICs) for a server
+            - [add](docs/cli-commands.md#rpk-servers-nic-add) - Add a NIC to a server
+            - [set](docs/cli-commands.md#rpk-servers-nic-set) - Update properties of a server NIC
+            - [del](docs/cli-commands.md#rpk-servers-nic-del) - Remove a NIC from a server
+    - [switches](docs/cli-commands.md#rpk-switches) - Manage network switches
+        - [summary](docs/cli-commands.md#rpk-switches-summary) - Show a hardware report for all switches
+        - [add](docs/cli-commands.md#rpk-switches-add) - Add a new network switch to the inventory
+        - [list](docs/cli-commands.md#rpk-switches-list) - List all switches in the system
+        - [get](docs/cli-commands.md#rpk-switches-get) - Retrieve details of a specific switch by name
+        - [describe](docs/cli-commands.md#rpk-switches-describe) - Show detailed information about a switch
+        - [set](docs/cli-commands.md#rpk-switches-set) - Update properties of a switch
+        - [del](docs/cli-commands.md#rpk-switches-del) - Delete a switch from the inventory
+        - [port](docs/cli-commands.md#rpk-switches-port) - Manage ports on a network switch
+            - [add](docs/cli-commands.md#rpk-switches-port-add) - Add a port to a switch
+            - [set](docs/cli-commands.md#rpk-switches-port-set) - Update a switch port
+            - [del](docs/cli-commands.md#rpk-switches-port-del) - Remove a port from a switch
+    - [routers](docs/cli-commands.md#rpk-routers) - Manage network routers
+        - [summary](docs/cli-commands.md#rpk-routers-summary) - Show a hardware report for all routers
+        - [add](docs/cli-commands.md#rpk-routers-add) - Add a new network router to the inventory
+        - [list](docs/cli-commands.md#rpk-routers-list) - List all routers in the system
+        - [get](docs/cli-commands.md#rpk-routers-get) - Retrieve details of a specific router by name
+        - [describe](docs/cli-commands.md#rpk-routers-describe) - Show detailed information about a router
+        - [set](docs/cli-commands.md#rpk-routers-set) - Update properties of a router
+        - [del](docs/cli-commands.md#rpk-routers-del) - Delete a router from the inventory
+        - [port](docs/cli-commands.md#rpk-routers-port) - Manage ports on a router
+            - [add](docs/cli-commands.md#rpk-routers-port-add) - Add a port to a router
+            - [set](docs/cli-commands.md#rpk-routers-port-set) - Update a router port
+            - [del](docs/cli-commands.md#rpk-routers-port-del) - Remove a port from a router
+    - [firewalls](docs/cli-commands.md#rpk-firewalls) - Manage firewalls
+        - [summary](docs/cli-commands.md#rpk-firewalls-summary) - Show a hardware report for all firewalls
+        - [add](docs/cli-commands.md#rpk-firewalls-add) - Add a new firewall to the inventory
+        - [list](docs/cli-commands.md#rpk-firewalls-list) - List all firewalls in the system
+        - [get](docs/cli-commands.md#rpk-firewalls-get) - Retrieve details of a specific firewall by name
+        - [describe](docs/cli-commands.md#rpk-firewalls-describe) - Show detailed information about a firewall
+        - [set](docs/cli-commands.md#rpk-firewalls-set) - Update properties of a firewall
+        - [del](docs/cli-commands.md#rpk-firewalls-del) - Delete a firewall from the inventory
+        - [port](docs/cli-commands.md#rpk-firewalls-port) - Manage ports on a firewall
+            - [add](docs/cli-commands.md#rpk-firewalls-port-add) - Add a port to a firewall
+            - [set](docs/cli-commands.md#rpk-firewalls-port-set) - Update a firewall port
+            - [del](docs/cli-commands.md#rpk-firewalls-port-del) - Remove a port from a firewall
+    - [systems](docs/cli-commands.md#rpk-systems) - Manage systems and their dependencies
+        - [summary](docs/cli-commands.md#rpk-systems-summary) - Show a summary report for all systems
+        - [add](docs/cli-commands.md#rpk-systems-add) - Add a new system to the inventory
+        - [list](docs/cli-commands.md#rpk-systems-list) - List all systems
+        - [get](docs/cli-commands.md#rpk-systems-get) - Retrieve a system by name
+        - [describe](docs/cli-commands.md#rpk-systems-describe) - Display detailed information about a system
+        - [set](docs/cli-commands.md#rpk-systems-set) - Update properties of a system
+        - [del](docs/cli-commands.md#rpk-systems-del) - Delete a system from the inventory
+        - [tree](docs/cli-commands.md#rpk-systems-tree) - Display the dependency tree for a system
+    - [accesspoints](docs/cli-commands.md#rpk-accesspoints) - Manage access points
+        - [summary](docs/cli-commands.md#rpk-accesspoints-summary) - Show a hardware report for all access points
+        - [add](docs/cli-commands.md#rpk-accesspoints-add) - Add a new access point
+        - [list](docs/cli-commands.md#rpk-accesspoints-list) - List all access points
+        - [get](docs/cli-commands.md#rpk-accesspoints-get) - Retrieve an access point by name
+        - [describe](docs/cli-commands.md#rpk-accesspoints-describe) - Show detailed information about an access point
+        - [set](docs/cli-commands.md#rpk-accesspoints-set) - Update properties of an access point
+        - [del](docs/cli-commands.md#rpk-accesspoints-del) - Delete an access point
+    - [ups](docs/cli-commands.md#rpk-ups) - Manage UPS units
+        - [summary](docs/cli-commands.md#rpk-ups-summary) - Show a hardware report for all UPS units
+        - [add](docs/cli-commands.md#rpk-ups-add) - Add a new UPS unit
+        - [list](docs/cli-commands.md#rpk-ups-list) - List all UPS units
+        - [get](docs/cli-commands.md#rpk-ups-get) - Retrieve a UPS unit by name
+        - [describe](docs/cli-commands.md#rpk-ups-describe) - Show detailed information about a UPS unit
+        - [set](docs/cli-commands.md#rpk-ups-set) - Update properties of a UPS unit
+        - [del](docs/cli-commands.md#rpk-ups-del) - Delete a UPS unit
+    - [desktops](docs/cli-commands.md#rpk-desktops) - Manage desktop computers and their components
+        - [add](docs/cli-commands.md#rpk-desktops-add) - Add a new desktop
+        - [list](docs/cli-commands.md#rpk-desktops-list) - List all desktops
+        - [get](docs/cli-commands.md#rpk-desktops-get) - Retrieve a desktop by name
+        - [describe](docs/cli-commands.md#rpk-desktops-describe) - Show detailed information about a desktop
+        - [set](docs/cli-commands.md#rpk-desktops-set) - Update properties of a desktop
+        - [del](docs/cli-commands.md#rpk-desktops-del) - Delete a desktop from the inventory
+        - [summary](docs/cli-commands.md#rpk-desktops-summary) - Show a summarized hardware report for all desktops
+        - [tree](docs/cli-commands.md#rpk-desktops-tree) - Display the dependency tree for a desktop
+        - [cpu](docs/cli-commands.md#rpk-desktops-cpu) - Manage CPUs attached to desktops
+            - [add](docs/cli-commands.md#rpk-desktops-cpu-add) - Add a CPU to a desktop
+            - [set](docs/cli-commands.md#rpk-desktops-cpu-set) - Update a desktop CPU
+            - [del](docs/cli-commands.md#rpk-desktops-cpu-del) - Remove a CPU from a desktop
+        - [drive](docs/cli-commands.md#rpk-desktops-drive) - Manage storage drives attached to desktops
+            - [add](docs/cli-commands.md#rpk-desktops-drive-add) - Add a drive to a desktop
+            - [set](docs/cli-commands.md#rpk-desktops-drive-set) - Update a desktop drive
+            - [del](docs/cli-commands.md#rpk-desktops-drive-del) - Remove a drive from a desktop
+        - [gpu](docs/cli-commands.md#rpk-desktops-gpu) - Manage GPUs attached to desktops
+            - [add](docs/cli-commands.md#rpk-desktops-gpu-add) - Add a GPU to a desktop
+            - [set](docs/cli-commands.md#rpk-desktops-gpu-set) - Update a desktop GPU
+            - [del](docs/cli-commands.md#rpk-desktops-gpu-del) - Remove a GPU from a desktop
+        - [nic](docs/cli-commands.md#rpk-desktops-nic) - Manage network interface cards (NICs) for desktops
+            - [add](docs/cli-commands.md#rpk-desktops-nic-add) - Add a NIC to a desktop
+            - [set](docs/cli-commands.md#rpk-desktops-nic-set) - Update a desktop NIC
+            - [del](docs/cli-commands.md#rpk-desktops-nic-del) - Remove a NIC from a desktop
+    - [laptops](docs/cli-commands.md#rpk-laptops) - Manage Laptop computers and their components
+        - [add](docs/cli-commands.md#rpk-laptops-add) - Add a new Laptop
+        - [list](docs/cli-commands.md#rpk-laptops-list) - List all Laptops
+        - [get](docs/cli-commands.md#rpk-laptops-get) - Retrieve a Laptop by name
+        - [describe](docs/cli-commands.md#rpk-laptops-describe) - Show detailed information about a Laptop
+        - [del](docs/cli-commands.md#rpk-laptops-del) - Delete a Laptop from the inventory
+        - [summary](docs/cli-commands.md#rpk-laptops-summary) - Show a summarized hardware report for all Laptops
+        - [tree](docs/cli-commands.md#rpk-laptops-tree) - Display the dependency tree for a Laptop
+        - [cpu](docs/cli-commands.md#rpk-laptops-cpu) - Manage CPUs attached to Laptops
+            - [add](docs/cli-commands.md#rpk-laptops-cpu-add) - Add a CPU to a Laptop
+            - [set](docs/cli-commands.md#rpk-laptops-cpu-set) - Update a Laptop CPU
+            - [del](docs/cli-commands.md#rpk-laptops-cpu-del) - Remove a CPU from a Laptop
+        - [drives](docs/cli-commands.md#rpk-laptops-drives) - Manage storage drives attached to Laptops
+            - [add](docs/cli-commands.md#rpk-laptops-drives-add) - Add a drive to a Laptop
+            - [set](docs/cli-commands.md#rpk-laptops-drives-set) - Update a Laptop drive
+            - [del](docs/cli-commands.md#rpk-laptops-drives-del) - Remove a drive from a Laptop
+        - [gpu](docs/cli-commands.md#rpk-laptops-gpu) - Manage GPUs attached to Laptops
+            - [add](docs/cli-commands.md#rpk-laptops-gpu-add) - Add a GPU to a Laptop
+            - [set](docs/cli-commands.md#rpk-laptops-gpu-set) - Update a Laptop GPU
+            - [del](docs/cli-commands.md#rpk-laptops-gpu-del) - Remove a GPU from a Laptop
+    - [services](docs/cli-commands.md#rpk-services) - Manage services and their configurations
+        - [summary](docs/cli-commands.md#rpk-services-summary) - Show a summary report for all services
+        - [add](docs/cli-commands.md#rpk-services-add) - Add a new service
+        - [list](docs/cli-commands.md#rpk-services-list) - List all services
+        - [get](docs/cli-commands.md#rpk-services-get) - Retrieve a service by name
+        - [describe](docs/cli-commands.md#rpk-services-describe) - Show detailed information about a service
+        - [set](docs/cli-commands.md#rpk-services-set) - Update properties of a service
+        - [del](docs/cli-commands.md#rpk-services-del) - Delete a service
+        - [subnets](docs/cli-commands.md#rpk-services-subnets) - List subnets associated with a service, optionally filtered
+          by CIDR

+ 0 - 0
Shared.Rcl/wwwroot/raw_docs/Commands.md → Shared.Rcl/wwwroot/raw_docs/cli-commands.md


+ 7 - 0
Shared.Rcl/wwwroot/raw_docs/docs-index.json

@@ -0,0 +1,7 @@
+[
+  "overview.md",
+  "install-guide.md",
+  "ansible-generator-guide.md",
+  "cli-commands.md",
+  "cli-commands-index.md"
+]

+ 181 - 0
Shared.Rcl/wwwroot/raw_docs/install-guide.md

@@ -0,0 +1,181 @@
+# Installation Guide
+
+RackPeek can run in two ways:
+
+* **Docker (includes Web UI + CLI)**
+* **Native CLI binary**
+
+RackPeek stores everything in a writable `config/` directory as YAML (including automatic backups).
+Wherever you run it, that directory must be writable.
+
+---
+
+# Docker (Recommended)
+
+This gives you:
+
+* Web UI on port `8080`
+* CLI available inside the container
+
+---
+
+## Docker Compose
+
+```yaml
+version: "3.9"
+
+services:
+  rackpeek:
+    image: aptacode/rackpeek:latest
+    container_name: rackpeek
+    ports:
+      - "8080:8080"
+    volumes:
+      - rackpeek-config:/app/config
+    restart: unless-stopped
+
+volumes:
+  rackpeek-config:
+```
+
+Start it:
+
+```bash
+docker compose up -d
+```
+
+Open:
+
+```
+http://localhost:8080
+```
+
+This uses a **named volume**, which avoids permission issues and is recommended for most users.
+
+---
+
+## Portainer
+
+Use the same Compose file above in a stack.
+Portainer typically handles user permissions automatically.
+
+---
+
+## Bind Mount (Advanced)
+
+If you want the YAML stored directly on your host:
+
+```yaml
+volumes:
+  - /path/on/host/rackpeek:/app/config
+```
+
+⚠️ The directory must be writable.
+
+If you see:
+
+```
+Access to the path '/app/config/config.yaml' is denied.
+```
+
+Fix ownership:
+
+```bash
+sudo chown -R 1000:1000 /path/on/host/rackpeek
+```
+
+Or explicitly set the container user:
+
+```yaml
+user: "1000:1000"
+```
+
+RackPeek must be able to:
+
+* Create `config.yaml`
+* Update it
+* Write backup files
+
+Permission issues are almost always the cause of startup failures.
+
+---
+
+## Using the CLI (Without Installing It)
+
+If running Docker, you already have the CLI.
+
+Run commands directly inside the container:
+
+```bash
+docker exec -it rackpeek rpk --help
+docker exec -it rackpeek rpk systems list
+```
+
+# Native CLI (Linux Only)
+
+If you prefer running RackPeek directly on Linux:
+
+## Download
+
+```bash
+wget https://github.com/Timmoth/RackPeek/releases/download/RackPeek-0.0.3/rackpeek_0_0_3_linux-x64 -O rackpeek
+```
+
+Or:
+
+```bash
+curl -L https://github.com/Timmoth/RackPeek/releases/download/RackPeek-0.0.3/rackpeek_0_0_3_linux-x64 -o rackpeek
+```
+
+---
+
+## Install
+
+```bash
+chmod +x rackpeek
+sudo mv rackpeek /usr/local/bin/rpk
+```
+
+---
+
+## Create Config Directory
+
+RackPeek expects a `config` folder next to the binary:
+
+```bash
+sudo mkdir -p /usr/local/bin/config
+sudo touch /usr/local/bin/config/config.yaml
+```
+
+⚠️ It must be writable, since RackPeek writes backups there as well.
+
+If needed:
+
+```bash
+sudo chown -R $USER:$USER /usr/local/bin/config
+```
+
+---
+
+## Test
+
+```bash
+rpk --help
+```
+
+---
+
+# Where Your Data Lives
+
+RackPeek stores everything in plain YAML:
+
+```
+config/
+└── config.yaml
+```
+
+No database.
+No telemetry.
+No lock-in.
+
+You own your data.

+ 87 - 0
Shared.Rcl/wwwroot/raw_docs/overview.md

@@ -0,0 +1,87 @@
+# RackPeek Documentation
+
+Welcome to the RackPeek knowledge base.
+
+RackPeek is a lightweight, opinionated CLI tool and Web UI for documenting and managing home lab and small-scale IT infrastructure.
+
+It exists to help you understand your infrastructure clearly — without enterprise bloat, proprietary lock-in, or unnecessary process.
+
+---
+
+## What RackPeek Is
+
+RackPeek helps you:
+
+- Document hardware, systems, and services
+- Model relationships between components
+- Keep infrastructure knowledge versionable and portable
+- Treat your lab “as code” using simple YAML
+- Turn documentation into automation (e.g. Ansible inventory)
+
+It is intentionally focused on homelabs and self-hosted environments, not enterprise CMDBs.
+
+---
+
+## Design Philosophy
+
+RackPeek is built around a few core principles:
+
+### Simplicity
+Clear models. Minimal abstraction. No unnecessary metadata.
+
+### Openness
+Your data is stored in plain YAML. No hidden databases. No lock-in.
+
+### Ease of Deployment
+Runs in Docker or as a single binary. Fast to install. Easy to maintain.
+
+### Privacy
+No telemetry. No tracking. No ads. What runs on your infrastructure stays on your infrastructure.
+
+### Opinionated
+Optimized for real-world home lab use — not corporate documentation workflows.
+
+---
+
+## Project Status
+
+RackPeek is actively developed and currently in beta.
+
+The focus is on:
+
+- Stability
+- Core feature completeness
+- Clean UX
+- Strong automation integrations
+- Community feedback before v1.0.0
+
+Post-1.0, expansion areas include deeper automation support, diagramming, and infrastructure integrations.
+
+---
+
+## Community
+
+RackPeek is open source and community-driven.
+
+If you:
+- Run a homelab
+- Self-host services
+- Experiment with infrastructure
+- Care about documenting what you build
+
+You belong here.
+
+Feedback, bug reports, ideas, and contributions are always welcome.
+
+---
+
+## Where to Go Next
+
+Use this documentation section to explore:
+
+- Concepts and modeling guidance
+- CLI reference
+- Automation and Ansible integration
+- Development and contribution guides
+
+RackPeek is designed to grow with your lab — and stay out of your way.

+ 72 - 0
Tests.E2e/AnsibleInventoryTests.cs

@@ -0,0 +1,72 @@
+using Tests.E2e.Infra;
+using Tests.E2e.PageObjectModels;
+using Xunit.Abstractions;
+
+namespace Tests.E2e;
+
+public class AnsibleInventoryTests(
+    PlaywrightFixture fixture,
+    ITestOutputHelper output) : E2ETestBase(fixture, output)
+{
+    private readonly ITestOutputHelper _output = output;
+
+    [Fact]
+    public async Task User_Can_Generate_Ansible_Inventory()
+    {
+        var (context, page) = await CreatePageAsync();
+
+        try
+        {
+            // Go home
+            await page.GotoAsync(fixture.BaseUrl);
+
+            _output.WriteLine($"URL after Goto: {page.Url}");
+
+            var layout = new MainLayoutPom(page);
+            await layout.AssertLoadedAsync();
+
+            // Navigate directly to inventory page
+            await page.GotoAsync($"{fixture.BaseUrl}/ansible/inventory");
+
+            var inventoryPage = new AnsibleInventoryPagePom(page);
+            await inventoryPage.AssertVisibleAsync();
+
+            // Configure options
+            await inventoryPage.SetGroupByTagsAsync("prod,staging");
+            await inventoryPage.SetGroupByLabelsAsync("env");
+            await inventoryPage.SetGlobalVarsAsync("""
+ansible_user=ansible
+ansible_python_interpreter=/usr/bin/python3
+""");
+
+            // Generate inventory
+            await inventoryPage.GenerateAsync();
+
+            // Assert output contains expected sections
+            await inventoryPage.AssertInventoryContainsAsync("[all:vars]");
+            await inventoryPage.AssertInventoryContainsAsync("ansible_user=ansible");
+
+            // Ensure no warnings shown
+            await inventoryPage.AssertNoWarningsAsync();
+
+            await context.CloseAsync();
+        }
+        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();
+        }
+    }
+}

+ 74 - 0
Tests.E2e/PageObjectModels/AnsibleInventoryPagePom.cs

@@ -0,0 +1,74 @@
+using Microsoft.Playwright;
+
+namespace Tests.E2e.PageObjectModels;
+
+public class AnsibleInventoryPagePom(IPage page)
+{
+    // -------------------------------------------------
+    // Root
+    // -------------------------------------------------
+
+    public ILocator Page
+        => page.GetByTestId("ansible-inventory-page");
+
+    // -------------------------------------------------
+    // Actions
+    // -------------------------------------------------
+
+    public ILocator GenerateButton
+        => page.GetByTestId("generate-inventory-button");
+
+    // -------------------------------------------------
+    // Inputs
+    // -------------------------------------------------
+
+    public ILocator GroupByTagsInput
+        => page.GetByTestId("group-by-tags-input");
+
+    public ILocator GroupByLabelsInput
+        => page.GetByTestId("group-by-labels-input");
+
+    public ILocator GlobalVarsInput
+        => page.GetByTestId("global-vars-input");
+
+    // -------------------------------------------------
+    // Output
+    // -------------------------------------------------
+
+    public ILocator InventoryOutput
+        => page.GetByTestId("inventory-output");
+
+    public ILocator WarningsContainer
+        => page.GetByTestId("inventory-warnings");
+
+    // -------------------------------------------------
+    // High-Level Actions
+    // -------------------------------------------------
+
+    public async Task AssertVisibleAsync()
+        => await Assertions.Expect(Page).ToBeVisibleAsync();
+
+    public async Task SetGroupByTagsAsync(string value)
+        => await GroupByTagsInput.FillAsync(value);
+
+    public async Task SetGroupByLabelsAsync(string value)
+        => await GroupByLabelsInput.FillAsync(value);
+
+    public async Task SetGlobalVarsAsync(string value)
+        => await GlobalVarsInput.FillAsync(value);
+
+    public async Task GenerateAsync()
+        => await GenerateButton.ClickAsync();
+
+    public async Task<string> GetInventoryTextAsync()
+        => await InventoryOutput.InputValueAsync();
+
+    public async Task AssertInventoryContainsAsync(string text)
+        => await Assertions.Expect(InventoryOutput).ToContainTextAsync(text);
+
+    public async Task AssertNoWarningsAsync()
+        => await Assertions.Expect(WarningsContainer).ToHaveCountAsync(0);
+
+    public async Task AssertWarningContainsAsync(string text)
+        => await Assertions.Expect(WarningsContainer).ToContainTextAsync(text);
+}

+ 90 - 0
Tests/EndToEnd/AnsibleTests/AnsibleInventoryWorkflowTests.cs

@@ -0,0 +1,90 @@
+using Tests.EndToEnd.Infra;
+using Xunit.Abstractions;
+
+namespace Tests.EndToEnd.AnsibleTests;
+
+[Collection("Yaml CLI tests")]
+public class AnsibleInventoryWorkflowTests(
+    TempYamlCliFixture fs,
+    ITestOutputHelper outputHelper)
+    : IClassFixture<TempYamlCliFixture>
+{
+    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 ansible_inventory_cli_workflow_test()
+    {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), """
+                                                                           version: 1
+                                                                           resources:
+                                                                           - kind: System
+                                                                             type: vm
+                                                                             os: ubuntu-22.04
+                                                                             cores: 2
+                                                                             ram: 4
+                                                                             name: vm-web01
+                                                                             tags:
+                                                                             - prod
+                                                                             labels:
+                                                                               ansible_host: 192.168.1.10
+                                                                               ansible_user: ubuntu
+                                                                               env: prod
+
+                                                                           - kind: System
+                                                                             type: vm
+                                                                             os: debian-12
+                                                                             cores: 2
+                                                                             ram: 2
+                                                                             name: vm-staging01
+                                                                             tags:
+                                                                             - staging
+                                                                             labels:
+                                                                               ansible_host: 192.168.1.20
+                                                                               ansible_user: debian
+                                                                               env: staging
+
+                                                                           """);
+
+        var (output, yaml) = await ExecuteAsync(
+            "ansible", "inventory",
+            "--group-tags", "prod,staging",
+            "--group-labels", "env",
+            "--global-var", "ansible_user=ansible",
+            "--global-var", "ansible_python_interpreter=/usr/bin/python3"
+        );
+
+        Assert.Equal("""
+                     Generated Inventory:
+
+                     [all:vars]
+                     ansible_python_interpreter=/usr/bin/python3
+                     ansible_user=ansible
+
+                     [env_prod]
+                     vm-web01 ansible_host=192.168.1.10 ansible_user=ubuntu
+
+                     [env_staging]
+                     vm-staging01 ansible_host=192.168.1.20 ansible_user=debian
+
+                     [prod]
+                     vm-web01 ansible_host=192.168.1.10 ansible_user=ubuntu
+
+                     [staging]
+                     vm-staging01 ansible_host=192.168.1.20 ansible_user=debian
+                     """, output);
+    }
+}

+ 0 - 0
Tests/EndToEnd/Labels/ServerWorkflowTests.cs → Tests/EndToEnd/Labels/LabelsWorkflowTests.cs


+ 1 - 1
Tests/EndToEnd/LaptopTests/LaptopCommandTests.cs

@@ -1,7 +1,7 @@
 using Tests.EndToEnd.Infra;
 using Xunit.Abstractions;
 
-namespace Tests.EndToEnd;
+namespace Tests.EndToEnd.LaptopTests;
 
 [Collection("Yaml CLI tests")]
 public class LaptopCommandTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper)

+ 1 - 1
Tests/EndToEnd/LaptopTests/LaptopErrorTests.cs

@@ -1,7 +1,7 @@
 using Tests.EndToEnd.Infra;
 using Xunit.Abstractions;
 
-namespace Tests.EndToEnd;
+namespace Tests.EndToEnd.LaptopTests;
 
 [Collection("Yaml CLI tests")]
 public class LaptopErrorTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper)

+ 1 - 1
Tests/EndToEnd/LaptopTests/LaptopWorkflowTests.cs

@@ -1,7 +1,7 @@
 using Tests.EndToEnd.Infra;
 using Xunit.Abstractions;
 
-namespace Tests.EndToEnd;
+namespace Tests.EndToEnd.LaptopTests;
 
 [Collection("Yaml CLI tests")]
 public class LaptopWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper)

+ 1 - 1
Tests/EndToEnd/RouterTests/RouterCommandTests.cs

@@ -1,7 +1,7 @@
 using Tests.EndToEnd.Infra;
 using Xunit.Abstractions;
 
-namespace Tests.EndToEnd;
+namespace Tests.EndToEnd.RouterTests;
 
 [Collection("Yaml CLI tests")]
 public class RouterCommandTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper)

+ 1 - 1
Tests/EndToEnd/RouterTests/RouterErrorTests.cs

@@ -1,7 +1,7 @@
 using Tests.EndToEnd.Infra;
 using Xunit.Abstractions;
 
-namespace Tests.EndToEnd;
+namespace Tests.EndToEnd.RouterTests;
 
 [Collection("Yaml CLI tests")]
 public class RouterErrorTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper)

+ 1 - 1
Tests/EndToEnd/RouterTests/RouterWorkflowTests.cs

@@ -1,7 +1,7 @@
 using Tests.EndToEnd.Infra;
 using Xunit.Abstractions;
 
-namespace Tests.EndToEnd;
+namespace Tests.EndToEnd.RouterTests;
 
 [Collection("Yaml CLI tests")]
 public class RouterWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper)

+ 1 - 1
Tests/EndToEnd/ServerTests/ServerCommandTests.cs

@@ -1,7 +1,7 @@
 using Tests.EndToEnd.Infra;
 using Xunit.Abstractions;
 
-namespace Tests.EndToEnd;
+namespace Tests.EndToEnd.ServerTests;
 
 [Collection("Yaml CLI tests")]
 public class ServerCommandTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper)

+ 1 - 1
Tests/EndToEnd/ServerTests/ServerErrorTests.cs

@@ -1,7 +1,7 @@
 using Tests.EndToEnd.Infra;
 using Xunit.Abstractions;
 
-namespace Tests.EndToEnd;
+namespace Tests.EndToEnd.ServerTests;
 
 [Collection("Yaml CLI tests")]
 public class ServerErrorTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper)

+ 1 - 1
Tests/EndToEnd/ServerTests/ServerWorkflowTests.cs

@@ -1,7 +1,7 @@
 using Tests.EndToEnd.Infra;
 using Xunit.Abstractions;
 
-namespace Tests.EndToEnd;
+namespace Tests.EndToEnd.ServerTests;
 
 [Collection("Yaml CLI tests")]
 public class ServerWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper)

+ 1 - 1
Tests/EndToEnd/ServiceTests/ServiceCommandTests.cs

@@ -1,7 +1,7 @@
 using Tests.EndToEnd.Infra;
 using Xunit.Abstractions;
 
-namespace Tests.EndToEnd;
+namespace Tests.EndToEnd.ServiceTests;
 
 [Collection("Yaml CLI tests")]
 public class ServiceCommandTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper)

+ 1 - 1
Tests/EndToEnd/ServiceTests/ServiceErrorTests.cs

@@ -1,7 +1,7 @@
 using Tests.EndToEnd.Infra;
 using Xunit.Abstractions;
 
-namespace Tests.EndToEnd;
+namespace Tests.EndToEnd.ServiceTests;
 
 [Collection("Yaml CLI tests")]
 public class ServiceErrorTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper)

+ 1 - 1
Tests/EndToEnd/ServiceTests/ServiceWorkflowTests.cs

@@ -1,7 +1,7 @@
 using Tests.EndToEnd.Infra;
 using Xunit.Abstractions;
 
-namespace Tests.EndToEnd;
+namespace Tests.EndToEnd.ServiceTests;
 
 [Collection("Yaml CLI tests")]
 public class ServiceWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper)