Prechádzať zdrojové kódy

Merge origin/main into feature/other-hardware-type, resolving conflicts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tim Jones 21 hodín pred
rodič
commit
1f52ba405b
100 zmenil súbory, kde vykonal 2970 pridanie a 1156 odobranie
  1. 142 0
      .devcontainer/brew.sh
  2. 28 0
      .devcontainer/devcontainer.json
  3. 7 0
      .devcontainer/postInstall.sh
  4. 52 0
      .editorconfig
  5. 1 1
      .github/workflows/generate-docs.yaml
  6. 1 1
      .github/workflows/publish-docker-nightly.yaml
  7. 0 20
      .github/workflows/test-cli.yml
  8. 54 7
      .github/workflows/test.yml
  9. 318 0
      AGENTS.md
  10. 8 0
      Directory.Build.props
  11. 8 22
      README.md
  12. 3 5
      RackPeek.Domain/Api/InventoryRequest.cs
  13. 2 3
      RackPeek.Domain/Api/InventoryResponse.cs
  14. 28 51
      RackPeek.Domain/Api/UpsertInventoryUseCase.cs
  15. 10 0
      RackPeek.Domain/Git/GitStatus.cs
  16. 19 0
      RackPeek.Domain/Git/IGitRepository.cs
  17. 384 0
      RackPeek.Domain/Git/LibGit2GitRepository.cs
  18. 19 0
      RackPeek.Domain/Git/NullGitRepository.cs
  19. 36 0
      RackPeek.Domain/Git/UseCases/AddRemoteUseCase.cs
  20. 21 0
      RackPeek.Domain/Git/UseCases/CommitAllUseCase.cs
  21. 16 0
      RackPeek.Domain/Git/UseCases/InitRepoUseCase.cs
  22. 20 0
      RackPeek.Domain/Git/UseCases/PullUseCase.cs
  23. 27 0
      RackPeek.Domain/Git/UseCases/PushUseCase.cs
  24. 16 0
      RackPeek.Domain/Git/UseCases/RestoreAllUseCase.cs
  25. 52 0
      RackPeek.Domain/Graph/Graph.cs
  26. 465 0
      RackPeek.Domain/Graph/Serialisers/MermaidSerialiser.cs
  27. 188 0
      RackPeek.Domain/Graph/UseCases/BuildLogicalGraphUseCase.cs
  28. 79 0
      RackPeek.Domain/Graph/UseCases/BuildPhysicalTopologyUseCase.cs
  29. 4 7
      RackPeek.Domain/Helpers/ConflictException.cs
  30. 3 5
      RackPeek.Domain/Helpers/DeepClone.cs
  31. 21 52
      RackPeek.Domain/Helpers/Normalize.cs
  32. 4 7
      RackPeek.Domain/Helpers/NotFoundException.cs
  33. 18 35
      RackPeek.Domain/Helpers/ThrowIfInvalid.cs
  34. 2 3
      RackPeek.Domain/IConsoleEmulator.cs
  35. 2 3
      RackPeek.Domain/IUseCase.cs
  36. 39 49
      RackPeek.Domain/Persistence/HardwareRepository.cs
  37. 12 6
      RackPeek.Domain/Persistence/IResourceCollection.cs
  38. 41 70
      RackPeek.Domain/Persistence/ResourceCollectionMerger.cs
  39. 11 26
      RackPeek.Domain/Persistence/ServiceRepository.cs
  40. 18 36
      RackPeek.Domain/Persistence/SystemRepository.cs
  41. 15 22
      RackPeek.Domain/Persistence/Yaml/Converters.cs
  42. 6 17
      RackPeek.Domain/Persistence/Yaml/ITextFileStore.cs
  43. 7 14
      RackPeek.Domain/Persistence/Yaml/NotesStringYamlConverter.cs
  44. 75 24
      RackPeek.Domain/Persistence/Yaml/RackPeekConfigMigrationDeserializer.cs
  45. 10 20
      RackPeek.Domain/Persistence/Yaml/ResourceYamlMigrationService.cs
  46. 242 181
      RackPeek.Domain/Persistence/Yaml/YamlResourceCollection.cs
  47. 5 3
      RackPeek.Domain/RackPeek.Domain.csproj
  48. 6 3
      RackPeek.Domain/Resources/AccessPoints/AccessPoint.cs
  49. 4 6
      RackPeek.Domain/Resources/AccessPoints/AccessPointHardwareReport.cs
  50. 5 9
      RackPeek.Domain/Resources/AccessPoints/UpdateAccessPointUseCase.cs
  51. 74 0
      RackPeek.Domain/Resources/Connections/AddConnectionUseCase.cs
  52. 17 0
      RackPeek.Domain/Resources/Connections/Connection.cs
  53. 11 0
      RackPeek.Domain/Resources/Connections/ConnectionHelpers.cs
  54. 19 0
      RackPeek.Domain/Resources/Connections/GetConnectionForPortUseCase.cs
  55. 19 0
      RackPeek.Domain/Resources/Connections/GetConnectionsForResourceUseCase.cs
  56. 19 0
      RackPeek.Domain/Resources/Connections/RemoveConnectionUseCase.cs
  57. 4 6
      RackPeek.Domain/Resources/Desktops/DescribeDesktopUseCase.cs
  58. 4 5
      RackPeek.Domain/Resources/Desktops/Desktop.cs
  59. 9 13
      RackPeek.Domain/Resources/Desktops/DesktopHardwareReport.cs
  60. 11 25
      RackPeek.Domain/Resources/Desktops/UpdateDesktopUseCase.cs
  61. 6 9
      RackPeek.Domain/Resources/Firewalls/DescribeFirewallUseCase.cs
  62. 2 3
      RackPeek.Domain/Resources/Firewalls/Firewall.cs
  63. 7 11
      RackPeek.Domain/Resources/Firewalls/FirewallHardwareReport.cs
  64. 3 5
      RackPeek.Domain/Resources/Firewalls/UpdateFirewallUseCase.cs
  65. 10 17
      RackPeek.Domain/Resources/Hardware/GetHardwareSystemTreeUseCase.cs
  66. 7 11
      RackPeek.Domain/Resources/Hardware/GetHardwareUseCaseSummary.cs
  67. 2 3
      RackPeek.Domain/Resources/Hardware/Hardware.cs
  68. 4 7
      RackPeek.Domain/Resources/Hardware/IHardwareRepository.cs
  69. 2 3
      RackPeek.Domain/Resources/IResourceRepository.cs
  70. 3 5
      RackPeek.Domain/Resources/Laptops/DescribeLaptopUseCase.cs
  71. 2 3
      RackPeek.Domain/Resources/Laptops/Laptop.cs
  72. 5 8
      RackPeek.Domain/Resources/Laptops/LaptopHardwareReportUseCase.cs
  73. 11 25
      RackPeek.Domain/Resources/Laptops/UpdateLaptopUseCase.cs
  74. 28 40
      RackPeek.Domain/Resources/Resource.cs
  75. 6 9
      RackPeek.Domain/Resources/Routers/DescribeRouterUseCase.cs
  76. 2 3
      RackPeek.Domain/Resources/Routers/Router.cs
  77. 7 11
      RackPeek.Domain/Resources/Routers/RouterHardwareReport.cs
  78. 3 5
      RackPeek.Domain/Resources/Routers/UpdateRouterUseCase.cs
  79. 4 6
      RackPeek.Domain/Resources/Servers/DescribeServerUseCase.cs
  80. 2 3
      RackPeek.Domain/Resources/Servers/ICpuResource.cs
  81. 2 3
      RackPeek.Domain/Resources/Servers/IDriveResource.cs
  82. 2 3
      RackPeek.Domain/Resources/Servers/IGpuResource.cs
  83. 0 8
      RackPeek.Domain/Resources/Servers/INicResource.cs
  84. 2 3
      RackPeek.Domain/Resources/Servers/IPortResource.cs
  85. 3 4
      RackPeek.Domain/Resources/Servers/Server.cs
  86. 24 29
      RackPeek.Domain/Resources/Servers/ServerHardwareReport.cs
  87. 10 24
      RackPeek.Domain/Resources/Servers/UpdateServerUseCase.cs
  88. 2 3
      RackPeek.Domain/Resources/Services/IServiceRepository.cs
  89. 6 15
      RackPeek.Domain/Resources/Services/Networking/Cidr.cs
  90. 5 9
      RackPeek.Domain/Resources/Services/Networking/IpHelper.cs
  91. 6 11
      RackPeek.Domain/Resources/Services/Service.cs
  92. 5 14
      RackPeek.Domain/Resources/Services/UseCases/DescribeServiceUseCase.cs
  93. 6 9
      RackPeek.Domain/Resources/Services/UseCases/GetServiceSummaryUseCase.cs
  94. 10 19
      RackPeek.Domain/Resources/Services/UseCases/ServiceReportUseCase.cs
  95. 12 25
      RackPeek.Domain/Resources/Services/UseCases/ServiceSubnetsUseCase.cs
  96. 9 17
      RackPeek.Domain/Resources/Services/UseCases/UpdateServiceUseCase.cs
  97. 3 7
      RackPeek.Domain/Resources/SubResources/Cpu.cs
  98. 2 3
      RackPeek.Domain/Resources/SubResources/Drive.cs
  99. 2 3
      RackPeek.Domain/Resources/SubResources/Gpu.cs
  100. 2 3
      RackPeek.Domain/Resources/SubResources/Nic.cs

+ 142 - 0
.devcontainer/brew.sh

@@ -0,0 +1,142 @@
+#!/usr/bin/env bash
+
+BREW_PREFIX=${BREW_PREFIX:-"/home/vscode/.linuxbrew"}
+SHALLOW_CLONE=${SHALLOWCLONE:-"true"}
+USERNAME=${USERNAME:-"automatic"}
+
+ARCHITECTURE="$(uname -m)"
+if [ "${ARCHITECTURE}" != "amd64" ] && [ "${ARCHITECTURE}" != "x86_64" ] && [ "${ARCHITECTURE}" != "aarch64" ]; then
+  echo "(!) Architecture $ARCHITECTURE unsupported"
+  exit 1
+fi
+
+cleanup() {
+  source /etc/os-release
+  case "${ID}" in
+    debian|ubuntu)
+      rm -rf /var/lib/apt/lists/*
+    ;;
+  esac
+}
+
+if [ "$(id -u)" -ne 0 ]; then
+  echo -e 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.'
+  exit 1
+fi
+
+# Ensure that login shells get the correct path if the user updated the PATH using ENV.
+rm -f /etc/profile.d/00-restore-env.sh
+echo "export PATH=${PATH//$(sh -lc 'echo $PATH')/\$PATH}" > /etc/profile.d/00-restore-env.sh
+chmod +x /etc/profile.d/00-restore-env.sh
+
+# Determine the appropriate non-root user
+if [ "${USERNAME}" = "auto" ] || [ "${USERNAME}" = "automatic" ]; then
+  USERNAME=""
+  POSSIBLE_USERS=("vscode" "node" "codespace" "$(awk -v val=1000 -F ":" '$3==val{print $1}' /etc/passwd)")
+  for CURRENT_USER in "${POSSIBLE_USERS[@]}"; do
+    if id -u ${CURRENT_USER} > /dev/null 2>&1; then
+      USERNAME=${CURRENT_USER}
+      break
+    fi
+  done
+  if [ "${USERNAME}" = "" ]; then
+    USERNAME=root
+  fi
+elif [ "${USERNAME}" = "none" ] || ! id -u ${USERNAME} > /dev/null 2>&1; then
+  USERNAME=root
+fi
+
+apt_get_update() {
+    if [ "$(find /var/lib/apt/lists/* | wc -l)" = "0" ]; then
+        echo "Running apt-get update..."
+        apt-get update -y
+    fi
+}
+
+# Checks if packages are installed and installs them if not
+check_packages() {
+  source /etc/os-release
+  case "${ID}" in
+    debian|ubuntu)
+      if ! dpkg -s "$@" >/dev/null 2>&1; then
+        apt_get_update
+        apt-get -y install --no-install-recommends "$@"
+      fi
+    ;;
+    alpine)
+      if ! apk -e info "$@" >/dev/null 2>&1; then
+        apk add --no-cache "$@"
+      fi
+    ;;
+  esac
+}
+
+updaterc() {
+  if [ "${UPDATE_RC}" = "true" ]; then
+    echo "Updating /etc/bash.bashrc and /etc/zsh/zshrc..."
+    if [[ "$(cat /etc/bash.bashrc)" != *"$1"* ]]; then
+      echo -e "$1" >> /etc/bash.bashrc
+    fi
+    if [ -f "/etc/zsh/zshrc" ] && [[ "$(cat /etc/zsh/zshrc)" != *"$1"* ]]; then
+      echo -e "$1" >> /etc/zsh/zshrc
+    fi
+  fi
+}
+
+updatefishconfig() {
+  if [ "${UPDATE_RC}" = "true" ]; then
+    echo "Updating /etc/fish/config.fish..."
+    if [ -f "/etc/fish/config.fish" ]; then
+        echo -e "$1" >> /etc/fish/config.fish
+      fi
+  fi
+}
+
+export DEBIAN_FRONTEND=noninteractive
+
+# Clean up
+cleanup
+
+# Install dependencies if missing
+check_packages \
+  bzip2 \
+  ca-certificates \
+  curl \
+  file \
+  fonts-dejavu-core \
+  g++ \
+  git \
+  less \
+  libz-dev \
+  locales \
+  make \
+  netbase \
+  openssh-client \
+  patch \
+  sudo \
+  tzdata \
+  uuid-runtime
+
+# Install Homebrew
+mkdir -p "${BREW_PREFIX}"
+echo "Installing Homebrew..."
+if [ "${SHALLOW_CLONE}" = "false" ]; then
+  git clone https://github.com/Homebrew/brew "${BREW_PREFIX}/Homebrew"
+  mkdir -p "${BREW_PREFIX}/Homebrew/Library/Taps/homebrew"
+  git clone https://github.com/Homebrew/homebrew-core "${BREW_PREFIX}/Homebrew/Library/Taps/homebrew/homebrew-core"
+else
+  echo "Using shallow clone..."
+  git clone --depth 1 https://github.com/Homebrew/brew "${BREW_PREFIX}/Homebrew"
+  mkdir -p "${BREW_PREFIX}/Homebrew/Library/Taps/homebrew"
+  git clone --depth 1 https://github.com/Homebrew/homebrew-core "${BREW_PREFIX}/Homebrew/Library/Taps/homebrew/homebrew-core"
+  # Disable automatic updates as they are not allowed with shallow clone installation
+  updaterc "export HOMEBREW_NO_AUTO_UPDATE=1"
+  updatefishconfig "set -gx HOMEBREW_NO_AUTO_UPDATE 1"
+fi
+"${BREW_PREFIX}/Homebrew/bin/brew" config
+mkdir "${BREW_PREFIX}/bin"
+ln -s "${BREW_PREFIX}/Homebrew/bin/brew" "${BREW_PREFIX}/bin"
+chown -R ${USERNAME} "${BREW_PREFIX}"
+
+echo "Done!"
+

+ 28 - 0
.devcontainer/devcontainer.json

@@ -0,0 +1,28 @@
+// For format details, see https://aka.ms/devcontainer.json. For config options, see the
+// README at: https://github.com/devcontainers/templates/tree/main/src/dotnet
+{
+	"name": "C# (.NET)",
+	// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
+	"image": "mcr.microsoft.com/devcontainers/dotnet:2-10.0-noble",
+
+	// Features to add to the dev container. More info: https://containers.dev/features.
+	"features": {
+	},
+
+	// Use 'forwardPorts' to make a list of ports inside the container available locally.
+	// "forwardPorts": [5000, 5001],
+	// "portsAttributes": {
+	//		"5001": {
+	//			"protocol": "https"
+	//		}
+	// }
+
+	// Use 'postCreateCommand' to run commands after the container is created.
+	"postCreateCommand": "sudo .devcontainer/postInstall.sh"
+
+	// Configure tool-specific properties.
+	// "customizations": {},
+
+	// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
+	// "remoteUser": "root"
+}

+ 7 - 0
.devcontainer/postInstall.sh

@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+
+# originally based on https://github.com/meaningful-ooo/devcontainer-features/tree/main/src/homebrew
+
+source .devcontainer/brew.sh
+echo 'export PATH=/home/vscode/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:$PATH' >> /home/vscode/.bashrc
+/home/vscode/.linuxbrew/bin/brew install just

+ 52 - 0
.editorconfig

@@ -0,0 +1,52 @@
+root = true
+
+[*.cs]
+
+###############
+# Formatting  #
+###############
+
+indent_style = space
+indent_size = 4
+tab_width = 4
+
+end_of_line = lf
+insert_final_newline = true
+
+###############
+# C# Style    #
+###############
+
+csharp_new_line_before_open_brace = all:error
+csharp_indent_case_contents = true:error
+csharp_indent_switch_labels = true:error
+
+###############
+# var usage   #
+###############
+
+csharp_style_var_for_built_in_types = true:error
+csharp_style_var_when_type_is_apparent = true:error
+csharp_style_var_elsewhere = false:error
+csharp_style_var_in_deconstruction = true:error
+
+###############
+# Expression-bodied members
+###############
+
+csharp_style_expression_bodied_methods = when_on_single_line:error
+csharp_style_expression_bodied_properties = when_on_single_line:error
+
+###############
+# Naming rules
+###############
+
+dotnet_naming_rule.private_fields_should_be_camel_case.severity = error
+dotnet_naming_rule.private_fields_should_be_camel_case.symbols = private_fields
+dotnet_naming_rule.private_fields_should_be_camel_case.style = camel_case_style
+
+dotnet_naming_symbols.private_fields.applicable_kinds = field
+dotnet_naming_symbols.private_fields.applicable_accessibilities = private
+
+dotnet_naming_style.camel_case_style.capitalization = camel_case
+dotnet_naming_style.camel_case_style.required_prefix = _

+ 1 - 1
.github/workflows/generate-docs.yaml

@@ -35,6 +35,6 @@ jobs:
         run: |
           git config user.name "github-actions"
           git config user.email "github-actions@github.com"
-          git add CommandIndex.md Commands.md
+          git add Shared.Rcl/wwwroot/raw_docs/CommandIndex.md Shared.Rcl/wwwroot/raw_docs/Commands.md
           git commit -m "Update CLI docs" || echo "No changes to commit"
           git push

+ 1 - 1
.github/workflows/publish-docker-nightly.yaml

@@ -2,7 +2,7 @@ name: Docker Nightly Publish (amd64)
 
 on:
   push:
-    branches: [ main ]
+    branches: [ staging ]
   workflow_dispatch:
 
 permissions:

+ 0 - 20
.github/workflows/test-cli.yml

@@ -1,20 +0,0 @@
-name: CLI Tests
-
-on:
-  pull_request:
-  workflow_dispatch:
-
-jobs:
-  build:
-    runs-on: ubuntu-latest
-    steps:
-      - name: Checkout
-        uses: actions/checkout@v3
-
-      - name: Setup .NET
-        uses: actions/setup-dotnet@v3
-        with:
-          dotnet-version: 10.0.x
-
-      - name: Test
-        run: dotnet test Tests --verbosity normal

+ 54 - 7
.github/workflows/test-webui.yml → .github/workflows/test.yml

@@ -1,12 +1,60 @@
-name: WebUi Tests
+name: Tests
 
 on:
   pull_request:
   workflow_dispatch:
 
 jobs:
-  build:
-    runs-on: ubuntu-24.04  # pin for consistency (22.04 is also fine)
+
+  format:
+    name: Format Check
+    runs-on: ubuntu-latest
+
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v4
+
+      - name: Setup .NET
+        uses: actions/setup-dotnet@v4
+        with:
+          dotnet-version: 10.0.x
+
+      - name: Restore
+        run: dotnet restore
+
+      - name: Check Formatting
+        run: dotnet format --verify-no-changes
+
+
+  cli-tests:
+    name: CLI Tests
+    runs-on: ubuntu-latest
+    needs: format
+
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v4
+
+      - name: Setup .NET
+        uses: actions/setup-dotnet@v4
+        with:
+          dotnet-version: 10.0.x
+
+      - name: Restore
+        run: dotnet restore
+
+      - name: Build
+        run: dotnet build --no-restore --configuration Release
+
+      - name: Run CLI Tests
+        run: dotnet test Tests --no-build --configuration Release --verbosity normal
+
+
+  webui-tests:
+    name: WebUI / Playwright Tests
+    runs-on: ubuntu-24.04
+    needs: cli-tests
+
     steps:
       - name: Checkout
         uses: actions/checkout@v4
@@ -24,7 +72,7 @@ jobs:
           restore-keys: |
             ${{ runner.os }}-nuget-
 
-      - name: Cache Playwright browsers (Chromium)
+      - name: Cache Playwright browsers
         uses: actions/cache@v4
         with:
           path: ~/.cache/ms-playwright
@@ -38,8 +86,7 @@ jobs:
       - name: Build
         run: dotnet build --no-restore --configuration Release
 
-      # Prefer the Playwright script that comes with the NuGet package (no global tool install)
-      - name: Install Playwright Browsers (Chromium only)
+      - name: Install Playwright Browsers
         shell: bash
         run: |
           pwsh Tests.E2e/bin/Release/net*/playwright.ps1 install --with-deps chromium
@@ -52,4 +99,4 @@ jobs:
             .
 
       - name: Run E2E Tests
-        run: dotnet test Tests.E2e --configuration Release --verbosity normal
+        run: dotnet test Tests.E2e --configuration Release --verbosity normal

+ 318 - 0
AGENTS.md

@@ -0,0 +1,318 @@
+# RackPeek — Agent Guide
+
+This document is the entry point for AI agents (Claude Code, OpenCode, etc.) working in this repo. It captures everything needed to understand the codebase, make a focused change, validate it, and open a PR without re-reading the whole tree.
+
+---
+
+## 1. What RackPeek is
+
+RackPeek is a **CLI + Web UI for documenting and managing home-lab / small-scale IT infrastructure** (servers, switches, routers, firewalls, access points, UPS units, desktops, laptops, systems, and services).
+
+- All state is persisted to a **single YAML file** (`config/config.yaml`) — no database.
+- Same domain code powers the CLI binary (`rpk`) and the Blazor Server Web UI.
+- Distributed as a Docker image (`aptacode/rackpeek`) and a self-contained CLI binary.
+- Live demo: <https://timmoth.github.io/RackPeek/> · Docs: <https://timmoth.github.io/RackPeek/docs/overview>
+
+### Core values (these shape design decisions)
+
+- **Simplicity** — narrow scope, no enterprise CMDB features.
+- **Openness** — open YAML format, user owns their data.
+- **Privacy** — no telemetry, no tracking.
+- **Dogfooding** — features must be useful to real home-labs.
+- **Opinionated** — built for home labs, not corporate documentation.
+
+If a proposed change conflicts with these values, push back before implementing.
+
+---
+
+## 2. Tech stack
+
+| Layer | Tech |
+|---|---|
+| Language | C# (.NET **10.0**, `net10.0` TFM) |
+| CLI | [Spectre.Console.Cli](https://spectreconsole.net/) |
+| Web UI | Blazor Server (`Microsoft.NET.Sdk.Web`) + a WASM viewer (`RackPeek.Web.Viewer`) for the live demo |
+| Persistence | YAML (`YamlDotNet`, `DocMigrator.Yaml`) — single file |
+| Git integration | `LibGit2Sharp` (optional, used when `GIT_TOKEN` is set) |
+| CLI tests | xUnit + `Spectre.Console.Testing` + `JsonSchema.Net` |
+| E2E tests | xUnit + `Microsoft.Playwright` + `Testcontainers` (spins up the real Docker image) |
+| Build runner | [`just`](https://github.com/casey/just) |
+| Container | `mcr.microsoft.com/dotnet/aspnet:10.0` — exposes port 8080 |
+| Code style | `dotnet format` (CI gate) + `.editorconfig` |
+| Analysis | `TreatWarningsAsErrors=true`, `EnforceCodeStyleInBuild=true`, latest analyzers (see `Directory.Build.props`) |
+
+**.NET 10 is required.** If `dotnet --version` shows < 10, see `docs/development/dev-setup.md`. A devcontainer is included (`.devcontainer/`).
+
+---
+
+## 3. Solution layout
+
+```
+RackPeek.sln
+├── RackPeek/                  CLI entry point (Spectre.Console.Cli) → produces `rpk`
+├── RackPeek.Domain/           Core domain: resources, use-cases, persistence, git
+│   ├── Resources/             Resource models (Server, Switch, System, Service, …)
+│   ├── UseCases/              Generic use-cases (Add, Delete, Rename, Cpus, Drives, Gpus, Ports, Labels, Tags, Ansible, SSH, Hosts)
+│   ├── Persistence/           IResourceCollection, Yaml repositories, migrations
+│   │   └── Yaml/              YamlResourceCollection, RackPeekConfigMigrationDeserializer, ResourceYamlMigrationService
+│   ├── Git/                   Optional LibGit2Sharp integration (NullGitRepository when disabled)
+│   ├── Api/                   InventoryRequest/Response + UpsertInventoryUseCase (used by Web API)
+│   └── ServiceCollectionExtensions.cs   DI: AddUseCases / AddYamlRepos / AddGitServices
+├── Shared.Rcl/                Razor Class Library: Blazor components AND CLI command wiring shared between Web + CLI
+│   ├── Commands/              Spectre.Console.Cli command classes (one folder per resource kind)
+│   ├── Components/            Shared Razor components
+│   ├── Modals/, Layout/, Services/, Console/
+│   ├── CliBootstrap.cs        Registers all CLI commands + DI internals (single source of truth for the `rpk` command tree)
+│   └── ConsoleRunner.cs       Lets the Web UI execute CLI commands in-process
+├── RackPeek.Web/              Blazor Server host (Dockerfile lives here)
+├── RackPeek.Web.Viewer/       Blazor WebAssembly viewer (powers the github-pages demo)
+├── Tests/                     CLI integration tests (xUnit) — fast, no Docker
+│   ├── EndToEnd/              Per-resource workflow tests using the real CLI
+│   ├── Api/                   Web API endpoint tests (Microsoft.AspNetCore.Mvc.Testing)
+│   ├── TestConfigs/v1,v2,v3/  Fixture YAML files for migration tests
+│   └── schemas/               JSON schemas validated against output
+└── Tests.E2e/                 Playwright + Testcontainers — spins up the Docker image and drives the Web UI
+    ├── PageObjectModels/      One POM per page/component (required pattern)
+    └── Infra/PlaywrightFixture.cs   Container + browser lifecycle
+```
+
+### Where to put new code
+
+| You're adding… | Goes in… |
+|---|---|
+| A new CLI subcommand | `Shared.Rcl/Commands/<ResourceKind>/…` + register it in `Shared.Rcl/CliBootstrap.cs` |
+| A new resource kind | `RackPeek.Domain/Resources/<Kind>/` model, register in `Resource.cs` maps, add YAML migration, wire repos in `ServiceCollectionExtensions.cs`, add Razor pages under `Shared.Rcl/<Kind>/`, add Web routing |
+| A new use-case | `RackPeek.Domain/UseCases/` — implement `IUseCase` (auto-registered by reflection in `AddUseCases`) or the generic `IResourceUseCase<T>` |
+| A new Razor component used by CLI+Web | `Shared.Rcl/Components/` |
+| A new Web page only | `RackPeek.Web/Components/` |
+| A YAML schema change | Bump schema version under `schemas/vN/` + add migration in `RackPeek.Domain/Persistence/Yaml/` + add migration test in `Tests/TestConfigs/vN/` |
+
+---
+
+## 4. Build, test, run
+
+All workflow commands go through `justfile`. Prefer `just <target>` over running `dotnet` directly so behaviour stays consistent with CI.
+
+### Build
+
+```bash
+just build              # dotnet build RackPeek.sln (Debug)
+just build-release      # Release
+just build-cli          # publish self-contained single-file binary (default linux-x64)
+just build-cli linux-arm64    # cross-target
+just build-web          # docker build -t rackpeek:ci -f RackPeek.Web/Dockerfile .
+```
+
+### Test
+
+```bash
+just test-cli           # fast CLI tests, no Docker required
+just e2e-setup          # ONCE: installs Playwright CLI + browsers
+just test-e2e           # implies build-web; runs Playwright suite
+just test-all           # = build-web + e2e-setup + test-cli + test-e2e
+just ci                 # alias for test-all — matches the pre-PR checklist
+```
+
+CI order (`.github/workflows/test.yml`):
+
+1. **`format`** → `dotnet format --verify-no-changes` (runs on `ubuntu-latest`)
+2. **`cli-tests`** → `dotnet test Tests` (runs on `ubuntu-latest`, depends on format)
+3. **`webui-tests`** → builds the docker image then runs `dotnet test Tests.E2e` (runs on `ubuntu-24.04`, depends on cli-tests)
+
+Always run `dotnet format` before commit — formatting breaks CI first.
+
+### Run
+
+```bash
+just run-docker         # build + run container on http://localhost:8080
+just rpk [args]         # run CLI directly from Debug build
+just clean              # dotnet clean
+```
+
+### Release
+
+```bash
+just docker-push 1.3.2  # multi-arch (linux/amd64, linux/arm64) push to aptacode/rackpeek
+```
+
+CLI binary version is bumped in `RackPeek/RackPeek.csproj` (`<AssemblyVersion>`).
+
+### Demos (rarely needed by agents)
+
+```bash
+just build-cli-demo     # VHS recording — needs vhs, imagemagick, chrome
+just build-web-demo     # GIF capture — needs Chrome, ImageMagick
+```
+
+---
+
+## 5. Code style
+
+Enforced by CI via `dotnet format --verify-no-changes`. From `.editorconfig` + `Directory.Build.props`:
+
+- 4-space indent, LF line endings, final newline, UTF-8.
+- `var` for built-in types and when the type is apparent; explicit type otherwise.
+- Expression-bodied members only when on a single line.
+- Private fields are `_camelCase` (underscore prefix, error severity).
+- Open braces on a new line (Allman) — `csharp_new_line_before_open_brace = all:error`.
+- **Warnings are errors** repo-wide. Don't introduce nullable warnings or analyzer warnings.
+- Nullable reference types enabled in every project (`<Nullable>enable</Nullable>`).
+
+Default to writing no comments. The project favours readable names + tests-as-documentation.
+
+---
+
+## 6. Persistence model (important)
+
+There is **one YAML file**: `config/config.yaml` (or the path given by `RPK_YAML_DIR` env var; the Docker image sets it to `/app/config`).
+
+Top-level shape:
+
+```yaml
+resources:
+  - kind: Server | Switch | Firewall | Router | Accesspoint | Desktop | Laptop | Ups | System | Service
+    name: <unique name within kind>
+    tags: [...]
+    labels: { key: value }
+    notes: |
+      free-form markdown
+    runsOn: [<parent-resource-name>, ...]   # only meaningful for System / Service
+    # kind-specific fields follow (e.g. ports[], cpus[], drives[], gpus[], nics[], network, ram, …)
+```
+
+Key invariants (see `RackPeek.Domain/Resources/Resource.cs`):
+
+- `name` is the identity within a `kind`. Don't introduce numeric IDs.
+- `runsOn` relationships are validated by `Resource.CanRunOn<T>`:
+  - `Service` may run on a `System`.
+  - `System` may run on hardware (`Server`, `Switch`, `Firewall`, `Router`, `Accesspoint`, `Desktop`, `Laptop`, `Ups`) or on another `System`.
+- "Hardware" is the umbrella term for the eight physical kinds above (`Resource.IsHardware`).
+- Anything that mutates the YAML must go through an `IResourceUseCase<T>` → `IResourceCollection` → repository, never direct file writes.
+
+### YAML migrations
+
+Schemas are versioned under `schemas/v1`, `schemas/v2`, `schemas/v3`. Migration code lives in `RackPeek.Domain/Persistence/Yaml/`:
+
+- `RackPeekConfigMigrationDeserializer.cs` — deserialisation entry point
+- `ResourceYamlMigrationService.cs` — applies the version chain
+
+When you change persisted YAML shape, the PR **must** include:
+
+1. A new `schemas/vN+1/schema.vN+1.json`.
+2. A forward migration that reads vN and emits vN+1.
+3. Test fixtures under `Tests/TestConfigs/vN+1/` (note the explicit `<None Update>` entries in `Tests/Tests.csproj` if you add new files).
+4. Backwards compatibility for at least vN, OR a clearly documented breaking change.
+
+---
+
+## 7. CLI surface
+
+The full command tree is documented in `docs/Commands.md` and `docs/CommandIndex.md` (auto-generated by `generate-docs.sh`). At a glance:
+
+```
+rpk <kind> <verb> [name] [flags]
+
+kinds:   summary, servers, switches, routers, firewalls, systems,
+         accesspoints, ups, desktops, laptops, services
+verbs:   summary, add, list, get, describe, set, del, tree
+sub:     cpu, drive, gpu, nic, port, subnets, labels, tags, rename, …
+```
+
+When adding/altering commands, regenerate the docs (`./generate-docs.sh`) so the published reference stays in sync.
+
+---
+
+## 8. Environment variables
+
+| Var | Default | Purpose |
+|---|---|---|
+| `RPK_YAML_DIR` | `config` (CLI) / `/app/config` (Docker) | Directory containing `config.yaml` |
+| `GIT_TOKEN` | unset | If set, enables `LibGit2GitRepository` for the config dir |
+| `GIT_USERNAME` | `git` | Username paired with `GIT_TOKEN` |
+| `ASPNETCORE_URLS` | `http://+:8080` (Docker) | Web UI bind |
+
+---
+
+## 9. Testing principles
+
+Read `docs/development/testing-guidelines.md` in full before touching tests. Highlights:
+
+- **Test at the edges.** Black-box integration tests over micro-mocked unit tests. If a refactor breaks a test without changing observable behaviour, the test was too coupled.
+- **CLI tests** (`Tests/`) drive the real `CommandApp`, assert exact stdout, and inspect the YAML written to disk. Use the `ExecuteAsync(...)` helper pattern.
+- **E2E tests** (`Tests.E2e/`) use Testcontainers to run the real Docker image then drive the Web UI via Playwright. Every page has a Page Object Model (POM) in `Tests.E2e/PageObjectModels/`. Tests should read like workflows, not browser scripts.
+- E2E tests must be **independent, idempotent, and self-cleaning** — generate unique names with `Guid.NewGuid()` and delete what you create.
+- Treat every bug as a missing test: reproduce with a failing test, then fix.
+- Fix flakiness immediately; don't retry.
+
+### Adding a feature checklist
+
+- [ ] CLI test covering happy + at least one unhappy path (output + YAML side-effect)
+- [ ] E2E test for the corresponding Web UI flow (if there is one)
+- [ ] YAML migration + migration test (if persisted shape changed)
+- [ ] `dotnet format` clean
+- [ ] `just ci` green locally
+
+---
+
+## 10. Pull-request workflow
+
+From `docs/development/contribution-guidelines.md`:
+
+1. **Find / open a GitHub issue first.** Validate approach with maintainers before coding (issues > Discord for design discussion).
+2. Keep PRs **small and focused** — one concern per PR.
+3. Open as **Draft**; move to Ready only when:
+   - All tests pass locally (`just ci`)
+   - Scope is complete
+   - No debug code left in (especially `Headless = false` in `PlaywrightFixture.cs`)
+4. Pre-PR checklist (mirror in PR body):
+   - [ ] Linked GitHub issue
+   - [ ] Approach validated
+   - [ ] Small, focused PR
+   - [ ] CLI tests passing locally
+   - [ ] E2E tests passing locally
+   - [ ] Behaviour covered by tests
+   - [ ] YAML migration defined (if persisted shape changed)
+
+Default branches: feature work targets `staging`; releases flow `staging → main`.
+
+---
+
+## 11. Gotchas
+
+- **E2E tests require the Docker image.** `just test-e2e` rebuilds it via `just build-web`. If you change anything in `RackPeek.Web`, `RackPeek.Domain`, or `Shared.Rcl`, the image must be rebuilt before E2E runs.
+- **Playwright browsers** are installed once via `just e2e-setup`. In CI they're cached under `~/.cache/ms-playwright`.
+- **Bumping the `Microsoft.Playwright` package invalidates the browser cache.** Each Playwright version pins a specific Chromium build (e.g. 1.58 → `chromium_headless_shell-1208`, 1.59 → `-1217`). After bumping, every E2E test fails fast with `PlaywrightException : Executable doesn't exist at .../chromium_headless_shell-NNNN`. Re-run `just e2e-setup` (or `~/.dotnet/tools/playwright install chromium`) to download the matching build before running the suite.
+- **Docker image tag** is `rackpeek:ci` locally (referenced by `Tests.E2e/Infra/PlaywrightFixture.cs:9`); the registry tag is `aptacode/rackpeek`.
+- **Debugging E2E**: temporarily set `Headless = false, SlowMo = 1500` in `Tests.E2e/Infra/PlaywrightFixture.cs`. **Always revert before commit** — CI requires headless.
+- **TreatWarningsAsErrors** — a stray `unused-variable` warning fails the whole build. Don't add `#pragma warning disable` to push through; fix the warning.
+- **Git integration** is optional and silently no-ops when `GIT_TOKEN` is absent (`NullGitRepository`). Don't assume git is wired up.
+- **Single YAML file**: concurrent writes from CLI + Web are not coordinated beyond file replacement. Treat the Web UI as the source of truth while it's running.
+- The `RackPeek.Web/config copy/` directory looks like cruft but is checked-in — leave it alone unless cleaning up is the explicit goal.
+- The Web Docker image bundles **both** the Web app and the CLI binary (`rpk` is placed in `/usr/local/bin`). You can `docker exec rackpeek rpk ...` against a running container.
+
+---
+
+## 12. Reference
+
+| Path | What |
+|---|---|
+| `justfile` | Single source of truth for developer commands |
+| `RackPeek.sln` | Solution root |
+| `Directory.Build.props` | Repo-wide MSBuild props (analyzers, warnings-as-errors) |
+| `.editorconfig` | Formatting + naming rules |
+| `.github/workflows/test.yml` | CI pipeline (format → cli-tests → webui-tests) |
+| `.github/workflows/publish-*.yml` | Release pipelines |
+| `RackPeek.Web/Dockerfile` | Multi-stage build for the runtime image |
+| `RackPeek/Program.cs` | CLI entry point |
+| `RackPeek.Web/Program.cs` | Web entry point + DI wiring |
+| `Shared.Rcl/CliBootstrap.cs` | Master CLI command registration |
+| `RackPeek.Domain/ServiceCollectionExtensions.cs` | Domain DI registration |
+| `RackPeek.Domain/Resources/Resource.cs` | Resource base + kind/relationship rules |
+| `docs/development/contribution-guidelines.md` | PR process |
+| `docs/development/dev-cheat-sheet.md` | Build / release / Docker / Playwright details |
+| `docs/development/dev-setup.md` | First-time environment setup |
+| `docs/development/testing-guidelines.md` | Testing philosophy + examples |
+| `docs/Commands.md` / `docs/CommandIndex.md` | Auto-generated CLI reference |
+| `schemas/v1,v2,v3/` | Versioned YAML schemas |
+| `README.md` | User-facing overview, Docker install, links |
+| `LICENSE` | License terms |

+ 8 - 0
Directory.Build.props

@@ -0,0 +1,8 @@
+<Project>
+    <PropertyGroup>
+        <EnableNETAnalyzers>true</EnableNETAnalyzers>
+        <AnalysisLevel>latest</AnalysisLevel>
+        <EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
+        <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
+    </PropertyGroup>
+</Project>

+ 8 - 22
README.md

@@ -1,28 +1,12 @@
 [![RackPeek demo](./assets/rackpeek_banner_thin.png)](./assets/rackpeek_banner_thin.png)
 
-![Version](https://img.shields.io/badge/Version-1.0.0-2ea44f) ![Status](https://img.shields.io/badge/Status-Stable-success)
+![Version](https://img.shields.io/badge/Version-2.0.0-2ea44f) ![Status](https://img.shields.io/badge/Status-Stable-success)
 [![Join our Discord](https://img.shields.io/badge/Discord-Join%20Us-7289DA?logo=discord&logoColor=white)](https://discord.gg/egXRPdesee) [![Live Demo](https://img.shields.io/badge/Live%20Demo-Try%20RackPeek%20Online-2ea44f?logo=githubpages&logoColor=white)](https://timmoth.github.io/RackPeek/) [![Docker Hub](https://img.shields.io/badge/Docker%20Hub-rackpeek-2496ED?logo=docker&logoColor=white)](https://hub.docker.com/r/aptacode/rackpeek/)
 
-```
-Announcing v1.0.0, officially out of beta.  
-
-Thanks to everyone who tried early versions, opened issues, suggested changes, or used it in their lab and shared feedback.  
-   
-Appreciate all the support.
-```
-
-RackPeek is a lightweight, opinionated CLI tool / webui for documenting and managing home lab and small-scale IT infrastructure.
+RackPeek is a webui & CLI tool for documenting and managing home lab and small-scale IT infrastructure.
 
 It helps you track hardware, services, networks, and their relationships in a clear, scriptable, and reusable way without enterprise bloat or proprietary lock-in or drowning in unnecessary metadata or process.
 
-## Roadmap
-- Proxmox config / auto system creation
-- docker-gen ingestion / auto service creation
-- Support for IoT and networked devices (amongst other new hardware types)
-- Enhanced networking and port mapping
-- Git integration (version-controlled, shared configuration)
-- Diagramming tools
-
 ### The roadmap for the next wave of features is actively being discussed, please make your voice heard! 
 
 [![DB Tech — Finally Document Your Home Lab the Easy Way (Docker Install)](https://img.shields.io/badge/DB%20Tech%20[video]-Finally%20Document%20Your%20Home%20Lab%20the%20Easy%20Way-blue?style=for-the-badge)](https://www.youtube.com/watch?v=RJtMO8kIsqU)
@@ -68,6 +52,12 @@ services:
     volumes:
       - rackpeek-config:/app/config
     restart: unless-stopped
+    healthcheck:
+      test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
+      interval: 30s
+      timeout: 5s
+      start_period: 15s
+      retries: 3
 
 volumes:
   rackpeek-config:
@@ -92,10 +82,6 @@ volumes:
   [**Versioning**](https://timmoth.github.io/RackPeek/docs/versioning)
 
 
-## Contribution Guide
-
-We are now gearing up for the full v1.0.0 release, so development focus is on stability / bug fixes / essential core missing features. Please raise any suggestions / bugs / feedback in the Github issues.
-
 ## Questionnaire
 
 We’re gathering feedback from homelabbers to validate direction and prioritize features.  

+ 3 - 5
RackPeek.Domain/Api/InventoryRequest.cs

@@ -1,13 +1,11 @@
 using RackPeek.Domain.Persistence;
-using RackPeek.Domain.Persistence.Yaml;
 
 namespace RackPeek.Domain.Api;
 
-public class ImportYamlRequest
-{
+public class ImportYamlRequest {
     public string? Yaml { get; set; }
-    public object? Json { get; set; } 
+    public object? Json { get; set; }
     public MergeMode Mode { get; set; } = MergeMode.Merge;
 
     public bool DryRun { get; set; } = false;
-}
+}

+ 2 - 3
RackPeek.Domain/Api/InventoryResponse.cs

@@ -1,7 +1,6 @@
 namespace RackPeek.Domain.Api;
 
-public class ImportYamlResponse
-{
+public class ImportYamlResponse {
     public List<string> Added { get; set; } = new();
     public List<string> Updated { get; set; } = new();
     public List<string> Replaced { get; set; } = new();
@@ -11,4 +10,4 @@ public class ImportYamlResponse
 
     public Dictionary<string, string> NewYaml { get; set; }
         = new(StringComparer.OrdinalIgnoreCase);
-}
+}

+ 28 - 51
RackPeek.Domain/Api/UpsertInventoryUseCase.cs

@@ -1,9 +1,11 @@
+using System.Collections.Specialized;
 using System.ComponentModel.DataAnnotations;
 using System.Text.Json;
 using System.Text.Json.Serialization;
 using RackPeek.Domain.Persistence;
-using RackPeek.Domain.Resources;
 using RackPeek.Domain.Persistence.Yaml;
+using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.Connections;
 using YamlDotNet.Serialization;
 using YamlDotNet.Serialization.NamingConventions;
 
@@ -12,10 +14,8 @@ namespace RackPeek.Domain.Api;
 public class UpsertInventoryUseCase(
     IResourceCollection repo,
     IResourceYamlMigrationService migrationService)
-    : IUseCase
-{
-    private static readonly JsonSerializerOptions JsonOptions = new()
-    {
+    : IUseCase {
+    private static readonly JsonSerializerOptions _jsonOptions = new() {
         PropertyNameCaseInsensitive = true,
         WriteIndented = false,
         DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
@@ -23,69 +23,56 @@ public class UpsertInventoryUseCase(
         TypeInfoResolver = ResourcePolymorphismResolver.Create()
     };
 
-    public async Task<ImportYamlResponse> ExecuteAsync(ImportYamlRequest request)
-    {
+    public async Task<ImportYamlResponse> ExecuteAsync(ImportYamlRequest request) {
         if (request == null)
             throw new ValidationException("Invalid request.");
 
         if (string.IsNullOrWhiteSpace(request.Yaml) && request.Json == null)
             throw new ValidationException("Either 'yaml' or 'json' must be provided.");
-        
+
         if (!string.IsNullOrWhiteSpace(request.Yaml) && request.Json != null)
             throw new ValidationException("Provide either 'yaml' or 'json', not both.");
-        
-        
+
+
         YamlRoot incomingRoot;
         string yamlInput;
 
-        if (!string.IsNullOrWhiteSpace(request.Yaml))
-        {
+        if (!string.IsNullOrWhiteSpace(request.Yaml)) {
             yamlInput = request.Yaml!;
             incomingRoot = await migrationService.DeserializeAsync(yamlInput)
                            ?? throw new ValidationException("Invalid YAML structure.");
         }
-        else
-        {
+        else {
             if (request.Json is not JsonElement element)
                 throw new ValidationException("Invalid JSON payload.");
-            
+
             var rawJson = element.GetRawText();
             incomingRoot = JsonSerializer.Deserialize<YamlRoot>(
                                rawJson,
-                               JsonOptions)
+                               _jsonOptions)
                            ?? throw new ValidationException("Invalid JSON structure.");
-            // Generate YAML only for persistence layer
-            var yamlSerializer = new SerializerBuilder()
-                .WithNamingConvention(CamelCaseNamingConvention.Instance)
-                .WithTypeConverter(new StorageSizeYamlConverter())
-                .WithTypeConverter(new NotesStringYamlConverter())
-                .ConfigureDefaultValuesHandling(
-                    DefaultValuesHandling.OmitNull |
-                    DefaultValuesHandling.OmitEmptyCollections)
-                .Build();
-
-            yamlInput = yamlSerializer.Serialize(incomingRoot);
+
+            yamlInput = YamlResourceCollection.SerializeRootAsync(incomingRoot);
         }
 
         if (incomingRoot.Resources == null)
             throw new ValidationException("Missing 'resources' section.");
 
         // 2️Compute Diff
+        List<Resource>? incomingResources = incomingRoot.Resources;
+        IReadOnlyList<Resource> currentResources = await repo.GetAllOfTypeAsync<Resource>();
 
-        var incomingResources = incomingRoot.Resources;
-        var currentResources = await repo.GetAllOfTypeAsync<Resource>();
-
-        var duplicate = incomingResources
+        IGrouping<string, Resource>? duplicate = incomingResources
             .GroupBy(r => r.Name, StringComparer.OrdinalIgnoreCase)
             .FirstOrDefault(g => g.Count() > 1);
 
         if (duplicate != null)
             throw new ValidationException($"Duplicate resource name: {duplicate.Key}");
-        
+
         var currentDict = currentResources
             .ToDictionary(r => r.Name, StringComparer.OrdinalIgnoreCase);
 
-        var serializerDiff = new SerializerBuilder()
+        ISerializer serializerDiff = new SerializerBuilder()
             .WithNamingConvention(CamelCaseNamingConvention.Instance)
             .ConfigureDefaultValuesHandling(
                 DefaultValuesHandling.OmitNull |
@@ -98,7 +85,7 @@ public class UpsertInventoryUseCase(
                 r => serializerDiff.Serialize(r),
                 StringComparer.OrdinalIgnoreCase);
 
-        var mergedResources = ResourceCollectionMerger.Merge(
+        List<Resource> mergedResources = ResourceCollectionMerger.Merge(
             currentResources,
             incomingResources,
             request.Mode);
@@ -108,16 +95,14 @@ public class UpsertInventoryUseCase(
 
         var response = new ImportYamlResponse();
 
-        foreach (var incoming in incomingResources)
-        {
-            if (!mergedDict.TryGetValue(incoming.Name, out var merged))
+        foreach (Resource incoming in incomingResources) {
+            if (!mergedDict.TryGetValue(incoming.Name, out Resource? merged))
                 continue;
 
             var newYaml = serializerDiff.Serialize(merged);
             response.NewYaml[incoming.Name] = newYaml;
 
-            if (!currentDict.ContainsKey(incoming.Name))
-            {
+            if (!currentDict.ContainsKey(incoming.Name)) {
                 response.Added.Add(incoming.Name);
                 continue;
             }
@@ -125,24 +110,16 @@ public class UpsertInventoryUseCase(
             var oldYaml = oldSnapshots[incoming.Name];
             response.OldYaml[incoming.Name] = oldYaml;
 
-            var existing = currentDict[incoming.Name];
+            Resource existing = currentDict[incoming.Name];
 
             if (request.Mode == MergeMode.Replace ||
                 existing.GetType() != incoming.GetType())
-            {
                 response.Replaced.Add(incoming.Name);
-            }
-            else if (oldYaml != newYaml)
-            {
-                response.Updated.Add(incoming.Name);
-            }
+            else if (oldYaml != newYaml) response.Updated.Add(incoming.Name);
         }
 
-        if (!request.DryRun)
-        {
-            await repo.Merge(yamlInput, request.Mode);
-        }
+        if (!request.DryRun) await repo.Merge(yamlInput, request.Mode);
 
         return response;
     }
-}
+}

+ 10 - 0
RackPeek.Domain/Git/GitStatus.cs

@@ -0,0 +1,10 @@
+namespace RackPeek.Domain.Git;
+
+public enum GitRepoStatus {
+    NotAvailable,
+    Clean,
+    Dirty
+}
+
+public record GitLogEntry(string Hash, string Message, string Author, string Date);
+public record GitSyncStatus(int Ahead, int Behind, bool HasRemote, string? Error = null);

+ 19 - 0
RackPeek.Domain/Git/IGitRepository.cs

@@ -0,0 +1,19 @@
+namespace RackPeek.Domain.Git;
+
+public interface IGitRepository {
+    bool IsAvailable { get; }
+    void Init();
+    GitRepoStatus GetStatus();
+    void StageAll();
+    void Commit(string message);
+    string GetDiff();
+    string[] GetChangedFiles();
+    void RestoreAll();
+    string GetCurrentBranch();
+    GitLogEntry[] GetLog(int count);
+    bool HasRemote();
+    GitSyncStatus FetchAndGetSyncStatus();
+    void Push();
+    void Pull();
+    void AddRemote(string name, string url);
+}

+ 384 - 0
RackPeek.Domain/Git/LibGit2GitRepository.cs

@@ -0,0 +1,384 @@
+using LibGit2Sharp;
+using LibGit2Sharp.Handlers;
+
+namespace RackPeek.Domain.Git;
+
+public interface IGitCredentialsProvider {
+    CredentialsHandler GetHandler();
+}
+
+/// <summary>
+/// HTTP Basic auth using a personal access token as the password. Works with
+/// any forge that accepts a token over HTTPS — GitHub, Gitea, GitLab, Bitbucket,
+/// Forgejo, etc.
+/// </summary>
+public sealed class TokenCredentialsProvider(string username, string token) : IGitCredentialsProvider {
+    private readonly string _username = username ?? throw new ArgumentNullException(nameof(username));
+    private readonly string _token = token ?? throw new ArgumentNullException(nameof(token));
+
+    public CredentialsHandler GetHandler() {
+        return (_, _, _) => new UsernamePasswordCredentials {
+            Username = _username,
+            Password = _token
+        };
+    }
+}
+
+public sealed class LibGit2GitRepository : IGitRepository {
+    private readonly string _configDirectory;
+    private readonly CredentialsHandler _credentials;
+    private readonly CertificateCheckHandler? _certificateCheck;
+
+    public LibGit2GitRepository(
+        string configDirectory,
+        IGitCredentialsProvider credentialsProvider,
+        bool insecureTls = false) {
+        _configDirectory = configDirectory;
+        _credentials = credentialsProvider.GetHandler();
+        // The user opted into git by setting GIT_TOKEN. Auto-init on a fresh
+        // config directory so the UI can immediately offer Add Remote — without
+        // this, first-time users hit "Git is not available." on every action.
+        // Init is idempotent for existing repos (IsValid skips the call) and
+        // does not touch existing files; it only creates .git/. Failure (e.g.
+        // a read-only mount) must not throw out of the singleton factory — the
+        // UI relies on IsAvailable=false to render the writability warning.
+        if (Directory.Exists(configDirectory) && !Repository.IsValid(configDirectory))
+            try {
+                Repository.Init(configDirectory);
+            }
+            catch {
+                // Leave IsAvailable=false; surfaced via the writability warning.
+            }
+
+        _isAvailable = Repository.IsValid(configDirectory);
+        // When insecureTls is true, accept any TLS certificate. Required for
+        // self-hosted forges (Gitea, GitLab) behind a private CA or self-signed
+        // cert. Public hosts already ship trusted certs; leave it off for them.
+        InsecureTls = insecureTls;
+        _certificateCheck = insecureTls
+            ? (_, _, _) => true
+            : null;
+    }
+
+    private FetchOptions BuildFetchOptions() => new() {
+        CredentialsProvider = _credentials,
+        CertificateCheck = _certificateCheck
+    };
+
+    private PushOptions BuildPushOptions() => new() {
+        CredentialsProvider = _credentials,
+        CertificateCheck = _certificateCheck
+    };
+
+    private bool _isAvailable;
+
+    public bool IsAvailable => _isAvailable;
+    public bool InsecureTls { get; }
+
+    public void Init() {
+        Repository.Init(_configDirectory);
+
+        _isAvailable = true;
+    }
+
+    private Repository OpenRepo() => new(_configDirectory);
+
+    private static Signature GetSignature(Repository repo) {
+        var name = repo.Config.Get<string>("user.name")?.Value ?? "RackPeek";
+        var email = repo.Config.Get<string>("user.email")?.Value ?? "rackpeek@local";
+
+        return new Signature(name, email, DateTimeOffset.Now);
+    }
+
+    private static Remote GetRemote(Repository repo)
+        => repo.Network.Remotes["origin"] ?? repo.Network.Remotes.First();
+
+    public GitRepoStatus GetStatus() {
+        if (!_isAvailable)
+            return GitRepoStatus.NotAvailable;
+
+        using Repository repo = OpenRepo();
+
+        return repo.RetrieveStatus().IsDirty
+            ? GitRepoStatus.Dirty
+            : GitRepoStatus.Clean;
+    }
+
+    public void StageAll() {
+        using Repository repo = OpenRepo();
+
+        var files = repo.RetrieveStatus()
+            .Where(e => e.State != FileStatus.Ignored)
+            .Select(e => e.FilePath)
+            .ToList();
+
+        if (files.Count == 0)
+            return;
+
+        Commands.Stage(repo, files);
+    }
+
+    public void Commit(string message) {
+        using Repository repo = OpenRepo();
+
+        Signature signature = GetSignature(repo);
+        repo.Commit(message, signature, signature);
+    }
+
+    public string GetDiff() {
+        using Repository repo = OpenRepo();
+
+        Tree? tree = repo.Head.Tip?.Tree;
+
+        Patch patch = repo.Diff.Compare<Patch>(
+            tree,
+            DiffTargets.Index | DiffTargets.WorkingDirectory);
+
+        return patch?.Content ?? string.Empty;
+    }
+    public string[] GetChangedFiles() {
+        using Repository repo = OpenRepo();
+
+        return repo.RetrieveStatus()
+            .Where(e => e.State != FileStatus.Ignored)
+            .Select(e => $"{GetPrefix(e.State)}  {e.FilePath}")
+            .ToArray();
+    }
+
+    private static string GetPrefix(FileStatus state) => state switch {
+        FileStatus.NewInWorkdir or FileStatus.NewInIndex => "A",
+        FileStatus.DeletedFromWorkdir or FileStatus.DeletedFromIndex => "D",
+        FileStatus.RenamedInWorkdir or FileStatus.RenamedInIndex => "R",
+        _ when state.HasFlag(FileStatus.ModifiedInWorkdir)
+          || state.HasFlag(FileStatus.ModifiedInIndex) => "M",
+        _ => "?"
+    };
+
+    public void RestoreAll() {
+        using Repository repo = OpenRepo();
+
+        repo.CheckoutPaths(
+            repo.Head.FriendlyName,
+            ["*"],
+            new CheckoutOptions { CheckoutModifiers = CheckoutModifiers.Force });
+
+        repo.RemoveUntrackedFiles();
+    }
+
+    public string GetCurrentBranch() {
+        using Repository repo = OpenRepo();
+        return repo.Head.FriendlyName;
+    }
+
+    public GitLogEntry[] GetLog(int count) {
+        using Repository repo = OpenRepo();
+
+        if (repo.Head.Tip is null)
+            return [];
+
+        return repo.Commits
+            .Take(count)
+            .Select(c => new GitLogEntry(
+                c.Sha[..7],
+                c.MessageShort,
+                c.Author.Name,
+                FormatRelativeDate(c.Author.When)))
+            .ToArray();
+    }
+
+    public bool HasRemote() {
+        using Repository repo = OpenRepo();
+        return repo.Network.Remotes.Any();
+    }
+
+    public GitSyncStatus FetchAndGetSyncStatus() {
+        using Repository repo = OpenRepo();
+
+        if (!repo.Network.Remotes.Any())
+            return new GitSyncStatus(0, 0, false);
+
+        Remote remote = GetRemote(repo);
+
+        Commands.Fetch(
+            repo,
+            remote.Name,
+            remote.FetchRefSpecs.Select(r => r.Specification),
+            BuildFetchOptions(),
+            null);
+
+        // If the repo has no commits yet (unborn branch)
+        if (repo.Head.Tip == null)
+            return new GitSyncStatus(0, 0, true);
+
+        Branch? remoteBranch = repo.Branches[$"{remote.Name}/{repo.Head.FriendlyName}"];
+
+        if (remoteBranch?.Tip == null)
+            return new GitSyncStatus(repo.Commits.Count(), 0, true);
+
+        HistoryDivergence? divergence = repo.ObjectDatabase.CalculateHistoryDivergence(
+            repo.Head.Tip,
+            remoteBranch.Tip);
+
+        return new GitSyncStatus(
+            divergence.AheadBy ?? 0,
+            divergence.BehindBy ?? 0,
+            true);
+    }
+    public void Push() {
+        using Repository repo = OpenRepo();
+
+        Remote remote = GetRemote(repo);
+        var branch = repo.Head.FriendlyName;
+        var refSpec = $"refs/heads/{branch}:refs/heads/{branch}";
+
+        try {
+            repo.Network.Push(
+                remote,
+                refSpec,
+                BuildPushOptions());
+        }
+        catch (NonFastForwardException) {
+            PullInternal(repo);
+
+            repo.Network.Push(
+                remote,
+                refSpec,
+                BuildPushOptions());
+        }
+
+        if (repo.Head.TrackedBranch is null) {
+            repo.Branches.Update(repo.Head,
+                b => b.TrackedBranch = $"refs/remotes/{remote.Name}/{branch}");
+        }
+    }
+
+    public void Pull() {
+        using Repository repo = OpenRepo();
+        PullInternal(repo);
+    }
+
+    private void PullInternal(Repository repo) {
+        if (!repo.Network.Remotes.Any())
+            return;
+
+        Remote remote = GetRemote(repo);
+
+        Commands.Fetch(
+            repo,
+            remote.Name,
+            remote.FetchRefSpecs.Select(r => r.Specification),
+            BuildFetchOptions(),
+            null);
+
+        Branch? remoteBranch = repo.Branches[$"{remote.Name}/{repo.Head.FriendlyName}"];
+
+        if (remoteBranch?.Tip == null)
+            return;
+
+        // hard reset to remote branch
+        repo.Reset(ResetMode.Hard, remoteBranch.Tip);
+
+        repo.Branches.Update(repo.Head,
+            b => b.TrackedBranch = remoteBranch.CanonicalName);
+    }
+    public void AddRemote(string name, string url) {
+        using Repository repo = OpenRepo();
+
+        if (repo.Network.Remotes[name] != null)
+            return;
+
+        repo.Network.Remotes.Add(name, url);
+
+        Remote remote = repo.Network.Remotes[name];
+
+        // fetch remote state
+        Commands.Fetch(
+            repo,
+            remote.Name,
+            remote.FetchRefSpecs.Select(r => r.Specification),
+            BuildFetchOptions(),
+            null);
+
+        // detect if remote has a default branch
+        Branch? remoteMain =
+            repo.Branches[$"{remote.Name}/main"] ??
+            repo.Branches[$"{remote.Name}/master"];
+
+        var hasLocalFiles =
+            repo.RetrieveStatus()
+                .Any(e => e.State != FileStatus.Ignored);
+
+        // CASE 1: remote repo already has commits
+        if (remoteMain != null && remoteMain.Tip != null) {
+            Branch local = repo.CreateBranch(remoteMain.FriendlyName, remoteMain.Tip);
+            Commands.Checkout(repo, local);
+
+            repo.Branches.Update(local,
+                b => b.TrackedBranch = remoteMain.CanonicalName);
+
+            if (hasLocalFiles) {
+                // import existing config to a new branch
+                var importBranchName = $"rackpeek-{DateTime.UtcNow:yyyyMMddHHmmss}";
+
+                Branch importBranch = repo.CreateBranch(importBranchName);
+                Commands.Checkout(repo, importBranch);
+
+                Commands.Stage(repo, "*");
+
+                Signature sig = GetSignature(repo);
+
+                repo.Commit(
+                    "rackpeek: import existing config",
+                    sig,
+                    sig);
+
+                repo.Network.Push(
+                    remote,
+                    $"refs/heads/{importBranchName}:refs/heads/{importBranchName}",
+                    BuildPushOptions());
+
+                repo.Branches.Update(importBranch,
+                    b => b.TrackedBranch = $"refs/remotes/{remote.Name}/{importBranchName}");
+            }
+
+            return;
+        }
+
+        // CASE 2: remote repo is empty
+        if (hasLocalFiles) {
+            var branchName = "main";
+
+            Branch branch = repo.CreateBranch(branchName);
+            Commands.Checkout(repo, branch);
+
+            Commands.Stage(repo, "*");
+
+            Signature sig = GetSignature(repo);
+
+            repo.Commit(
+                "rackpeek: initial config",
+                sig,
+                sig);
+
+            repo.Network.Push(
+                remote,
+                $"refs/heads/{branchName}:refs/heads/{branchName}",
+                BuildPushOptions());
+
+            repo.Branches.Update(branch,
+                b => b.TrackedBranch = $"refs/remotes/{remote.Name}/{branchName}");
+        }
+    }
+
+    private static string FormatRelativeDate(DateTimeOffset date) {
+        TimeSpan diff = DateTimeOffset.Now - date;
+
+        if (diff.TotalMinutes < 1) return "just now";
+        if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes} minutes ago";
+        if (diff.TotalHours < 24) return $"{(int)diff.TotalHours} hours ago";
+        if (diff.TotalDays < 30) return $"{(int)diff.TotalDays} days ago";
+        if (diff.TotalDays < 365) return $"{(int)(diff.TotalDays / 30)} months ago";
+
+        return $"{(int)(diff.TotalDays / 365)} years ago";
+    }
+}

+ 19 - 0
RackPeek.Domain/Git/NullGitRepository.cs

@@ -0,0 +1,19 @@
+namespace RackPeek.Domain.Git;
+
+public sealed class NullGitRepository : IGitRepository {
+    public bool IsAvailable => false;
+    public void Init() { }
+    public GitRepoStatus GetStatus() => GitRepoStatus.NotAvailable;
+    public void StageAll() { }
+    public void Commit(string message) { }
+    public string GetDiff() => string.Empty;
+    public string[] GetChangedFiles() => [];
+    public void RestoreAll() { }
+    public string GetCurrentBranch() => string.Empty;
+    public GitLogEntry[] GetLog(int count) => [];
+    public bool HasRemote() => false;
+    public GitSyncStatus FetchAndGetSyncStatus() => new(0, 0, false);
+    public void Push() { }
+    public void Pull() { }
+    public void AddRemote(string name, string url) { }
+}

+ 36 - 0
RackPeek.Domain/Git/UseCases/AddRemoteUseCase.cs

@@ -0,0 +1,36 @@
+namespace RackPeek.Domain.Git.UseCases;
+
+public class AddRemoteUseCase(IGitRepository repo) : IUseCase {
+    public Task<string?> ExecuteAsync(string url) {
+        if (!repo.IsAvailable)
+            return Task.FromResult<string?>("Git is not available.");
+
+        if (string.IsNullOrWhiteSpace(url))
+            return Task.FromResult<string?>("URL is required.");
+
+        url = url.Trim();
+
+        if (!url.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
+            return Task.FromResult<string?>("Only HTTPS URLs are supported.");
+
+        if (repo.HasRemote())
+            return Task.FromResult<string?>("Remote already configured.");
+
+        try {
+            repo.AddRemote("origin", url);
+
+            // fetch remote state
+            GitSyncStatus sync = repo.FetchAndGetSyncStatus();
+
+            // if remote already has commits, bring them locally
+            if (sync.Behind > 0) {
+                repo.Pull();
+            }
+
+            return Task.FromResult<string?>(null);
+        }
+        catch (Exception ex) {
+            return Task.FromResult<string?>($"Add remote failed: {ex.Message}");
+        }
+    }
+}

+ 21 - 0
RackPeek.Domain/Git/UseCases/CommitAllUseCase.cs

@@ -0,0 +1,21 @@
+namespace RackPeek.Domain.Git.UseCases;
+
+public class CommitAllUseCase(IGitRepository repo) : IUseCase {
+    public Task<string?> ExecuteAsync(string message) {
+        if (!repo.IsAvailable)
+            return Task.FromResult<string?>("Git is not available.");
+
+        try {
+            repo.StageAll();
+
+            if (repo.GetStatus() != GitRepoStatus.Dirty)
+                return Task.FromResult<string?>(null);
+
+            repo.Commit(message);
+            return Task.FromResult<string?>(null);
+        }
+        catch (Exception ex) {
+            return Task.FromResult<string?>($"Commit failed: {ex.Message}");
+        }
+    }
+}

+ 16 - 0
RackPeek.Domain/Git/UseCases/InitRepoUseCase.cs

@@ -0,0 +1,16 @@
+namespace RackPeek.Domain.Git.UseCases;
+
+public class InitRepoUseCase(IGitRepository repo) : IUseCase {
+    public Task<string?> ExecuteAsync() {
+        if (repo.IsAvailable)
+            return Task.FromResult<string?>(null);
+
+        try {
+            repo.Init();
+            return Task.FromResult<string?>(null);
+        }
+        catch (Exception ex) {
+            return Task.FromResult<string?>($"Init failed: {ex.Message}");
+        }
+    }
+}

+ 20 - 0
RackPeek.Domain/Git/UseCases/PullUseCase.cs

@@ -0,0 +1,20 @@
+using RackPeek.Domain;
+using RackPeek.Domain.Git;
+
+public class PullUseCase(IGitRepository repo) : IUseCase {
+    public Task<string?> ExecuteAsync() {
+        if (!repo.IsAvailable)
+            return Task.FromResult<string?>("Git is not available.");
+
+        if (!repo.HasRemote())
+            return Task.FromResult<string?>("No remote configured.");
+
+        try {
+            repo.Pull();
+            return Task.FromResult<string?>(null);
+        }
+        catch (Exception ex) {
+            return Task.FromResult<string?>($"Pull failed: {ex.Message}");
+        }
+    }
+}

+ 27 - 0
RackPeek.Domain/Git/UseCases/PushUseCase.cs

@@ -0,0 +1,27 @@
+using RackPeek.Domain;
+using RackPeek.Domain.Git;
+
+public class PushUseCase(IGitRepository repo) : IUseCase {
+    public Task<string?> ExecuteAsync() {
+        if (!repo.IsAvailable)
+            return Task.FromResult<string?>("Git is not available.");
+
+        if (!repo.HasRemote())
+            return Task.FromResult<string?>("No remote configured.");
+
+        try {
+            try {
+                repo.Push();
+            }
+            catch {
+                repo.Pull();
+                repo.Push();
+            }
+
+            return Task.FromResult<string?>(null);
+        }
+        catch (Exception ex) {
+            return Task.FromResult<string?>($"Push failed: {ex.Message}");
+        }
+    }
+}

+ 16 - 0
RackPeek.Domain/Git/UseCases/RestoreAllUseCase.cs

@@ -0,0 +1,16 @@
+namespace RackPeek.Domain.Git.UseCases;
+
+public class RestoreAllUseCase(IGitRepository repo) : IUseCase {
+    public Task<string?> ExecuteAsync() {
+        if (!repo.IsAvailable)
+            return Task.FromResult<string?>("Git is not available.");
+
+        try {
+            repo.RestoreAll();
+            return Task.FromResult<string?>(null);
+        }
+        catch (Exception ex) {
+            return Task.FromResult<string?>($"Restore failed: {ex.Message}");
+        }
+    }
+}

+ 52 - 0
RackPeek.Domain/Graph/Graph.cs

@@ -0,0 +1,52 @@
+namespace RackPeek.Domain.Graph;
+
+public record GraphNode(
+    string Id,
+    string Label,
+    string Kind,
+    string? Subtitle = null,
+    IReadOnlyDictionary<string, string>? Data = null,
+    IReadOnlyList<GraphNodeRow>? Rows = null);
+
+/// <summary>
+///     A bullet/list row rendered inside a node label. Used by the logical
+///     view to fold a host's services into a single host card.
+/// </summary>
+public record GraphNodeRow(string Name, string? Detail = null);
+
+public record GraphEdge(
+    string Source,
+    string Target,
+    string? Label,
+    string Kind,
+    IReadOnlyDictionary<string, string>? Data = null);
+
+/// <summary>
+///     A labelled cluster of nodes. Used to drive Mermaid <c>subgraph</c>
+///     blocks. Groups may nest via <see cref="ParentGroupId"/>.
+/// </summary>
+public record GraphGroup(
+    string Id,
+    string Label,
+    IReadOnlyList<string> NodeIds,
+    string? ParentGroupId = null);
+
+/// <summary>
+///     How a graph should be rendered. <see cref="Standard"/> is the
+///     hardware-topology view: one node per resource with shape-based kind
+///     signalling. <see cref="Compact"/> is the logical-services view:
+///     each host is a single card listing its services as rows, no edges,
+///     siblings packed vertically.
+/// </summary>
+public enum GraphRenderHint {
+    Standard,
+    Compact
+}
+
+public record Graph(
+    IReadOnlyList<GraphNode> Nodes,
+    IReadOnlyList<GraphEdge> Edges,
+    IReadOnlyList<GraphGroup>? Groups = null,
+    GraphRenderHint RenderHint = GraphRenderHint.Standard) {
+    public static Graph Empty { get; } = new([], [], null);
+}

+ 465 - 0
RackPeek.Domain/Graph/Serialisers/MermaidSerialiser.cs

@@ -0,0 +1,465 @@
+using System.Text;
+
+namespace RackPeek.Domain.Graph.Serialisers;
+
+/// <summary>
+///     Renders a <see cref="Graph"/> as a Mermaid flowchart string.
+///     Output is deterministic (nodes/edges in insertion order) so the
+///     same inventory always produces the same diagram — important for
+///     golden-file tests and for committing rendered diagrams to docs.
+/// </summary>
+public sealed class MermaidSerialiser {
+    // Single neutral palette for a sleek monochrome look. Resource kind is
+    // signalled by node shape, not colour, so diagrams stay calm even with
+    // every kind of resource mixed in.
+    private const string _nodeFill = "#1f2937";    // gray-800
+    private const string _nodeStroke = "#52525b";  // zinc-600
+    private const string _nodeText = "#e5e7eb";    // gray-200
+    private const string _edgeStroke = "#52525b";  // zinc-600
+    private const string _groupStroke = "#3f3f46"; // zinc-700
+    private const string _groupText = "#a1a1aa";   // zinc-400
+    private const string _nodeClass = "rpknode";
+    private const string _groupClass = "rpkgroup";
+    private const string _smallRowClass = "rpkrow";
+
+    // Compact-mode (logical view) tuning. Small-row size controls how many
+    // single-service host cards pack into one invisible row before wrapping.
+    private const int _compactSmallRowSize = 4;
+    private const int _compactTableColumns = 3;
+
+    // Mermaid node shape per resource kind. Shape choice borrows from the
+    // network-diagram conventions used by NetBox/draw.io/UniFi: hexagons for
+    // security boundaries, stadiums for gateways, cylinders for compute,
+    // circles for radios, etc. Looking at the silhouette alone should hint
+    // at the role without colour or icons.
+    private static readonly IReadOnlyDictionary<string, Shape> _shapes =
+        new Dictionary<string, Shape>(StringComparer.OrdinalIgnoreCase) {
+            // Physical / topology view shapes
+            ["Firewall"] = new("{{\"", "\"}}"),    // hexagon — boundary
+            ["Router"] = new("([\"", "\"])"),      // stadium — gateway
+            ["Switch"] = new("[[\"", "\"]]"),      // subroutine — distribution
+            ["Server"] = new("[(\"", "\")]"),      // cylinder — compute / storage
+            ["AccessPoint"] = new("((\"", "\"))"), // circle — radio
+            ["Ups"] = new("{\"", "\"}"),           // rhombus — utility
+            ["Desktop"] = new("(\"", "\")"),       // rounded rect — endpoint
+            ["Laptop"] = new("(\"", "\")"),        // rounded rect — endpoint
+
+            // Logical / service view shapes (don't appear with the physical
+            // kinds in the same diagram, so shape reuse across views is OK)
+            ["Service"] = new("[[\"", "\"]]"),     // subroutine — consumable
+            ["Hypervisor"] = new("([\"", "\"])"),  // stadium — host gateway
+            ["Vm"] = new("(\"", "\")"),            // rounded — virtual machine
+            ["Container"] = new("{{\"", "\"}}"),   // hexagon — lightweight unit
+            ["System"] = new("[\"", "\"]")         // plain rect — fallback
+        };
+
+    private static readonly Shape _fallbackShape = new("[\"", "\"]");
+
+    public string Serialise(Graph graph, string direction = "TD") {
+        if (graph.RenderHint == GraphRenderHint.Compact)
+            return SerialiseCompact(graph, direction);
+
+        var sb = new StringBuilder();
+
+        // Right-angle (Manhattan) edge routing — the visual signal that says
+        // "this is a network diagram", borrowed from every serious topology
+        // tool. Diagonal/curved lines read as "flowchart".
+        //
+        // Edge-label background is made transparent so connection labels read
+        // as floating annotations rather than chunky chips that fight with
+        // the line and the nodes for attention.
+        // ELK renderer + orthogonal edge routing — Mermaid's default `dagre`
+        // layout is fine for simple flowcharts but produces awkward arrow
+        // landings on right-angle edges. ELK (Eclipse Layout Kernel) is the
+        // engine NetBox/yEd/draw.io rely on for clean topology routing.
+        //
+        // Spacing values are generous on purpose — homelab diagrams read
+        // better with air around nodes and between subnet/host clusters.
+        // - `layout: elk`              : use the Mermaid 11 ELK plugin (the
+        //                                older `flowchart.defaultRenderer`
+        //                                still works but is the legacy path).
+        // - `elk.aspectRatio: 0.5`     : ask ELK to favour tall over wide so
+        //                                a host with dozens of services
+        //                                doesn't fan out into a single row
+        //                                kilometres long.
+        // - `layered.wrapping.strategy : MULTI_EDGE
+        //                                wraps an overlong layer into several
+        //                                shorter ones — exactly what large
+        //                                logical/service diagrams need.
+        sb.AppendLine(
+            "%%{init: {'layout': 'elk', 'flowchart': {'curve': 'step', 'nodeSpacing': 60, 'rankSpacing': 80, 'padding': 20, 'subGraphTitleMargin': {'top': 12, 'bottom': 12}}, 'elk': {'algorithm': 'layered', 'aspectRatio': 0.5, 'layered.wrapping.strategy': 'MULTI_EDGE', 'layered.nodePlacement.strategy': 'BRANDES_KOEPF'}, 'themeVariables': {'edgeLabelBackground': 'transparent', 'clusterBkg': 'transparent', 'clusterBorder': '" + _groupStroke + "'}}}%%");
+        sb.Append("flowchart ").AppendLine(direction);
+
+        EmitClassDefs(sb);
+
+        Dictionary<string, string> idMap = AssignSafeIds(graph.Nodes);
+
+        // Index groups & nodes for hierarchical emission.
+        IReadOnlyList<GraphGroup> groups = graph.Groups ?? [];
+        var childGroups = groups
+            .GroupBy(g => g.ParentGroupId ?? string.Empty)
+            .ToDictionary(g => g.Key, g => g.ToList());
+        var groupsById = groups.ToDictionary(g => g.Id);
+        HashSet<string> groupedNodeIds = new(
+            groups.SelectMany(g => g.NodeIds), StringComparer.OrdinalIgnoreCase);
+
+        // Emit top-level groups (parentGroupId == null/empty) — each recursively
+        // contains its sub-groups and direct nodes.
+        if (childGroups.TryGetValue(string.Empty, out List<GraphGroup>? topLevel))
+            foreach (GraphGroup group in topLevel)
+                EmitGroup(sb, group, childGroups, groupsById, graph.Nodes, idMap, indent: 1);
+
+        // Emit any nodes that didn't fall into a group at the top level.
+        foreach (GraphNode node in graph.Nodes) {
+            if (groupedNodeIds.Contains(node.Id)) continue;
+            EmitNode(sb, node, idMap, indent: 1);
+        }
+
+        if (graph.Edges.Count > 0) sb.AppendLine();
+
+        foreach (GraphEdge edge in graph.Edges) {
+            if (!idMap.TryGetValue(edge.Source, out var src) ||
+                !idMap.TryGetValue(edge.Target, out var dst))
+                continue;
+
+            // Directional edges (runsOn, depends-on …) get an arrowhead so
+            // the relationship reads correctly. Symmetric edges (port-to-port
+            // physical connections) stay as plain lines.
+            var connector = IsDirectional(edge.Kind) ? "-->" : "---";
+
+            sb.Append("    ").Append(src);
+            if (!string.IsNullOrWhiteSpace(edge.Label))
+                sb.Append(' ').Append(connector).Append("|\"")
+                    .Append(Escape(edge.Label)).Append("\"|");
+            else
+                sb.Append(' ').Append(connector);
+            sb.Append(' ').Append(dst).AppendLine();
+        }
+
+        // Dotted edges matching the dotted node borders. Labels float on top
+        // (themeVariables.edgeLabelBackground=transparent) so the line stays
+        // visually continuous through the label region.
+        if (graph.Edges.Count > 0) {
+            sb.AppendLine();
+            sb.Append("    linkStyle default stroke:").Append(_edgeStroke)
+                .AppendLine(",stroke-width:1.25px,stroke-dasharray:4 4,fill:none");
+        }
+
+        // Apply the group styling class to every subgraph id.
+        foreach (GraphGroup group in groups) {
+            sb.Append("    class ").Append(group.Id).Append(' ').Append(_groupClass).AppendLine();
+        }
+
+        return sb.ToString();
+    }
+
+    private void EmitGroup(
+        StringBuilder sb,
+        GraphGroup group,
+        Dictionary<string, List<GraphGroup>> childGroups,
+        Dictionary<string, GraphGroup> groupsById,
+        IReadOnlyList<GraphNode> allNodes,
+        Dictionary<string, string> idMap,
+        int indent) {
+        var pad = new string(' ', indent * 4);
+        sb.Append(pad).Append("subgraph ").Append(group.Id)
+            .Append(" [\"").Append(Escape(group.Label)).Append("\"]")
+            .AppendLine();
+
+        // Nested groups first
+        if (childGroups.TryGetValue(group.Id, out List<GraphGroup>? children))
+            foreach (GraphGroup child in children)
+                EmitGroup(sb, child, childGroups, groupsById, allNodes, idMap, indent + 1);
+
+        // Nodes that belong to this group directly (not via a child group)
+        HashSet<string> nodesInChildren = new(
+            (children ?? []).SelectMany(c => CollectAllNodeIds(c, childGroups)),
+            StringComparer.OrdinalIgnoreCase);
+
+        foreach (var nodeId in group.NodeIds) {
+            if (nodesInChildren.Contains(nodeId)) continue;
+            GraphNode? node = allNodes.FirstOrDefault(n =>
+                string.Equals(n.Id, nodeId, StringComparison.OrdinalIgnoreCase));
+            if (node is null) continue;
+            EmitNode(sb, node, idMap, indent + 1);
+        }
+
+        sb.Append(pad).AppendLine("end");
+    }
+
+    private static IEnumerable<string> CollectAllNodeIds(
+        GraphGroup group,
+        Dictionary<string, List<GraphGroup>> childGroups) {
+        foreach (var id in group.NodeIds) yield return id;
+        if (!childGroups.TryGetValue(group.Id, out List<GraphGroup>? children)) yield break;
+        foreach (GraphGroup c in children)
+            foreach (var id in CollectAllNodeIds(c, childGroups))
+                yield return id;
+    }
+
+    private void EmitNode(StringBuilder sb, GraphNode node, Dictionary<string, string> idMap, int indent) {
+        var safeId = idMap[node.Id];
+        Shape shape = ResolveShape(node.Kind);
+        var label = BuildLabel(node);
+        sb.Append(new string(' ', indent * 4)).Append(safeId)
+            .Append(shape.Open).Append(label).Append(shape.Close)
+            .Append(":::").Append(_nodeClass)
+            .AppendLine();
+    }
+
+    private static string BuildLabel(GraphNode node) {
+        // Two-line label: resource name on top, optional subtitle below.
+        // Each use case decides what's most useful as a subtitle (kind for
+        // the topology view, ip[:port] for the logical view) — the serialiser
+        // is agnostic.
+        var name = Escape(node.Label);
+        if (string.IsNullOrWhiteSpace(node.Subtitle)) return name;
+        return $"{name}<br/>{Escape(node.Subtitle!)}";
+    }
+
+    private static void EmitClassDefs(StringBuilder sb) {
+        // Dotted node borders + dotted edges (via linkStyle below) keep the
+        // whole diagram visually quiet — solid borders feel heavier than the
+        // information they convey.
+        sb.Append("    classDef ").Append(_nodeClass)
+            .Append(" fill:").Append(_nodeFill)
+            .Append(",stroke:").Append(_nodeStroke)
+            .Append(",color:").Append(_nodeText)
+            .Append(",stroke-width:1px,stroke-dasharray:3 3")
+            .AppendLine();
+
+        // Group containers: dotted outline, no fill, muted title. The cluster
+        // background/border theme variables in the init directive cover the
+        // built-in Mermaid styling; this class adds the dashed outline.
+        sb.Append("    classDef ").Append(_groupClass)
+            .Append(" fill:none,stroke:").Append(_groupStroke)
+            .Append(",color:").Append(_groupText)
+            .Append(",stroke-width:1px,stroke-dasharray:3 3")
+            .AppendLine();
+        sb.AppendLine();
+    }
+
+    private static Dictionary<string, string> AssignSafeIds(IReadOnlyList<GraphNode> nodes) {
+        // Mermaid node IDs must be a small alphabet (letters, digits, underscore).
+        // Map resource names → deterministic safe IDs, suffixing on collision.
+        var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
+        var taken = new HashSet<string>(StringComparer.Ordinal);
+
+        foreach (GraphNode node in nodes) {
+            var baseId = "n_" + Slug(node.Id);
+            var candidate = baseId;
+            var counter = 2;
+            while (!taken.Add(candidate)) candidate = $"{baseId}_{counter++}";
+            result[node.Id] = candidate;
+        }
+
+        return result;
+    }
+
+    private static string Slug(string value) {
+        var sb = new StringBuilder(value.Length);
+        foreach (var c in value)
+            sb.Append(char.IsLetterOrDigit(c) ? char.ToLowerInvariant(c) : '_');
+
+        return sb.Length == 0 ? "node" : sb.ToString();
+    }
+
+    private static Shape ResolveShape(string kind) =>
+        _shapes.TryGetValue(kind, out Shape shape) ? shape : _fallbackShape;
+
+    private static string Escape(string value) =>
+        value.Replace("\\", "\\\\").Replace("\"", "\\\"");
+
+    private static readonly HashSet<string> _directionalEdgeKinds = new(StringComparer.OrdinalIgnoreCase) {
+        "runsOn",
+        "dependsOn"
+    };
+
+    private static bool IsDirectional(string kind) =>
+        _directionalEdgeKinds.Contains(kind);
+
+    private readonly record struct Shape(string Open, string Close);
+
+    // ---------------------------------------------------------------------
+    // Compact mode (logical view): each system becomes a single "host card"
+    // whose label is an HTML table of its services. No edges are drawn —
+    // subgraph containment carries the runs-on relationship. Sibling cards
+    // are chained vertically via invisible ~~~ links so ELK doesn't fan
+    // them out into a kilometre-wide row, and single-row hosts are packed
+    // into invisible row subgraphs of N to use the horizontal space.
+    // ---------------------------------------------------------------------
+    private string SerialiseCompact(Graph graph, string direction) {
+        var sb = new StringBuilder();
+
+        // htmlLabels + securityLevel: 'loose' let us put raw HTML inside the
+        // node labels. aspectRatio is set above 0.5 because compact mode
+        // already wraps long sibling lists itself via the small-row packing.
+        sb.AppendLine(
+            "%%{init: {'layout': 'elk', 'flowchart': {'curve': 'step', 'nodeSpacing': 10, 'rankSpacing': 10, 'padding': 0, 'htmlLabels': true, 'subGraphTitleMargin': {'top': 0, 'bottom': 0}, 'titleTopMargin': 0}, 'securityLevel': 'loose', 'elk': {'algorithm': 'layered', 'padding': '[top=0,bottom=4,left=6,right=6]', 'spacing.nodeNode': 8, 'spacing.nodeNodeBetweenLayers': 8, 'spacing.componentComponent': 6, 'layered.spacing.nodeNodeBetweenLayers': 8, 'nodeLabels.placement': '[H_CENTER, V_TOP, INSIDE]'}, 'themeVariables': {'edgeLabelBackground': 'transparent', 'clusterBkg': 'transparent', 'clusterBorder': '" + _groupStroke + "'}}}%%");
+        sb.Append("flowchart ").AppendLine(direction);
+
+        EmitClassDefs(sb);
+        sb.Append("    classDef ").Append(_smallRowClass)
+            .AppendLine(" fill:none,stroke:none,color:transparent");
+        sb.AppendLine();
+
+        Dictionary<string, string> idMap = AssignSafeIds(graph.Nodes);
+
+        IReadOnlyList<GraphGroup> groups = graph.Groups ?? [];
+        var childGroups = groups
+            .GroupBy(g => g.ParentGroupId ?? string.Empty)
+            .ToDictionary(g => g.Key, g => g.ToList());
+        HashSet<string> groupedNodeIds = new(
+            groups.SelectMany(g => g.NodeIds), StringComparer.OrdinalIgnoreCase);
+
+        // Invisible chains and packed-row ids are collected during traversal
+        // and emitted in a block at the end.
+        var chains = new List<IReadOnlyList<string>>();
+        var smallRowIds = new List<string>();
+
+        void Emit(GraphGroup group, int indent) {
+            var pad = new string(' ', indent * 4);
+            sb.Append(pad).Append("subgraph ").Append(group.Id)
+                .Append(" [\"").Append(Escape(group.Label)).Append("\"]").AppendLine();
+
+            List<GraphGroup> subChildren =
+                childGroups.TryGetValue(group.Id, out List<GraphGroup>? cs) ? cs : new();
+            foreach (GraphGroup child in subChildren) Emit(child, indent + 1);
+            if (subChildren.Count > 1)
+                chains.Add(subChildren.Select(c => c.Id).ToList());
+
+            HashSet<string> nodesInChildren = new(
+                subChildren.SelectMany(c => CollectAllNodeIds(c, childGroups)),
+                StringComparer.OrdinalIgnoreCase);
+
+            // Partition the group's direct nodes into "big" cards (host with
+            // multiple service rows) and "small" cards (no rows or one row).
+            // Bigs get a dedicated row each; smalls pack horizontally.
+            var bigs = new List<GraphNode>();
+            var smalls = new List<GraphNode>();
+            foreach (var nodeId in group.NodeIds) {
+                if (nodesInChildren.Contains(nodeId)) continue;
+                GraphNode? node = graph.Nodes.FirstOrDefault(n =>
+                    string.Equals(n.Id, nodeId, StringComparison.OrdinalIgnoreCase));
+                if (node is null) continue;
+                if ((node.Rows?.Count ?? 0) > 1) bigs.Add(node);
+                else smalls.Add(node);
+            }
+
+            var verticalChain = new List<string>();
+
+            foreach (GraphNode b in bigs) {
+                EmitCompactNode(sb, b, idMap, indent + 1);
+                verticalChain.Add(idMap[b.Id]);
+            }
+
+            for (int i = 0, rowIdx = 0; i < smalls.Count; i += _compactSmallRowSize, rowIdx++) {
+                var slice = smalls.Skip(i).Take(_compactSmallRowSize).ToList();
+                // Single small host doesn't need an invisible row wrapper —
+                // wrapping adds another nested subgraph (with its own
+                // padding/title overhead) for no layout benefit.
+                if (slice.Count == 1) {
+                    EmitCompactNode(sb, slice[0], idMap, indent + 1);
+                    verticalChain.Add(idMap[slice[0].Id]);
+                    continue;
+                }
+                var rowId = group.Id + "__srow" + rowIdx;
+                smallRowIds.Add(rowId);
+                verticalChain.Add(rowId);
+                sb.Append(pad).Append("    subgraph ").Append(rowId).AppendLine(" [\" \"]");
+                sb.Append(pad).Append("        direction LR").AppendLine();
+                foreach (GraphNode s in slice)
+                    EmitCompactNode(sb, s, idMap, indent + 2);
+                sb.Append(pad).AppendLine("    end");
+                sb.Append(pad).Append("    ");
+                sb.AppendJoin(" ~~~ ", slice.Select(s => idMap[s.Id]));
+                sb.AppendLine();
+            }
+
+            if (verticalChain.Count > 1) chains.Add(verticalChain);
+
+            sb.Append(pad).AppendLine("end");
+        }
+
+        if (childGroups.TryGetValue(string.Empty, out List<GraphGroup>? topLevel)) {
+            foreach (GraphGroup g in topLevel) Emit(g, 1);
+            if (topLevel.Count > 1)
+                chains.Add(topLevel.Select(g => g.Id).ToList());
+        }
+
+        // Ungrouped nodes (uncommon in compact mode but render them sanely).
+        foreach (GraphNode node in graph.Nodes) {
+            if (groupedNodeIds.Contains(node.Id)) continue;
+            EmitCompactNode(sb, node, idMap, 1);
+        }
+
+        // Invisible vertical chains last — these are what tell ELK to stack
+        // siblings vertically instead of flowing into one long row.
+        if (chains.Count > 0) sb.AppendLine();
+        foreach (IReadOnlyList<string> chain in chains) {
+            if (chain.Count < 2) continue;
+            sb.Append("    ");
+            sb.AppendJoin(" ~~~ ", chain);
+            sb.AppendLine();
+        }
+
+        sb.AppendLine();
+        foreach (GraphGroup group in groups)
+            sb.Append("    class ").Append(group.Id).Append(' ').Append(_groupClass).AppendLine();
+        foreach (var rowId in smallRowIds)
+            sb.Append("    class ").Append(rowId).Append(' ').Append(_smallRowClass).AppendLine();
+
+        return sb.ToString();
+    }
+
+    private void EmitCompactNode(StringBuilder sb, GraphNode node, Dictionary<string, string> idMap, int indent) {
+        var safeId = idMap[node.Id];
+        Shape shape = ResolveShape(node.Kind);
+        var label = BuildCompactLabel(node);
+        sb.Append(new string(' ', indent * 4)).Append(safeId)
+            .Append(shape.Open).Append(label).Append(shape.Close)
+            .Append(":::").Append(_nodeClass)
+            .AppendLine();
+    }
+
+    private static string BuildCompactLabel(GraphNode node) {
+        var sb = new StringBuilder();
+        sb.Append("<div style='text-align:left;font-family:system-ui;padding:4px 6px'>");
+        sb.Append("<div style='font-weight:600;font-size:14px'>");
+        sb.Append(EscapeHtml(node.Label));
+        if (!string.IsNullOrWhiteSpace(node.Subtitle)) {
+            sb.Append(" - <span style='color:#9ca3af'>");
+            sb.Append(EscapeHtml(node.Subtitle!));
+            sb.Append("</span>");
+        }
+        sb.Append("</div>");
+
+        if (node.Rows is { Count: > 0 }) {
+            sb.Append("<hr style='border:none;border-top:1px dashed #52525b;margin:6px 0'>");
+            sb.Append("<table style='border-collapse:collapse;font-size:11px'>");
+            for (var i = 0; i < node.Rows.Count; i += _compactTableColumns) {
+                sb.Append("<tr>");
+                for (var c = 0; c < _compactTableColumns; c++) {
+                    var idx = i + c;
+                    if (idx >= node.Rows.Count) { sb.Append("<td></td>"); continue; }
+                    GraphNodeRow row = node.Rows[idx];
+                    sb.Append("<td style='padding:2px 10px 2px 0;white-space:nowrap'>");
+                    sb.Append("<span style='color:#e5e7eb'>").Append(EscapeHtml(row.Name)).Append("</span>");
+                    if (!string.IsNullOrEmpty(row.Detail))
+                        sb.Append("<span style='color:#71717a'>").Append(EscapeHtml(row.Detail!)).Append("</span>");
+                    sb.Append("</td>");
+                }
+                sb.Append("</tr>");
+            }
+            sb.Append("</table>");
+        }
+        sb.Append("</div>");
+        // Mermaid label is wrapped in "...", so any " in our HTML must be
+        // entity-encoded. We avoid literal " in inline styles by using
+        // single quotes; this last pass catches anything still embedded.
+        return sb.ToString().Replace("\"", "&quot;");
+    }
+
+    private static string EscapeHtml(string s) =>
+        s.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;");
+}

+ 188 - 0
RackPeek.Domain/Graph/UseCases/BuildLogicalGraphUseCase.cs

@@ -0,0 +1,188 @@
+using RackPeek.Domain.Persistence;
+using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.Hardware;
+using RackPeek.Domain.Resources.Services;
+using RackPeek.Domain.Resources.Services.Networking;
+using RackPeek.Domain.Resources.SystemResources;
+
+namespace RackPeek.Domain.Graph.UseCases;
+
+/// <summary>
+///     Logical / service-oriented view. Each system (hypervisor, VM, LXC,
+///     container) becomes a single "host card" whose body lists every
+///     service running on it. Cards are grouped subnet → hardware. No edges
+///     are emitted — containment alone conveys "runs on", and the
+///     serialiser stacks siblings vertically via invisible links.
+/// </summary>
+public class BuildLogicalGraphUseCase(IResourceCollection repo) : IUseCase {
+    private const int _defaultPrefix = 24;
+
+    public async Task<Graph> ExecuteAsync() {
+        IReadOnlyList<Service> services = await repo.GetAllOfTypeAsync<Service>();
+        IReadOnlyList<SystemResource> systems = await repo.GetAllOfTypeAsync<SystemResource>();
+        IReadOnlyList<Hardware> hardware = repo.HardwareResources;
+
+        var byName = new Dictionary<string, Resource>(StringComparer.OrdinalIgnoreCase);
+        foreach (Hardware hw in hardware) byName[hw.Name] = hw;
+        foreach (SystemResource s in systems) byName[s.Name] = s;
+        foreach (Service svc in services) byName[svc.Name] = svc;
+
+        // Group services by the system they ultimately run on. We resolve
+        // the immediate runsOn first — that's the host the service was
+        // declared against. Services whose immediate runsOn isn't a known
+        // system (e.g. it points at hardware or is missing) are dropped from
+        // the compact view since they have no host card to live inside.
+        var servicesByHost = new Dictionary<string, List<Service>>(StringComparer.OrdinalIgnoreCase);
+        foreach (Service service in services) {
+            var parent = service.RunsOn.FirstOrDefault();
+            if (parent is null) continue;
+            if (!byName.TryGetValue(parent, out Resource? parentResource)) continue;
+            if (parentResource is not SystemResource) continue;
+            if (!servicesByHost.TryGetValue(parent, out List<Service>? list))
+                servicesByHost[parent] = list = new List<Service>();
+            list.Add(service);
+        }
+
+        // Each system becomes a host card. Hosts without services still
+        // appear as a labelled card (e.g. a hypervisor that only contains
+        // VMs has no services running directly on it, but is still a
+        // meaningful logical entity).
+        var hostEntries = new List<HostEntry>();
+        foreach (SystemResource sys in systems) {
+            var ip = FindIp(sys, byName);
+            var subnet = SubnetCidr(ip, _defaultPrefix);
+            if (subnet is null) continue;
+            Hardware? parentHw = FindParentHardware(sys, byName);
+            servicesByHost.TryGetValue(sys.Name, out List<Service>? hostServices);
+            var rows = (hostServices ?? new List<Service>())
+                .OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase)
+                .Select(s => new GraphNodeRow(s.Name, ServiceDetail(s)))
+                .ToList();
+            hostEntries.Add(new HostEntry(sys, subnet, parentHw?.Name, ip, rows));
+        }
+
+        var nodes = hostEntries
+            .OrderBy(e => e.Subnet, StringComparer.Ordinal)
+            .ThenBy(e => e.HardwareName ?? string.Empty, StringComparer.OrdinalIgnoreCase)
+            .ThenByDescending(e => e.Rows.Count) // big cards first within a hardware bucket
+            .ThenBy(e => e.System.Name, StringComparer.OrdinalIgnoreCase)
+            .Select(e => new GraphNode(
+                e.System.Name,
+                e.System.Name,
+                NodeKind(e.System),
+                e.Ip,
+                Rows: e.Rows.Count > 0 ? e.Rows : null))
+            .ToList();
+
+        List<GraphGroup> groups = BuildGroups(hostEntries);
+
+        return new Graph(nodes, [], groups, GraphRenderHint.Compact);
+    }
+
+    private static List<GraphGroup> BuildGroups(IReadOnlyList<HostEntry> entries) {
+        var groups = new List<GraphGroup>();
+
+        IOrderedEnumerable<IGrouping<string, HostEntry>> bySubnet = entries
+            .GroupBy(e => e.Subnet, StringComparer.Ordinal)
+            .OrderBy(g => g.Key, StringComparer.Ordinal);
+
+        foreach (IGrouping<string, HostEntry> subnetGroup in bySubnet) {
+            var subnetId = "g_" + Slug(subnetGroup.Key);
+
+            var directNodes = new List<string>();
+            IOrderedEnumerable<IGrouping<string?, HostEntry>> byHardware = subnetGroup
+                .GroupBy(e => e.HardwareName)
+                .OrderBy(g => g.Key ?? string.Empty, StringComparer.OrdinalIgnoreCase);
+
+            foreach (IGrouping<string?, HostEntry> hwGroup in byHardware) {
+                if (hwGroup.Key is null) {
+                    directNodes.AddRange(hwGroup.Select(e => e.System.Name));
+                    continue;
+                }
+
+                var hwGroupId = subnetId + "__" + Slug(hwGroup.Key);
+                groups.Add(new GraphGroup(
+                    hwGroupId,
+                    hwGroup.Key,
+                    hwGroup.Select(e => e.System.Name).ToList(),
+                    subnetId));
+            }
+
+            groups.Add(new GraphGroup(subnetId, subnetGroup.Key, directNodes, null));
+        }
+
+        return groups;
+    }
+
+    private static string NodeKind(SystemResource sys) {
+        if (string.IsNullOrWhiteSpace(sys.Type)) return "System";
+        var t = sys.Type.Trim().ToLowerInvariant();
+        return t switch {
+            "hypervisor" => "Hypervisor",
+            "vm" => "Vm",
+            "container" => "Container",
+            _ => "System"
+        };
+    }
+
+    private static string? ServiceDetail(Service service) {
+        var port = service.Network?.Port;
+        return port.HasValue ? ":" + port.Value : null;
+    }
+
+    private static string? FindIp(Resource resource, Dictionary<string, Resource> byName) {
+        var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
+        Resource? current = resource;
+        while (current is not null && visited.Add(current.Name)) {
+            switch (current) {
+                case Service { Network.Ip: { Length: > 0 } svcIp }:
+                    return svcIp;
+                case SystemResource { Ip: { Length: > 0 } sysIp }:
+                    return sysIp;
+            }
+
+            var parent = current.RunsOn.FirstOrDefault();
+            if (parent is null) return null;
+            current = byName.GetValueOrDefault(parent);
+        }
+
+        return null;
+    }
+
+    private static Hardware? FindParentHardware(Resource resource, Dictionary<string, Resource> byName) {
+        var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
+        Resource? current = resource;
+        while (current is not null && visited.Add(current.Name)) {
+            if (current is Hardware hw) return hw;
+            var parent = current.RunsOn.FirstOrDefault();
+            if (parent is null) return null;
+            current = byName.GetValueOrDefault(parent);
+        }
+
+        return null;
+    }
+
+    private static string? SubnetCidr(string? ip, int prefix) {
+        if (string.IsNullOrWhiteSpace(ip)) return null;
+        try {
+            var u = IpHelper.ToUInt32(ip);
+            var mask = IpHelper.MaskFromPrefix(prefix);
+            return $"{IpHelper.ToIp(u & mask)}/{prefix}";
+        }
+        catch {
+            return null;
+        }
+    }
+
+    private static string Slug(string value) {
+        var chars = value.Select(c => char.IsLetterOrDigit(c) ? char.ToLowerInvariant(c) : '_').ToArray();
+        return new string(chars);
+    }
+
+    private readonly record struct HostEntry(
+        SystemResource System,
+        string Subnet,
+        string? HardwareName,
+        string? Ip,
+        IReadOnlyList<GraphNodeRow> Rows);
+}

+ 79 - 0
RackPeek.Domain/Graph/UseCases/BuildPhysicalTopologyUseCase.cs

@@ -0,0 +1,79 @@
+using RackPeek.Domain.Persistence;
+using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.Connections;
+using RackPeek.Domain.Resources.Hardware;
+using RackPeek.Domain.Resources.Servers;
+using RackPeek.Domain.Resources.SubResources;
+
+namespace RackPeek.Domain.Graph.UseCases;
+
+public class BuildPhysicalTopologyUseCase(IResourceCollection repo) : IUseCase {
+    public async Task<Graph> ExecuteAsync() {
+        IReadOnlyList<Hardware> hardware = repo.HardwareResources;
+        IReadOnlyList<Connection> connections = await repo.GetConnectionsAsync();
+
+        var nodes = hardware
+            .OrderBy(h => h.Kind, StringComparer.OrdinalIgnoreCase)
+            .ThenBy(h => h.Name, StringComparer.OrdinalIgnoreCase)
+            .Select(BuildNode)
+            .ToList();
+
+        var hardwareByName = hardware.ToDictionary(
+            h => h.Name,
+            StringComparer.OrdinalIgnoreCase);
+
+        var edges = connections
+            .Where(c => hardwareByName.ContainsKey(c.A.Resource) && hardwareByName.ContainsKey(c.B.Resource))
+            .Select(c => BuildEdge(c, hardwareByName))
+            .ToList();
+
+        return new Graph(nodes, edges);
+    }
+
+    private static GraphNode BuildNode(Hardware resource) {
+        var data = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
+        if (resource.Tags.Length > 0) data["tags"] = string.Join(",", resource.Tags);
+
+        return new GraphNode(
+            Id: resource.Name,
+            Label: resource.Name,
+            Kind: resource.Kind,
+            Subtitle: resource.Kind.ToLowerInvariant(),
+            Data: data);
+    }
+
+    private static GraphEdge BuildEdge(Connection c, Dictionary<string, Hardware> hardwareByName) {
+        var label = BuildEdgeLabel(c, hardwareByName);
+        return new GraphEdge(
+            Source: c.A.Resource,
+            Target: c.B.Resource,
+            Label: label,
+            Kind: "connection");
+    }
+
+    private static string? BuildEdgeLabel(Connection c, Dictionary<string, Hardware> hardwareByName) {
+        if (!string.IsNullOrWhiteSpace(c.Label))
+            return c.Label;
+
+        var a = PortLabel(c.A, hardwareByName);
+        var b = PortLabel(c.B, hardwareByName);
+
+        if (a is null && b is null) return null;
+        return $"{a ?? "?"} ↔ {b ?? "?"}";
+    }
+
+    private static string? PortLabel(PortReference reference, Dictionary<string, Hardware> hardwareByName) {
+        if (!hardwareByName.TryGetValue(reference.Resource, out Hardware? hardware))
+            return null;
+
+        if (hardware is not IPortResource portResource || portResource.Ports is null)
+            return null;
+
+        if (reference.PortGroup < 0 || reference.PortGroup >= portResource.Ports.Count)
+            return null;
+
+        Port group = portResource.Ports[reference.PortGroup];
+        var type = string.IsNullOrWhiteSpace(group.Type) ? "port" : group.Type;
+        return $"{type}{reference.PortIndex}";
+    }
+}

+ 4 - 7
RackPeek.Domain/Helpers/ConflictException.cs

@@ -1,14 +1,11 @@
 namespace RackPeek.Domain.Helpers;
 
-public sealed class ConflictException : Exception
-{
+public sealed class ConflictException : Exception {
     public ConflictException(string message)
-        : base(message)
-    {
+        : base(message) {
     }
 
     public ConflictException(string message, Exception innerException)
-        : base(message, innerException)
-    {
+        : base(message, innerException) {
     }
-}
+}

+ 3 - 5
RackPeek.Domain/Helpers/DeepClone.cs

@@ -2,11 +2,9 @@ using System.Text.Json;
 
 namespace RackPeek.Domain.Helpers;
 
-public static class Clone
-{
-    public static T DeepClone<T>(T obj)
-    {
+public static class Clone {
+    public static T DeepClone<T>(T obj) {
         var json = JsonSerializer.Serialize(obj);
         return JsonSerializer.Deserialize<T>(json)!;
     }
-}
+}

+ 21 - 52
RackPeek.Domain/Helpers/Normalize.cs

@@ -1,54 +1,23 @@
 namespace RackPeek.Domain.Helpers;
 
-public static class Normalize
-{
-    public static string DriveType(string value)
-    {
-        return value.Trim().ToLowerInvariant();
-    }
-
-    public static string NicType(string value)
-    {
-        return value.Trim().ToLowerInvariant();
-    }
-
-    public static string SystemType(string value)
-    {
-        return value.Trim().ToLowerInvariant();
-    }
-
-    public static string SystemName(string name)
-    {
-        return name.Trim();
-    }
-
-    public static string ServiceName(string name)
-    {
-        return name.Trim();
-    }
-
-    public static string HardwareName(string name)
-    {
-        return name.Trim();
-    }
-
-    public static string ResourceName(string name)
-    {
-        return name.Trim();
-    }
-
-    public static string Tag(string name)
-    {
-        return name.Trim();
-    }
-
-    public static string LabelKey(string key)
-    {
-        return key.Trim();
-    }
-
-    public static string LabelValue(string value)
-    {
-        return value.Trim();
-    }
-}
+public static class Normalize {
+    public static string DriveType(string value) => value.Trim().ToLowerInvariant();
+
+    public static string NicType(string value) => value.Trim().ToLowerInvariant();
+
+    public static string SystemType(string value) => value.Trim().ToLowerInvariant();
+
+    public static string SystemName(string name) => name.Trim();
+
+    public static string ServiceName(string name) => name.Trim();
+
+    public static string HardwareName(string name) => name.Trim();
+
+    public static string ResourceName(string name) => name.Trim();
+
+    public static string Tag(string name) => name.Trim();
+
+    public static string LabelKey(string key) => key.Trim();
+
+    public static string LabelValue(string value) => value.Trim();
+}

+ 4 - 7
RackPeek.Domain/Helpers/NotFoundException.cs

@@ -1,14 +1,11 @@
 namespace RackPeek.Domain.Helpers;
 
-public sealed class NotFoundException : Exception
-{
+public sealed class NotFoundException : Exception {
     public NotFoundException(string message)
-        : base(message)
-    {
+        : base(message) {
     }
 
     public NotFoundException(string message, Exception innerException)
-        : base(message, innerException)
-    {
+        : base(message, innerException) {
     }
-}
+}

+ 18 - 35
RackPeek.Domain/Helpers/ThrowIfInvalid.cs

@@ -4,29 +4,24 @@ using RackPeek.Domain.Resources.SystemResources;
 
 namespace RackPeek.Domain.Helpers;
 
-public static class ThrowIfInvalid
-{
-    public static void ResourceName(string name)
-    {
+public static class ThrowIfInvalid {
+    public static void ResourceName(string name) {
         if (string.IsNullOrWhiteSpace(name)) throw new ValidationException("Name is required.");
 
         if (name.Length > 50) throw new ValidationException("Name is too long.");
     }
 
-    public static void LabelKey(string key)
-    {
+    public static void LabelKey(string key) {
         if (string.IsNullOrWhiteSpace(key)) throw new ValidationException("Label key is required.");
         if (key.Length > 50) throw new ValidationException("Label key is too long.");
     }
 
-    public static void LabelValue(string value)
-    {
+    public static void LabelValue(string value) {
         if (string.IsNullOrWhiteSpace(value)) throw new ValidationException("Label value is required.");
         if (value.Length > 200) throw new ValidationException("Label value is too long.");
     }
 
-    public static void AccessPointModelName(string name)
-    {
+    public static void AccessPointModelName(string name) {
         if (string.IsNullOrWhiteSpace(name))
             throw new ValidationException("Model name is required.");
 
@@ -34,15 +29,13 @@ public static class ThrowIfInvalid
             throw new ValidationException("Model name is too long.");
     }
 
-    public static void RamGb(double? value)
-    {
+    public static void RamGb(double? value) {
         if (value is null) throw new ValidationException("RAM value must be specified.");
 
         if (value < 0) throw new ValidationException("RAM value must be a non negative number of gigabytes.");
     }
 
-    public static void SystemType(string systemType)
-    {
+    public static void SystemType(string systemType) {
         if (string.IsNullOrWhiteSpace(systemType)) throw new ValidationException("System type is required.");
 
         var normalized = systemType.Trim().ToLowerInvariant();
@@ -58,8 +51,7 @@ public static class ThrowIfInvalid
         throw new ValidationException(message);
     }
 
-    private static IEnumerable<string> GetSystemTypeSuggestions(string input)
-    {
+    private static IEnumerable<string> GetSystemTypeSuggestions(string input) {
         return SystemResource.ValidSystemTypes.Select(type => new { Type = type, Score = SimilarityScore(input, type) })
             .Where(x => x.Score >= 0.5)
             .OrderByDescending(x => x.Score)
@@ -69,8 +61,7 @@ public static class ThrowIfInvalid
 
     #region Nics
 
-    public static void NicType(string nicType)
-    {
+    public static void NicType(string nicType) {
         if (string.IsNullOrWhiteSpace(nicType)) throw new ValidationException("NIC type is required.");
 
         var normalized = nicType.Trim().ToLowerInvariant();
@@ -86,8 +77,7 @@ public static class ThrowIfInvalid
         throw new ValidationException(message);
     }
 
-    private static IEnumerable<string> GetNicTypeSuggestions(string input)
-    {
+    private static IEnumerable<string> GetNicTypeSuggestions(string input) {
         return Nic.ValidNicTypes.Select(type => new { Type = type, Score = SimilarityScore(input, type) })
             .Where(x => x.Score >= 0.5)
             .OrderByDescending(x => x.Score)
@@ -95,8 +85,7 @@ public static class ThrowIfInvalid
             .Select(x => x.Type);
     }
 
-    private static double SimilarityScore(string a, string b)
-    {
+    private static double SimilarityScore(string a, string b) {
         if (a == b) return 1.0;
 
         if (b.StartsWith(a) || a.StartsWith(b)) return 0.9;
@@ -105,21 +94,18 @@ public static class ThrowIfInvalid
         return (double)commonChars / Math.Max(a.Length, b.Length);
     }
 
-    public static void NicSpeed(double speed)
-    {
+    public static void NicSpeed(double speed) {
         if (speed < 0) throw new ValidationException("NIC speed must be a non negative number of gigabits per second.");
     }
 
-    public static void NetworkSpeed(double speed)
-    {
+    public static void NetworkSpeed(double speed) {
         if (speed < 0)
             throw new ValidationException(
                 "Network speed must be a non negative number of gigabits per second.");
     }
 
 
-    public static void NicPorts(int ports)
-    {
+    public static void NicPorts(int ports) {
         if (ports < 0) throw new ValidationException("NIC port count must be a non negative integer.");
     }
 
@@ -127,8 +113,7 @@ public static class ThrowIfInvalid
 
     #region Drives
 
-    public static void DriveType(string driveType)
-    {
+    public static void DriveType(string driveType) {
         if (string.IsNullOrWhiteSpace(driveType)) throw new ValidationException("Drive type is required.");
 
         var normalized = driveType.Trim().ToLowerInvariant();
@@ -144,8 +129,7 @@ public static class ThrowIfInvalid
         throw new ValidationException(message);
     }
 
-    private static IEnumerable<string> GetDriveTypeSuggestions(string input)
-    {
+    private static IEnumerable<string> GetDriveTypeSuggestions(string input) {
         return Drive.ValidDriveTypes.Select(type => new { Type = type, Score = SimilarityScore(input, type) })
             .Where(x => x.Score >= 0.5)
             .OrderByDescending(x => x.Score)
@@ -153,10 +137,9 @@ public static class ThrowIfInvalid
             .Select(x => x.Type);
     }
 
-    public static void DriveSize(int size)
-    {
+    public static void DriveSize(int size) {
         if (size < 0) throw new ValidationException("Drive size value must be a non negative number of gigabytes.");
     }
 
     #endregion
-}
+}

+ 2 - 3
RackPeek.Domain/IConsoleEmulator.cs

@@ -1,6 +1,5 @@
 namespace RackPeek.Domain;
 
-public interface IConsoleEmulator
-{
+public interface IConsoleEmulator {
     public Task<string> Execute(string input);
-}
+}

+ 2 - 3
RackPeek.Domain/IUseCase.cs

@@ -1,5 +1,4 @@
 namespace RackPeek.Domain;
 
-public interface IUseCase
-{
-}
+public interface IUseCase {
+}

+ 39 - 49
RackPeek.Domain/Persistence/HardwareRepository.cs

@@ -1,78 +1,68 @@
 using RackPeek.Domain.Resources.Hardware;
+using RackPeek.Domain.Resources.Services;
+using RackPeek.Domain.Resources.SystemResources;
 
 namespace RackPeek.Domain.Persistence;
 
-public class YamlHardwareRepository(IResourceCollection resources) : IHardwareRepository
-{
-    public Task<int> GetCountAsync()
-    {
-        return Task.FromResult(resources.HardwareResources.Count);
-    }
+public class YamlHardwareRepository(IResourceCollection resources) : IHardwareRepository {
+    public Task<int> GetCountAsync() => Task.FromResult(resources.HardwareResources.Count);
 
-    public Task<Dictionary<string, int>> GetKindCountAsync()
-    {
+    public Task<Dictionary<string, int>> GetKindCountAsync() {
         return Task.FromResult(resources.HardwareResources
             .GroupBy(h => h.Kind)
             .ToDictionary(k => k.Key, v => v.Count()));
     }
 
-    public Task<List<HardwareTree>> GetTreeAsync()
-    {
+    public Task<List<HardwareTree>> GetTreeAsync() {
         var hardwareTree = new List<HardwareTree>();
-        
-            var systemGroups = resources.SystemResources
-                .Where(s => s.RunsOn.Count != 0)
-                .SelectMany(
-                    s => s.RunsOn,
-                    (system, hardwareName) => new
-                    {
-                        Hardware = hardwareName.Trim(),
-                        System = system
-                    })
-                .GroupBy(x => x.Hardware, StringComparer.OrdinalIgnoreCase)
-                .ToDictionary(
-                    g => g.Key,
-                    g => g.Select(x => x.System).ToList(),
-                    StringComparer.OrdinalIgnoreCase);
 
-            var serviceGroups = resources.ServiceResources
-                .Where(s => s.RunsOn.Count != 0)
-                .SelectMany(
-                    s => s.RunsOn,
-                    (service, systemName) => new
-                    {
-                        System = systemName.Trim(),
-                        Service = service
-                    })
-                .GroupBy(x => x.System, StringComparer.OrdinalIgnoreCase)
-                .ToDictionary(
-                    g => g.Key,
-                    g => g.Select(x => x.Service).ToList(),
-                    StringComparer.OrdinalIgnoreCase);
+        var systemGroups = resources.SystemResources
+            .Where(s => s.RunsOn.Count != 0)
+            .SelectMany(
+                s => s.RunsOn,
+                (system, hardwareName) => new {
+                    Hardware = hardwareName.Trim(),
+                    System = system
+                })
+            .GroupBy(x => x.Hardware, StringComparer.OrdinalIgnoreCase)
+            .ToDictionary(
+                g => g.Key,
+                g => g.Select(x => x.System).ToList(),
+                StringComparer.OrdinalIgnoreCase);
+
+        var serviceGroups = resources.ServiceResources
+            .Where(s => s.RunsOn.Count != 0)
+            .SelectMany(
+                s => s.RunsOn,
+                (service, systemName) => new {
+                    System = systemName.Trim(),
+                    Service = service
+                })
+            .GroupBy(x => x.System, StringComparer.OrdinalIgnoreCase)
+            .ToDictionary(
+                g => g.Key,
+                g => g.Select(x => x.Service).ToList(),
+                StringComparer.OrdinalIgnoreCase);
 
-        foreach (var hardware in resources.HardwareResources)
-        {
+        foreach (Hardware hardware in resources.HardwareResources) {
             var systems = new List<SystemTree>();
             var hardwareKey = hardware.Name.Trim();
 
-            if (systemGroups.TryGetValue(hardwareKey, out var systemResources))
-                foreach (var system in systemResources)
-                {
+            if (systemGroups.TryGetValue(hardwareKey, out List<SystemResource>? systemResources))
+                foreach (SystemResource system in systemResources) {
                     var services = new List<string>();
                     var systemKey = system.Name.Trim();
 
-                    if (serviceGroups.TryGetValue(systemKey, out var serviceResources))
+                    if (serviceGroups.TryGetValue(systemKey, out List<Service>? serviceResources))
                         services.AddRange(serviceResources.Select(s => s.Name));
 
-                    systems.Add(new SystemTree
-                    {
+                    systems.Add(new SystemTree {
                         SystemName = system.Name,
                         Services = services
                     });
                 }
 
-            hardwareTree.Add(new HardwareTree
-            {
+            hardwareTree.Add(new HardwareTree {
                 Kind = hardware.Kind,
                 HardwareName = hardware.Name,
                 Systems = systems

+ 12 - 6
RackPeek.Domain/Persistence/IResourceCollection.cs

@@ -1,12 +1,12 @@
 using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.Connections;
 using RackPeek.Domain.Resources.Hardware;
 using RackPeek.Domain.Resources.Services;
 using RackPeek.Domain.Resources.SystemResources;
 
 namespace RackPeek.Domain.Persistence;
 
-public interface IResourceCollection
-{
+public interface IResourceCollection {
     IReadOnlyList<Hardware> HardwareResources { get; }
     IReadOnlyList<SystemResource> SystemResources { get; }
     IReadOnlyList<Service> ServiceResources { get; }
@@ -19,17 +19,17 @@ public interface IResourceCollection
 
     Resource? GetByName(string name);
     Task<bool> Exists(string name);
-    
+
     Task<string?> GetKind(string? name);
 
 
     Task LoadAsync(); // required for WASM startup
     Task<IReadOnlyList<Resource>> GetByTagAsync(string name);
     public Task<Dictionary<string, int>> GetTagsAsync();
-    
+
     Task<IReadOnlyList<(Resource, string)>> GetByLabelAsync(string name);
     public Task<Dictionary<string, int>> GetLabelsAsync();
-    
+
     Task<IReadOnlyList<(Resource, string)>> GetResourceIpsAsync();
 
     Task<IReadOnlyList<T>> GetAllOfTypeAsync<T>();
@@ -38,4 +38,10 @@ public interface IResourceCollection
     Task Merge(string incomingYaml, MergeMode mode);
 
 
-}
+    Task AddConnectionAsync(Connection connection);
+    Task RemoveConnectionAsync(Connection connection);
+    Task RemoveConnectionsForPortAsync(PortReference port);
+    Task<IReadOnlyList<Connection>> GetConnectionsAsync();
+    Task<IReadOnlyList<Connection>> GetConnectionsForResourceAsync(string resource);
+    Task<Connection?> GetConnectionForPortAsync(PortReference port);
+}

+ 41 - 70
RackPeek.Domain/Persistence/ResourceCollectionMerger.cs

@@ -1,8 +1,9 @@
-using RackPeek.Domain.Resources;
+using System.Collections;
 using System.Reflection;
 using System.Text.Json;
 using System.Text.Json.Serialization;
 using System.Text.Json.Serialization.Metadata;
+using RackPeek.Domain.Resources;
 using RackPeek.Domain.Resources.AccessPoints;
 using RackPeek.Domain.Resources.Desktops;
 using RackPeek.Domain.Resources.Firewalls;
@@ -16,16 +17,13 @@ using RackPeek.Domain.Resources.UpsUnits;
 
 namespace RackPeek.Domain.Persistence;
 
-public enum MergeMode
-{
+public enum MergeMode {
     Replace,
     Merge
 }
 
-public static class ResourceCollectionMerger
-{
-    private static readonly JsonSerializerOptions CloneJsonOptions = new()
-    {
+public static class ResourceCollectionMerger {
+    private static readonly JsonSerializerOptions _cloneJsonOptions = new() {
         PropertyNameCaseInsensitive = true,
         WriteIndented = false,
         DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
@@ -36,46 +34,39 @@ public static class ResourceCollectionMerger
     public static List<Resource> Merge(
         IEnumerable<Resource> original,
         IEnumerable<Resource> incoming,
-        MergeMode mode)
-    {
-        var originalClone = DeepCloneList(original);
-        var incomingClone = DeepCloneList(incoming);
+        MergeMode mode) {
+        List<Resource> originalClone = DeepCloneList(original);
+        List<Resource> incomingClone = DeepCloneList(incoming);
 
         var result = originalClone.ToDictionary(r => r.Name, r => r, StringComparer.OrdinalIgnoreCase);
 
-        foreach (var newResource in incomingClone)
-        {
-            if (!result.TryGetValue(newResource.Name, out var existing))
-            {
+        foreach (Resource newResource in incomingClone) {
+            if (!result.TryGetValue(newResource.Name, out Resource? existing)) {
                 result[newResource.Name] = newResource;
                 continue;
             }
 
             if (mode == MergeMode.Replace ||
-                existing.GetType() != newResource.GetType())
-            {
+                existing.GetType() != newResource.GetType()) {
                 result[newResource.Name] = newResource;
                 continue;
             }
 
             DeepMerge(existing, newResource, mode);
-            
         }
 
         return result.Values.ToList();
     }
 
-    private static List<Resource> DeepCloneList(IEnumerable<Resource> resources)
-    {
-        var json = JsonSerializer.Serialize(resources, CloneJsonOptions);
-        return JsonSerializer.Deserialize<List<Resource>>(json, CloneJsonOptions) ?? new List<Resource>();
+    private static List<Resource> DeepCloneList(IEnumerable<Resource> resources) {
+        var json = JsonSerializer.Serialize(resources, _cloneJsonOptions);
+        return JsonSerializer.Deserialize<List<Resource>>(json, _cloneJsonOptions) ?? new List<Resource>();
     }
-    private static void DeepMerge(object target, object source, MergeMode mode)
-    {
-        var type = target.GetType();
 
-        foreach (var prop in type.GetProperties(BindingFlags.Public | BindingFlags.Instance))
-        {
+    private static void DeepMerge(object target, object source, MergeMode mode) {
+        Type type = target.GetType();
+
+        foreach (PropertyInfo prop in type.GetProperties(BindingFlags.Public | BindingFlags.Instance)) {
             if (!prop.CanRead || !prop.CanWrite)
                 continue;
 
@@ -84,18 +75,16 @@ public static class ResourceCollectionMerger
                 continue;
 
             var targetValue = prop.GetValue(target);
-            var propType = prop.PropertyType;
+            Type propType = prop.PropertyType;
 
             // Simple types → overwrite
-            if (IsSimple(propType))
-            {
+            if (IsSimple(propType)) {
                 prop.SetValue(target, sourceValue);
                 continue;
             }
 
             // Dictionary
-            if (IsDictionary(propType))
-            {
+            if (IsDictionary(propType)) {
                 if (mode == MergeMode.Merge && IsDictionaryEmpty(sourceValue))
                     continue;
 
@@ -104,8 +93,7 @@ public static class ResourceCollectionMerger
             }
 
             // List / collection
-            if (IsEnumerable(propType))
-            {
+            if (IsEnumerable(propType)) {
                 if (mode == MergeMode.Merge && IsEnumerableEmpty(sourceValue))
                     continue;
 
@@ -115,18 +103,13 @@ public static class ResourceCollectionMerger
 
             // Complex object → recursive merge
             if (targetValue == null)
-            {
                 prop.SetValue(target, sourceValue);
-            }
             else
-            {
                 DeepMerge(targetValue, sourceValue, mode);
-            }
         }
     }
 
-    private static bool IsSimple(Type type)
-    {
+    private static bool IsSimple(Type type) {
         return type.IsPrimitive
                || type == typeof(string)
                || type == typeof(decimal)
@@ -135,56 +118,44 @@ public static class ResourceCollectionMerger
                || Nullable.GetUnderlyingType(type)?.IsPrimitive == true;
     }
 
-    private static bool IsDictionary(Type type)
-    {
+    private static bool IsDictionary(Type type) {
         return type.IsGenericType &&
                type.GetGenericTypeDefinition() == typeof(Dictionary<,>);
     }
 
-    private static bool IsEnumerable(Type type)
-    {
-        return typeof(System.Collections.IEnumerable).IsAssignableFrom(type)
+    private static bool IsEnumerable(Type type) {
+        return typeof(IEnumerable).IsAssignableFrom(type)
                && type != typeof(string)
                && !IsDictionary(type);
     }
-    private static bool IsEnumerableEmpty(object value)
-    {
-        var enumerable = (System.Collections.IEnumerable)value;
+
+    private static bool IsEnumerableEmpty(object value) {
+        var enumerable = (IEnumerable)value;
         return !enumerable.GetEnumerator().MoveNext();
     }
 
-    private static bool IsDictionaryEmpty(object value)
-    {
-        var dict = (System.Collections.IDictionary)value;
+    private static bool IsDictionaryEmpty(object value) {
+        var dict = (IDictionary)value;
         return dict.Count == 0;
     }
 
-    private static void MergeDictionaries(object? target, object source)
-    {
+    private static void MergeDictionaries(object? target, object source) {
         if (target == null) return;
 
-        var targetDict = (System.Collections.IDictionary)target;
-        var sourceDict = (System.Collections.IDictionary)source;
+        var targetDict = (IDictionary)target;
+        var sourceDict = (IDictionary)source;
 
-        foreach (var key in sourceDict.Keys)
-        {
-            targetDict[key] = sourceDict[key];
-        }
+        foreach (var key in sourceDict.Keys) targetDict[key] = sourceDict[key];
     }
 }
 
-internal static class ResourcePolymorphismResolver
-{
-    public static IJsonTypeInfoResolver Create()
-    {
+internal static class ResourcePolymorphismResolver {
+    public static IJsonTypeInfoResolver Create() {
         var resolver = new DefaultJsonTypeInfoResolver();
 
-        resolver.Modifiers.Add(typeInfo =>
-        {
-            if (typeInfo.Type == typeof(Resource))
-            {
-                typeInfo.PolymorphismOptions = new JsonPolymorphismOptions
-                {
+        resolver.Modifiers.Add(typeInfo => {
+            if (typeInfo.Type == typeof(Resource)) {
+                typeInfo.PolymorphismOptions = new JsonPolymorphismOptions {
                     TypeDiscriminatorPropertyName = "kind",
                     IgnoreUnrecognizedTypeDiscriminators = false,
                     UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FailSerialization
@@ -224,4 +195,4 @@ internal static class ResourcePolymorphismResolver
 
         return resolver;
     }
-}
+}

+ 11 - 26
RackPeek.Domain/Persistence/ServiceRepository.cs

@@ -2,15 +2,10 @@ using RackPeek.Domain.Resources.Services;
 
 namespace RackPeek.Domain.Persistence;
 
-public class ServiceRepository(IResourceCollection resources) : IServiceRepository
-{
-    public Task<int> GetCountAsync()
-    {
-        return Task.FromResult(resources.ServiceResources.Count);
-    }
+public class ServiceRepository(IResourceCollection resources) : IServiceRepository {
+    public Task<int> GetCountAsync() => Task.FromResult(resources.ServiceResources.Count);
 
-    public Task<int> GetIpAddressCountAsync()
-    {
+    public Task<int> GetIpAddressCountAsync() {
         return Task.FromResult(resources.ServiceResources
             .Where(i => i.Network?.Ip != null)
             .Select(i => i.Network!.Ip)
@@ -18,26 +13,18 @@ public class ServiceRepository(IResourceCollection resources) : IServiceReposito
             .Count());
     }
 
-    public Task<IReadOnlyList<Service>> GetBySystemHostAsync(string systemHostName)
-    {
+    public Task<IReadOnlyList<Service>> GetBySystemHostAsync(string systemHostName) {
         var systemHostNameLower = systemHostName.ToLower().Trim();
         var results = resources.ServiceResources
             .Where(s => s.RunsOn.Select(p => p.ToLower().Equals(systemHostNameLower)).ToList().Count > 0).ToList();
         return Task.FromResult<IReadOnlyList<Service>>(results);
     }
 
-    public Task<IReadOnlyList<Service>> GetAllAsync()
-    {
-        return Task.FromResult(resources.ServiceResources);
-    }
+    public Task<IReadOnlyList<Service>> GetAllAsync() => Task.FromResult(resources.ServiceResources);
 
-    public Task<Service?> GetByNameAsync(string name)
-    {
-        return Task.FromResult(resources.GetByName(name) as Service);
-    }
+    public Task<Service?> GetByNameAsync(string name) => Task.FromResult(resources.GetByName(name) as Service);
 
-    public async Task AddAsync(Service service)
-    {
+    public async Task AddAsync(Service service) {
         if (resources.ServiceResources.Any(r =>
                 r.Name.Equals(service.Name, StringComparison.OrdinalIgnoreCase)))
             throw new InvalidOperationException(
@@ -46,9 +33,8 @@ public class ServiceRepository(IResourceCollection resources) : IServiceReposito
         await resources.AddAsync(service);
     }
 
-    public async Task UpdateAsync(Service service)
-    {
-        var existing = resources.ServiceResources
+    public async Task UpdateAsync(Service service) {
+        Service? existing = resources.ServiceResources
             .FirstOrDefault(r => r.Name.Equals(service.Name, StringComparison.OrdinalIgnoreCase));
 
         if (existing == null)
@@ -57,9 +43,8 @@ public class ServiceRepository(IResourceCollection resources) : IServiceReposito
         await resources.UpdateAsync(service);
     }
 
-    public async Task DeleteAsync(string name)
-    {
-        var existing = resources.ServiceResources
+    public async Task DeleteAsync(string name) {
+        Service? existing = resources.ServiceResources
             .FirstOrDefault(r => r.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
 
         if (existing == null)

+ 18 - 36
RackPeek.Domain/Persistence/SystemRepository.cs

@@ -2,23 +2,17 @@ using RackPeek.Domain.Resources.SystemResources;
 
 namespace RackPeek.Domain.Persistence;
 
-public class YamlSystemRepository(IResourceCollection resources) : ISystemRepository
-{
-    public Task<int> GetSystemCountAsync()
-    {
-        return Task.FromResult(resources.SystemResources.Count);
-    }
+public class YamlSystemRepository(IResourceCollection resources) : ISystemRepository {
+    public Task<int> GetSystemCountAsync() => Task.FromResult(resources.SystemResources.Count);
 
-    public Task<Dictionary<string, int>> GetSystemTypeCountAsync()
-    {
+    public Task<Dictionary<string, int>> GetSystemTypeCountAsync() {
         return Task.FromResult(resources.SystemResources
             .Where(s => !string.IsNullOrEmpty(s.Type))
             .GroupBy(h => h.Type!)
             .ToDictionary(k => k.Key, v => v.Count()));
     }
 
-    public Task<Dictionary<string, int>> GetSystemOsCountAsync()
-    {
+    public Task<Dictionary<string, int>> GetSystemOsCountAsync() {
         return Task.FromResult(resources.SystemResources
             .Where(s => !string.IsNullOrEmpty(s.Os))
             .GroupBy(h => h.Os!)
@@ -27,9 +21,8 @@ public class YamlSystemRepository(IResourceCollection resources) : ISystemReposi
 
     public Task<IReadOnlyList<SystemResource>> GetFilteredAsync(
         string? typeFilter,
-        string? osFilter)
-    {
-        var query = resources.SystemResources.AsQueryable();
+        string? osFilter) {
+        IQueryable<SystemResource> query = resources.SystemResources.AsQueryable();
 
         var type = Normalize(typeFilter);
         var os = Normalize(osFilter);
@@ -44,32 +37,23 @@ public class YamlSystemRepository(IResourceCollection resources) : ISystemReposi
         return Task.FromResult<IReadOnlyList<SystemResource>>(results);
     }
 
-    public Task<IReadOnlyList<SystemResource>> GetByPhysicalHostAsync(string physicalHostName)
-    {
+    public Task<IReadOnlyList<SystemResource>> GetByPhysicalHostAsync(string physicalHostName) {
         var physicalHostNameLower = physicalHostName.ToLower().Trim();
         var results = resources.SystemResources
-            .Where(s => s.RunsOn.Select(sys => sys.ToLower().Equals(physicalHostNameLower)).ToList().Count > 0).ToList();
+            .Where(s => s.RunsOn.Select(sys => sys.ToLower().Equals(physicalHostNameLower)).ToList().Count > 0)
+            .ToList();
         return Task.FromResult<IReadOnlyList<SystemResource>>(results);
     }
 
-    public Task<IReadOnlyList<SystemResource>> GetAllAsync()
-    {
-        return Task.FromResult(resources.SystemResources);
-    }
+    public Task<IReadOnlyList<SystemResource>> GetAllAsync() => Task.FromResult(resources.SystemResources);
 
-    private static string? Normalize(string? value)
-    {
-        return string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLower();
-    }
+    private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLower();
 
 
-    public Task<SystemResource?> GetByNameAsync(string name)
-    {
-        return Task.FromResult(resources.GetByName(name) as SystemResource);
-    }
+    public Task<SystemResource?> GetByNameAsync(string name) =>
+        Task.FromResult(resources.GetByName(name) as SystemResource);
 
-    public async Task AddAsync(SystemResource systemResource)
-    {
+    public async Task AddAsync(SystemResource systemResource) {
         if (resources.SystemResources.Any(r =>
                 r.Name.Equals(systemResource.Name, StringComparison.OrdinalIgnoreCase)))
             throw new InvalidOperationException(
@@ -78,9 +62,8 @@ public class YamlSystemRepository(IResourceCollection resources) : ISystemReposi
         await resources.AddAsync(systemResource);
     }
 
-    public async Task UpdateAsync(SystemResource systemResource)
-    {
-        var existing = resources.SystemResources
+    public async Task UpdateAsync(SystemResource systemResource) {
+        SystemResource? existing = resources.SystemResources
             .FirstOrDefault(r => r.Name.Equals(systemResource.Name, StringComparison.OrdinalIgnoreCase));
 
         if (existing == null)
@@ -89,9 +72,8 @@ public class YamlSystemRepository(IResourceCollection resources) : ISystemReposi
         await resources.UpdateAsync(systemResource);
     }
 
-    public async Task DeleteAsync(string name)
-    {
-        var existing = resources.SystemResources
+    public async Task DeleteAsync(string name) {
+        SystemResource? existing = resources.SystemResources
             .FirstOrDefault(r => r.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
 
         if (existing == null)

+ 15 - 22
RackPeek.Domain/Persistence/Yaml/Converters.cs

@@ -6,45 +6,40 @@ using YamlDotNet.Serialization;
 
 namespace RackPeek.Domain.Persistence.Yaml;
 
-public static class StorageSizeParser
-{
-    private static readonly Regex SizeRegex = new(@"^\s*(\d+(?:\.\d+)?)\s*(gb|tb)?\s*$",
+public static class StorageSizeParser {
+    private static readonly Regex _sizeRegex = new(@"^\s*(\d+(?:\.\d+)?)\s*(gb|tb)?\s*$",
         RegexOptions.IgnoreCase | RegexOptions.Compiled);
 
-    public static double ParseToGbDouble(string input)
-    {
-        var match = SizeRegex.Match(input);
+    public static double ParseToGbDouble(string input) {
+        Match match = _sizeRegex.Match(input);
         if (!match.Success) throw new FormatException($"Invalid storage size: '{input}'");
         var value = double.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture);
         var unit = match.Groups[2].Value.ToLowerInvariant();
-        return unit switch
-        {
-            "tb" => value * 1024, "gb" or "" => value, _ => throw new FormatException($"Unknown unit in '{input}'")
+        return unit switch {
+            "tb" => value * 1024,
+            "gb" or "" => value,
+            _ => throw new FormatException($"Unknown unit in '{input}'")
         };
     }
 }
 
-public class StorageSizeYamlConverter : IYamlTypeConverter
-{
-    public bool Accepts(Type type)
-    {
+public class StorageSizeYamlConverter : IYamlTypeConverter {
+    public bool Accepts(Type type) {
         return type == typeof(int) ||
                type == typeof(int?) ||
                type == typeof(double) ||
                type == typeof(double?);
     }
 
-    public object? ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer)
-    {
-        var scalar = parser.Consume<Scalar>();
+    public object? ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer) {
+        Scalar scalar = parser.Consume<Scalar>();
         var value = scalar.Value;
 
         if (string.IsNullOrWhiteSpace(value))
             return null;
 
         // If it's already a number, parse directly
-        if (double.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var numericDouble))
-        {
+        if (double.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var numericDouble)) {
             if (type == typeof(double) || type == typeof(double?))
                 return numericDouble;
 
@@ -61,8 +56,6 @@ public class StorageSizeYamlConverter : IYamlTypeConverter
         return (int)Math.Round(gb);
     }
 
-    public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer)
-    {
+    public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) =>
         emitter.Emit(new Scalar(value?.ToString() ?? string.Empty));
-    }
-}
+}

+ 6 - 17
RackPeek.Domain/Persistence/Yaml/ITextFileStore.cs

@@ -1,26 +1,15 @@
 namespace RackPeek.Domain.Persistence.Yaml;
 
-public interface ITextFileStore
-{
+public interface ITextFileStore {
     Task<bool> ExistsAsync(string path);
     Task<string> ReadAllTextAsync(string path);
     Task WriteAllTextAsync(string path, string contents);
 }
 
-public sealed class PhysicalTextFileStore : ITextFileStore
-{
-    public Task<bool> ExistsAsync(string path)
-    {
-        return Task.FromResult(File.Exists(path));
-    }
+public sealed class PhysicalTextFileStore : ITextFileStore {
+    public Task<bool> ExistsAsync(string path) => Task.FromResult(File.Exists(path));
 
-    public Task<string> ReadAllTextAsync(string path)
-    {
-        return File.ReadAllTextAsync(path);
-    }
+    public Task<string> ReadAllTextAsync(string path) => File.ReadAllTextAsync(path);
 
-    public Task WriteAllTextAsync(string path, string contents)
-    {
-        return File.WriteAllTextAsync(path, contents);
-    }
-}
+    public Task WriteAllTextAsync(string path, string contents) => File.WriteAllTextAsync(path, contents);
+}

+ 7 - 14
RackPeek.Domain/Persistence/Yaml/NotesStringYamlConverter.cs

@@ -4,23 +4,16 @@ using YamlDotNet.Serialization;
 
 namespace RackPeek.Domain.Persistence.Yaml;
 
-public sealed class NotesStringYamlConverter : IYamlTypeConverter
-{
-    public bool Accepts(Type type)
-    {
-        return type == typeof(string);
-    }
+public sealed class NotesStringYamlConverter : IYamlTypeConverter {
+    public bool Accepts(Type type) => type == typeof(string);
 
-    public object? ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer)
-    {
-        var scalar = parser.Consume<Scalar>();
+    public object? ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer) {
+        Scalar scalar = parser.Consume<Scalar>();
         return scalar.Value;
     }
 
-    public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer)
-    {
-        if (value is null)
-        {
+    public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) {
+        if (value is null) {
             emitter.Emit(new Scalar(
                 AnchorName.Empty,
                 TagName.Empty,
@@ -45,4 +38,4 @@ public sealed class NotesStringYamlConverter : IYamlTypeConverter
         else
             emitter.Emit(new Scalar(s));
     }
-}
+}

+ 75 - 24
RackPeek.Domain/Persistence/Yaml/RackPeekConfigMigrationDeserializer.cs

@@ -17,25 +17,26 @@ using YamlDotNet.Serialization.NamingConventions;
 
 namespace RackPeek.Domain.Persistence.Yaml;
 
-public class RackPeekConfigMigrationDeserializer : YamlMigrationDeserializer<YamlRoot>
-{
+public class RackPeekConfigMigrationDeserializer : YamlMigrationDeserializer<YamlRoot> {
     // List migration functions here
-    public static readonly IReadOnlyList<Func<IServiceProvider, Dictionary<object, object>, ValueTask>> ListOfMigrations = new List<Func<IServiceProvider, Dictionary<object,object>, ValueTask>>{
-        EnsureSchemaVersionExists,
-        ConvertScalarRunsOnToList,
-    };
+    public static readonly IReadOnlyList<Func<IServiceProvider, Dictionary<object, object>, ValueTask>>
+        ListOfMigrations = new List<Func<IServiceProvider, Dictionary<object, object>, ValueTask>>
+        {
+            EnsureSchemaVersionExists,
+            ConvertScalarRunsOnToList,
+            ConvertNicsToPortsV3
+        };
 
     public RackPeekConfigMigrationDeserializer(IServiceProvider serviceProvider,
         ILogger<YamlMigrationDeserializer<YamlRoot>> logger) :
-        base(serviceProvider, logger, 
+        base(serviceProvider, logger,
             ListOfMigrations,
             "version",
             new DeserializerBuilder().WithNamingConvention(CamelCaseNamingConvention.Instance)
                 .WithCaseInsensitivePropertyMatching()
                 .WithTypeConverter(new StorageSizeYamlConverter())
                 .WithTypeConverter(new NotesStringYamlConverter())
-                .WithTypeDiscriminatingNodeDeserializer(options =>
-                {
+                .WithTypeDiscriminatingNodeDeserializer(options => {
                     options.AddKeyValueTypeDiscriminator<Resource>("kind", new Dictionary<string, Type>
                     {
                         { Server.KindLabel, typeof(Server) },
@@ -50,7 +51,7 @@ public class RackPeekConfigMigrationDeserializer : YamlMigrationDeserializer<Yam
                         { SystemResource.KindLabel, typeof(SystemResource) },
                         { Service.KindLabel, typeof(Service) }
                     });
-                }), 
+                }),
             new SerializerBuilder()
                 .WithNamingConvention(CamelCaseNamingConvention.Instance)
                 .WithTypeConverter(new StorageSizeYamlConverter())
@@ -58,24 +59,21 @@ public class RackPeekConfigMigrationDeserializer : YamlMigrationDeserializer<Yam
                 .ConfigureDefaultValuesHandling(
                     DefaultValuesHandling.OmitNull |
                     DefaultValuesHandling.OmitEmptyCollections
-                )) {}
+                )) {
+    }
 
     #region Migrations
 
     // Define migration functions here
-    public static ValueTask EnsureSchemaVersionExists(IServiceProvider serviceProvider, Dictionary<object, object> obj)
-    {
-        if (!obj.ContainsKey("version"))
-        {
-            obj["version"] = 0;
-        }
-        
+    public static ValueTask EnsureSchemaVersionExists(IServiceProvider serviceProvider, Dictionary<object, object> obj) {
+        if (!obj.ContainsKey("version")) obj["version"] = 0;
+
         return ValueTask.CompletedTask;
     }
+
     public static ValueTask ConvertScalarRunsOnToList(
         IServiceProvider serviceProvider,
-        Dictionary<object, object> obj)
-    {
+        Dictionary<object, object> obj) {
         const string key = "runsOn";
 
         if (!obj.TryGetValue("resources", out var resourceListObj))
@@ -84,16 +82,14 @@ public class RackPeekConfigMigrationDeserializer : YamlMigrationDeserializer<Yam
         if (resourceListObj is not List<object> resources)
             return ValueTask.CompletedTask;
 
-        foreach (var resourceObj in resources)
-        {
+        foreach (var resourceObj in resources) {
             if (resourceObj is not Dictionary<object, object> resourceDict)
                 continue;
 
             if (!resourceDict.TryGetValue(key, out var runsOn))
                 continue;
 
-            switch (runsOn)
-            {
+            switch (runsOn) {
                 case string single:
                     resourceDict[key] = new List<string> { single };
                     break;
@@ -116,5 +112,60 @@ public class RackPeekConfigMigrationDeserializer : YamlMigrationDeserializer<Yam
 
         return ValueTask.CompletedTask;
     }
+
+    public static ValueTask ConvertNicsToPortsV3(
+        IServiceProvider serviceProvider,
+        Dictionary<object, object> obj) {
+        if (!obj.TryGetValue("resources", out var resourcesObj))
+            return ValueTask.CompletedTask;
+
+        if (resourcesObj is not List<object> resources)
+            return ValueTask.CompletedTask;
+
+        foreach (var resourceObj in resources) {
+            if (resourceObj is not Dictionary<object, object> resourceDict)
+                continue;
+
+            if (!resourceDict.TryGetValue("nics", out var nicsObj))
+                continue;
+
+            if (nicsObj is not List<object> nics)
+                continue;
+
+            var ports = new List<Dictionary<object, object>>();
+
+            foreach (var nicObj in nics) {
+                if (nicObj is not Dictionary<object, object> nicDict)
+                    continue;
+
+                var port = new Dictionary<object, object>();
+
+                if (nicDict.TryGetValue("type", out var type))
+                    port["type"] = type;
+
+                if (nicDict.TryGetValue("speed", out var speed))
+                    port["speed"] = speed;
+
+                if (nicDict.TryGetValue("ports", out var portCount))
+                    port["count"] = portCount;
+
+                ports.Add(port);
+            }
+
+            resourceDict.Remove("nics");
+
+            if (resourceDict.TryGetValue("ports", out var existingPortsObj)
+                && existingPortsObj is List<object> existingPorts)
+                foreach (Dictionary<object, object> p in ports)
+                    existingPorts.Add(p);
+            else
+                resourceDict["ports"] = ports.Cast<object>().ToList();
+        }
+
+        obj["version"] = 3;
+
+        return ValueTask.CompletedTask;
+    }
+
     #endregion
 }

+ 10 - 20
RackPeek.Domain/Persistence/Yaml/ResourceYamlMigrationService.cs

@@ -1,43 +1,34 @@
 namespace RackPeek.Domain.Persistence.Yaml;
 
-using RackPeek.Domain.Resources;
-using YamlDotNet.Core;
-
-public interface IResourceYamlMigrationService
-{
+public interface IResourceYamlMigrationService {
     Task<YamlRoot> DeserializeAsync(
         string yaml,
         Func<string, Task>? preMigrationAction = null,
         Func<YamlRoot, Task>? postMigrationAction = null);
 }
 
-public sealed class ResourceYamlMigrationService( 
+public sealed class ResourceYamlMigrationService(
     RackPeekConfigMigrationDeserializer deserializer)
-    : IResourceYamlMigrationService
-{
-    private static readonly int CurrentSchemaVersion =
+    : IResourceYamlMigrationService {
+    private static readonly int _currentSchemaVersion =
         RackPeekConfigMigrationDeserializer.ListOfMigrations.Count;
 
     public async Task<YamlRoot> DeserializeAsync(
         string yaml,
         Func<string, Task>? preMigrationAction = null,
-        Func<YamlRoot, Task>? postMigrationAction = null)
-    {
+        Func<YamlRoot, Task>? postMigrationAction = null) {
         if (string.IsNullOrWhiteSpace(yaml))
             return new YamlRoot();
 
         var version = deserializer.GetSchemaVersion(yaml);
 
-        if (version > CurrentSchemaVersion)
-        {
+        if (version > _currentSchemaVersion)
             throw new InvalidOperationException(
-                $"Config schema version {version} is newer than this application supports ({CurrentSchemaVersion}).");
-        }
+                $"Config schema version {version} is newer than this application supports ({_currentSchemaVersion}).");
 
         YamlRoot? root;
 
-        if (version < CurrentSchemaVersion)
-        {
+        if (version < _currentSchemaVersion) {
             if (preMigrationAction != null)
                 await preMigrationAction(yaml);
 
@@ -46,11 +37,10 @@ public sealed class ResourceYamlMigrationService(
             if (postMigrationAction != null)
                 await postMigrationAction(root);
         }
-        else
-        {
+        else {
             root = await deserializer.Deserialize(yaml);
         }
 
         return root ?? new YamlRoot();
     }
-}
+}

+ 242 - 181
RackPeek.Domain/Persistence/Yaml/YamlResourceCollection.cs

@@ -1,6 +1,9 @@
+using System.Collections.ObjectModel;
 using System.Collections.Specialized;
+using System.Diagnostics;
 using RackPeek.Domain.Resources;
 using RackPeek.Domain.Resources.AccessPoints;
+using RackPeek.Domain.Resources.Connections;
 using RackPeek.Domain.Resources.Desktops;
 using RackPeek.Domain.Resources.Firewalls;
 using RackPeek.Domain.Resources.Hardware;
@@ -8,21 +11,19 @@ using RackPeek.Domain.Resources.Laptops;
 using RackPeek.Domain.Resources.Routers;
 using RackPeek.Domain.Resources.Servers;
 using RackPeek.Domain.Resources.Services;
-using RackPeek.Domain.Resources.Switches;
 using RackPeek.Domain.Resources.SystemResources;
 using RackPeek.Domain.Resources.OtherHardware;
 using RackPeek.Domain.Resources.UpsUnits;
-using YamlDotNet.Core;
 using YamlDotNet.Serialization;
 using YamlDotNet.Serialization.NamingConventions;
+using Switch = RackPeek.Domain.Resources.Switches.Switch;
 
 namespace RackPeek.Domain.Persistence.Yaml;
 
-
-public class ResourceCollection
-{
+public class ResourceCollection {
     public readonly SemaphoreSlim FileLock = new(1, 1);
     public List<Resource> Resources { get; } = new();
+    public List<Connection> Connections { get; } = new();
 }
 
 public sealed class YamlResourceCollection(
@@ -30,26 +31,22 @@ public sealed class YamlResourceCollection(
     ITextFileStore fileStore,
     ResourceCollection resourceCollection,
     IResourceYamlMigrationService migrationService)
-    : IResourceCollection
-{
+    : IResourceCollection {
     // Bump this when your YAML schema changes, and add a migration step below.
-    private static readonly int CurrentSchemaVersion = RackPeekConfigMigrationDeserializer.ListOfMigrations.Count;
+    private static readonly int _currentSchemaVersion = RackPeekConfigMigrationDeserializer.ListOfMigrations.Count;
 
-    public Task<bool> Exists(string name)
-    {
+    public Task<bool> Exists(string name) {
         return Task.FromResult(resourceCollection.Resources.Exists(r =>
             r.Name.Equals(name, StringComparison.OrdinalIgnoreCase)));
     }
 
-    public Task<string?> GetKind(string? name)
-    {
+    public Task<string?> GetKind(string? name) {
         return Task.FromResult(resourceCollection.Resources.FirstOrDefault(r =>
             r.Name.Equals(name, StringComparison.OrdinalIgnoreCase))?.Kind);
-        
     }
-    public Task<IReadOnlyList<(Resource, string)>> GetByLabelAsync(string name)
-    {
-        var result = resourceCollection.Resources
+
+    public Task<IReadOnlyList<(Resource, string)>> GetByLabelAsync(string name) {
+        ReadOnlyCollection<(Resource r, string)> result = resourceCollection.Resources
             .Where(r => r.Labels != null && r.Labels.TryGetValue(name, out _))
             .Select(r => (r, r.Labels![name]))
             .ToList()
@@ -57,8 +54,8 @@ public sealed class YamlResourceCollection(
 
         return Task.FromResult<IReadOnlyList<(Resource, string)>>(result);
     }
-    public Task<Dictionary<string, int>> GetLabelsAsync()
-    {
+
+    public Task<Dictionary<string, int>> GetLabelsAsync() {
         var result = resourceCollection.Resources
             .SelectMany(r => r.Labels ?? Enumerable.Empty<KeyValuePair<string, string>>())
             .Where(kvp => !string.IsNullOrWhiteSpace(kvp.Key))
@@ -67,11 +64,11 @@ public sealed class YamlResourceCollection(
 
         return Task.FromResult(result);
     }
-    public Task<IReadOnlyList<(Resource, string)>> GetResourceIpsAsync()
-    {
+
+    public Task<IReadOnlyList<(Resource, string)>> GetResourceIpsAsync() {
         var result = new List<(Resource, string)>();
 
-        var allResources = resourceCollection.Resources;
+        List<Resource> allResources = resourceCollection.Resources;
 
         // Build fast lookup for systems
         var systemsByName = allResources
@@ -81,88 +78,27 @@ public sealed class YamlResourceCollection(
         // Cache resolved system IPs (prevents repeated recursion)
         var resolvedSystemIps = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
 
-        foreach (var resource in allResources)
-        {
-            switch (resource)
-            {
-                case SystemResource system:
-                {
-                    var ip = ResolveSystemIp(system, systemsByName, resolvedSystemIps);
-                    if (!string.IsNullOrWhiteSpace(ip))
-                        result.Add((system, ip));
-                    break;
-                }
-
-                case Service service:
-                {
-                    var ip = ResolveServiceIp(service, systemsByName, resolvedSystemIps);
-                    if (!string.IsNullOrWhiteSpace(ip))
-                        result.Add((service, ip));
-                    break;
-                }
+        foreach (Resource resource in allResources)
+            switch (resource) {
+                case SystemResource system: {
+                        var ip = ResolveSystemIp(system, systemsByName, resolvedSystemIps);
+                        if (!string.IsNullOrWhiteSpace(ip))
+                            result.Add((system, ip));
+                        break;
+                    }
+
+                case Service service: {
+                        var ip = ResolveServiceIp(service, systemsByName, resolvedSystemIps);
+                        if (!string.IsNullOrWhiteSpace(ip))
+                            result.Add((service, ip));
+                        break;
+                    }
             }
-        }
 
         return Task.FromResult((IReadOnlyList<(Resource, string)>)result);
     }
-    private string? ResolveSystemIp(
-        SystemResource system,
-        Dictionary<string, SystemResource> systemsByName,
-        Dictionary<string, string?> cache)
-    {
-        // Return cached result if already resolved
-        if (cache.TryGetValue(system.Name, out var cached))
-            return cached;
-
-        // Direct IP wins
-        if (!string.IsNullOrWhiteSpace(system.Ip))
-        {
-            cache[system.Name] = system.Ip;
-            return system.Ip;
-        }
-
-        // Must have exactly one parent
-        if (system.RunsOn?.Count != 1)
-        {
-            cache[system.Name] = null;
-            return null;
-        }
-
-        var parentName = system.RunsOn.First();
-
-        if (!systemsByName.TryGetValue(parentName, out var parent))
-        {
-            cache[system.Name] = null;
-            return null;
-        }
-
-        var resolved = ResolveSystemIp(parent, systemsByName, cache);
-        cache[system.Name] = resolved;
-
-        return resolved;
-    }
-    private string? ResolveServiceIp(
-        Service service,
-        Dictionary<string, SystemResource> systemsByName,
-        Dictionary<string, string?> cache)
-    {
-        // Direct IP wins
-        if (!string.IsNullOrWhiteSpace(service.Network?.Ip))
-            return service.Network!.Ip;
-
-        // Must have exactly one parent
-        if (service.RunsOn?.Count != 1)
-            return null;
 
-        var parentName = service.RunsOn.First();
-
-        if (!systemsByName.TryGetValue(parentName, out var parent))
-            return null;
-
-        return ResolveSystemIp(parent, systemsByName, cache);
-    }
-    public Task<Dictionary<string, int>> GetTagsAsync()
-    {
+    public Task<Dictionary<string, int>> GetTagsAsync() {
         var result = resourceCollection.Resources
             .SelectMany(r => r.Tags) // flatten all tag arrays
             .Where(t => !string.IsNullOrWhiteSpace(t))
@@ -172,13 +108,10 @@ public sealed class YamlResourceCollection(
         return Task.FromResult(result);
     }
 
-    public Task<IReadOnlyList<T>> GetAllOfTypeAsync<T>()
-    {
-        return Task.FromResult<IReadOnlyList<T>>(resourceCollection.Resources.OfType<T>().ToList());
-    }
-    
-    public Task<IReadOnlyList<Resource>> GetDependantsAsync(string name)
-    {
+    public Task<IReadOnlyList<T>> GetAllOfTypeAsync<T>() =>
+        Task.FromResult<IReadOnlyList<T>>(resourceCollection.Resources.OfType<T>().ToList());
+
+    public Task<IReadOnlyList<Resource>> GetDependantsAsync(string name) {
         var result = resourceCollection.Resources
             .Where(r => r.RunsOn.Any(p => p.Equals(name, StringComparison.OrdinalIgnoreCase)))
             .ToList();
@@ -186,18 +119,16 @@ public sealed class YamlResourceCollection(
         return Task.FromResult<IReadOnlyList<Resource>>(result);
     }
 
-    public async Task Merge(string incomingYaml, MergeMode mode)
-    {
+    public async Task Merge(string incomingYaml, MergeMode mode) {
         if (string.IsNullOrWhiteSpace(incomingYaml))
             return;
 
         await resourceCollection.FileLock.WaitAsync();
-        try
-        {
-            var incomingRoot = await migrationService.DeserializeAsync(incomingYaml);
+        try {
+            YamlRoot incomingRoot = await migrationService.DeserializeAsync(incomingYaml);
 
-            var incomingResources = incomingRoot.Resources ?? new List<Resource>();
-            var merged = ResourceCollectionMerger.Merge(
+            List<Resource> incomingResources = incomingRoot.Resources ?? new List<Resource>();
+            List<Resource> merged = ResourceCollectionMerger.Merge(
                 resourceCollection.Resources,
                 incomingResources,
                 mode);
@@ -205,22 +136,20 @@ public sealed class YamlResourceCollection(
             resourceCollection.Resources.Clear();
             resourceCollection.Resources.AddRange(merged);
 
-            var rootToSave = new YamlRoot
-            {
+            var rootToSave = new YamlRoot {
                 Version = RackPeekConfigMigrationDeserializer.ListOfMigrations.Count,
-                Resources = resourceCollection.Resources
+                Resources = resourceCollection.Resources,
+                Connections = resourceCollection.Connections
             };
 
             await SaveRootAsync(rootToSave);
         }
-        finally
-        {
+        finally {
             resourceCollection.FileLock.Release();
         }
     }
 
-    public Task<IReadOnlyList<Resource>> GetByTagAsync(string name)
-    {
+    public Task<IReadOnlyList<Resource>> GetByTagAsync(string name) {
         return Task.FromResult<IReadOnlyList<Resource>>(
             resourceCollection.Resources
                 .Where(r => r.Tags.Contains(name))
@@ -237,45 +166,55 @@ public sealed class YamlResourceCollection(
     public IReadOnlyList<Service> ServiceResources =>
         resourceCollection.Resources.OfType<Service>().ToList();
 
-    public Task<Resource?> GetByNameAsync(string name)
-    {
+    public Task<Resource?> GetByNameAsync(string name) {
         return Task.FromResult(resourceCollection.Resources.FirstOrDefault(r =>
             r.Name.Equals(name, StringComparison.OrdinalIgnoreCase)));
     }
 
-    public Task<T?> GetByNameAsync<T>(string name) where T : Resource
-    {
-        var resource =
+    public Task<T?> GetByNameAsync<T>(string name) where T : Resource {
+        Resource? resource =
             resourceCollection.Resources.FirstOrDefault(r => r.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
         return Task.FromResult(resource as T);
     }
 
-    public Resource? GetByName(string name)
-    {
+    public Resource? GetByName(string name) {
         return resourceCollection.Resources.FirstOrDefault(r =>
             r.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
     }
 
-    public async Task LoadAsync()
-    {
-        var yaml = await fileStore.ReadAllTextAsync(filePath);
+    public async Task LoadAsync() {
+        // Routes.razor calls LoadAsync on every Blazor circuit init, so
+        // multiple tabs / fresh page loads can run this concurrently. Without
+        // the lock, two callers can interleave Resources.Clear() and
+        // AddRange() and corrupt the List<T>'s internal _size, producing
+        // "Index was outside the bounds of the array" out of List.Clear.
+        await resourceCollection.FileLock.WaitAsync();
+        try {
+            var yaml = await fileStore.ReadAllTextAsync(filePath);
 
-        var root = await migrationService.DeserializeAsync(
-            yaml,
-            async originalYaml => await BackupOriginalAsync(originalYaml),
-            async migratedRoot => await SaveRootAsync(migratedRoot)
-        );
+            YamlRoot root = await migrationService.DeserializeAsync(
+                yaml,
+                async originalYaml => await BackupOriginalAsync(originalYaml),
+                async migratedRoot => await SaveRootAsync(migratedRoot)
+            );
+
+            resourceCollection.Resources.Clear();
 
-        resourceCollection.Resources.Clear();
+            if (root.Resources != null)
+                resourceCollection.Resources.AddRange(root.Resources);
 
-        if (root.Resources != null)
-            resourceCollection.Resources.AddRange(root.Resources);
+            resourceCollection.Connections.Clear();
+
+            if (root.Connections != null)
+                resourceCollection.Connections.AddRange(root.Connections);
+        }
+        finally {
+            resourceCollection.FileLock.Release();
+        }
     }
-    
-    public Task AddAsync(Resource resource)
-    {
-        return UpdateWithLockAsync(list =>
-        {
+
+    public Task AddAsync(Resource resource) {
+        return UpdateWithLockAsync(list => {
             if (list.Any(r => r.Name.Equals(resource.Name, StringComparison.OrdinalIgnoreCase)))
                 throw new InvalidOperationException($"'{resource.Name}' already exists.");
 
@@ -284,10 +223,8 @@ public sealed class YamlResourceCollection(
         });
     }
 
-    public Task UpdateAsync(Resource resource)
-    {
-        return UpdateWithLockAsync(list =>
-        {
+    public Task UpdateAsync(Resource resource) {
+        return UpdateWithLockAsync(list => {
             var index = list.FindIndex(r => r.Name.Equals(resource.Name, StringComparison.OrdinalIgnoreCase));
             if (index == -1) throw new InvalidOperationException("Not found.");
 
@@ -296,30 +233,128 @@ public sealed class YamlResourceCollection(
         });
     }
 
-    public Task DeleteAsync(string name)
-    {
+    public Task DeleteAsync(string name) {
         return UpdateWithLockAsync(list =>
             list.RemoveAll(r => r.Name.Equals(name, StringComparison.OrdinalIgnoreCase)));
     }
 
-    private async Task UpdateWithLockAsync(Action<List<Resource>> action)
-    {
+    public Task AddConnectionAsync(Connection connection) => UpdateConnectionsWithLockAsync(list => { list.Add(connection); });
+
+    public Task RemoveConnectionAsync(Connection connection) {
+        return UpdateConnectionsWithLockAsync(list => {
+            list.RemoveAll(c =>
+                (PortsMatch(c.A, connection.A) && PortsMatch(c.B, connection.B)) ||
+                (PortsMatch(c.A, connection.B) && PortsMatch(c.B, connection.A)));
+        });
+    }
+
+    public Task RemoveConnectionsForPortAsync(PortReference port) {
+        return UpdateConnectionsWithLockAsync(list => {
+            list.RemoveAll(c =>
+                PortsMatch(c.A, port) ||
+                PortsMatch(c.B, port));
+        });
+    }
+
+    public Task<IReadOnlyList<Connection>> GetConnectionsAsync() {
+        IReadOnlyList<Connection> result =
+            resourceCollection.Connections
+                .ToList()
+                .AsReadOnly();
+
+        return Task.FromResult(result);
+    }
+
+    public Task<IReadOnlyList<Connection>> GetConnectionsForResourceAsync(string resource) {
+        IReadOnlyList<Connection> result =
+            resourceCollection.Connections
+                .Where(c =>
+                    c.A.Resource.Equals(resource, StringComparison.OrdinalIgnoreCase) ||
+                    c.B.Resource.Equals(resource, StringComparison.OrdinalIgnoreCase))
+                .ToList()
+                .AsReadOnly();
+
+        return Task.FromResult(result);
+    }
+
+    public Task<Connection?> GetConnectionForPortAsync(PortReference port) {
+        Connection? connection =
+            resourceCollection.Connections
+                .FirstOrDefault(c =>
+                    PortsMatch(c.A, port) ||
+                    PortsMatch(c.B, port));
+
+        return Task.FromResult(connection);
+    }
+
+    private string? ResolveSystemIp(
+        SystemResource system,
+        Dictionary<string, SystemResource> systemsByName,
+        Dictionary<string, string?> cache) {
+        // Return cached result if already resolved
+        if (cache.TryGetValue(system.Name, out var cached))
+            return cached;
+
+        // Direct IP wins
+        if (!string.IsNullOrWhiteSpace(system.Ip)) {
+            cache[system.Name] = system.Ip;
+            return system.Ip;
+        }
+
+        // Must have exactly one parent
+        if (system.RunsOn?.Count != 1) {
+            cache[system.Name] = null;
+            return null;
+        }
+
+        var parentName = system.RunsOn.First();
+
+        if (!systemsByName.TryGetValue(parentName, out SystemResource? parent)) {
+            cache[system.Name] = null;
+            return null;
+        }
+
+        var resolved = ResolveSystemIp(parent, systemsByName, cache);
+        cache[system.Name] = resolved;
+
+        return resolved;
+    }
+
+    private string? ResolveServiceIp(
+        Service service,
+        Dictionary<string, SystemResource> systemsByName,
+        Dictionary<string, string?> cache) {
+        // Direct IP wins
+        if (!string.IsNullOrWhiteSpace(service.Network?.Ip))
+            return service.Network!.Ip;
+
+        // Must have exactly one parent
+        if (service.RunsOn?.Count != 1)
+            return null;
+
+        var parentName = service.RunsOn.First();
+
+        if (!systemsByName.TryGetValue(parentName, out SystemResource? parent))
+            return null;
+
+        return ResolveSystemIp(parent, systemsByName, cache);
+    }
+
+    private async Task UpdateWithLockAsync(Action<List<Resource>> action) {
         await resourceCollection.FileLock.WaitAsync();
-        try
-        {
+        try {
             action(resourceCollection.Resources);
 
             // Always write current schema version when app writes the file.
-            var root = new YamlRoot
-            {
-                Version = CurrentSchemaVersion,
-                Resources = resourceCollection.Resources
+            var root = new YamlRoot {
+                Version = _currentSchemaVersion,
+                Resources = resourceCollection.Resources,
+                Connections = resourceCollection.Connections
             };
 
             await SaveRootAsync(root);
         }
-        finally
-        {
+        finally {
             resourceCollection.FileLock.Release();
         }
     }
@@ -328,16 +363,19 @@ public sealed class YamlResourceCollection(
     // Versioning + migration
     // ----------------------------
 
-    private async Task BackupOriginalAsync(string originalYaml)
-    {
+    private async Task BackupOriginalAsync(string originalYaml) {
         // Timestamped backup for safe rollback
         var backupPath = $"{filePath}.bak.{DateTime.UtcNow:yyyyMMddHHmmss}";
         await fileStore.WriteAllTextAsync(backupPath, originalYaml);
     }
-    
-    private async Task SaveRootAsync(YamlRoot? root)
-    {
-        var serializer = new SerializerBuilder()
+
+    private async Task SaveRootAsync(YamlRoot? root) {
+        var contents = SerializeRootAsync(root);
+        await fileStore.WriteAllTextAsync(filePath, contents);
+    }
+
+    public static string SerializeRootAsync(YamlRoot? root) {
+        ISerializer serializer = new SerializerBuilder()
             .WithNamingConvention(CamelCaseNamingConvention.Instance)
             .WithTypeConverter(new StorageSizeYamlConverter())
             .WithTypeConverter(new NotesStringYamlConverter())
@@ -348,19 +386,20 @@ public sealed class YamlResourceCollection(
             .Build();
 
         // Preserve ordering: version first, then resources
-        var payload = new OrderedDictionary
-        {
+        Debug.Assert(root != null, nameof(root) + " != null");
+
+        var payload = new OrderedDictionary {
             ["version"] = root.Version,
-            ["resources"] = (root.Resources ?? new List<Resource>()).Select(SerializeResource).ToList()
+            ["resources"] = (root.Resources ?? new List<Resource>()).Select(SerializeResource).ToList(),
+            ["connections"] = root.Connections ?? new List<Connection>()
         };
 
-        await fileStore.WriteAllTextAsync(filePath, serializer.Serialize(payload));
+        return serializer.Serialize(payload);
     }
 
-    private string GetKind(Resource resource)
-    {
-        return resource switch
-        {
+
+    private static string GetKind(Resource resource) {
+        return resource switch {
             Server => "Server",
             Switch => "Switch",
             Firewall => "Firewall",
@@ -376,14 +415,12 @@ public sealed class YamlResourceCollection(
         };
     }
 
-    private OrderedDictionary SerializeResource(Resource resource)
-    {
-        var map = new OrderedDictionary
-        {
+    public static OrderedDictionary SerializeResource(Resource resource) {
+        var map = new OrderedDictionary {
             ["kind"] = GetKind(resource)
         };
 
-        var serializer = new SerializerBuilder()
+        ISerializer serializer = new SerializerBuilder()
             .WithNamingConvention(CamelCaseNamingConvention.Instance)
             .WithTypeConverter(new NotesStringYamlConverter())
             .ConfigureDefaultValuesHandling(
@@ -394,21 +431,45 @@ public sealed class YamlResourceCollection(
 
         var yaml = serializer.Serialize(resource);
 
-        var props = new DeserializerBuilder()
+        Dictionary<string, object?> props = new DeserializerBuilder()
             .Build()
             .Deserialize<Dictionary<string, object?>>(yaml);
 
-        foreach (var (key, value) in props)
+        foreach ((var key, var value) in props)
             if (!string.Equals(key, "kind", StringComparison.OrdinalIgnoreCase))
                 map[key] = value;
 
         return map;
     }
 
+    private static bool PortsMatch(PortReference a, PortReference b) {
+        return a.Resource.Equals(b.Resource, StringComparison.OrdinalIgnoreCase)
+               && a.PortGroup == b.PortGroup
+               && a.PortIndex == b.PortIndex;
+    }
+
+    private async Task UpdateConnectionsWithLockAsync(Action<List<Connection>> action) {
+        await resourceCollection.FileLock.WaitAsync();
+        try {
+            action(resourceCollection.Connections);
+
+            var root = new YamlRoot {
+                Version = _currentSchemaVersion,
+                Resources = resourceCollection.Resources,
+                Connections = resourceCollection.Connections
+            };
+
+            await SaveRootAsync(root);
+        }
+        finally {
+            resourceCollection.FileLock.Release();
+        }
+    }
 }
 
-public class YamlRoot
-{
+public class YamlRoot {
     public int Version { get; set; }
     public List<Resource>? Resources { get; set; }
+
+    public List<Connection>? Connections { get; set; }
 }

+ 5 - 3
RackPeek.Domain/RackPeek.Domain.csproj

@@ -8,9 +8,11 @@
 
     <ItemGroup>
         <PackageReference Include="DocMigrator.Yaml" Version="10.0.3" />
-        <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.3" />
-        <PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.3" />
-        <PackageReference Include="YamlDotNet" Version="16.3.0" />
+        <PackageReference Include="LibGit2Sharp" Version="0.31.0" />
+        <PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.9" />
+        <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" />
+        <PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.9" />
+        <PackageReference Include="YamlDotNet" Version="18.0.0" />
     </ItemGroup>
 
 </Project>

+ 6 - 3
RackPeek.Domain/Resources/AccessPoints/AccessPoint.cs

@@ -1,8 +1,11 @@
+using RackPeek.Domain.Resources.Servers;
+using RackPeek.Domain.Resources.SubResources;
+
 namespace RackPeek.Domain.Resources.AccessPoints;
 
-public class AccessPoint : Hardware.Hardware
-{
+public class AccessPoint : Hardware.Hardware, IPortResource {
     public const string KindLabel = "AccessPoint";
     public string? Model { get; set; }
     public double? Speed { get; set; }
-}
+    public List<Port>? Ports { get; set; }
+}

+ 4 - 6
RackPeek.Domain/Resources/AccessPoints/AccessPointHardwareReport.cs

@@ -12,11 +12,9 @@ public record AccessPointHardwareRow(
     double SpeedGb
 );
 
-public class AccessPointHardwareReportUseCase(IResourceCollection repository) : IUseCase
-{
-    public async Task<AccessPointHardwareReport> ExecuteAsync()
-    {
-        var aps = await repository.GetAllOfTypeAsync<AccessPoint>();
+public class AccessPointHardwareReportUseCase(IResourceCollection repository) : IUseCase {
+    public async Task<AccessPointHardwareReport> ExecuteAsync() {
+        IReadOnlyList<AccessPoint> aps = await repository.GetAllOfTypeAsync<AccessPoint>();
         var rows = aps.Select(ap => new AccessPointHardwareRow(
             ap.Name,
             ap.Model ?? "Unknown",
@@ -25,4 +23,4 @@ public class AccessPointHardwareReportUseCase(IResourceCollection repository) :
 
         return new AccessPointHardwareReport(rows);
     }
-}
+}

+ 5 - 9
RackPeek.Domain/Resources/AccessPoints/UpdateAccessPointUseCase.cs

@@ -3,15 +3,13 @@ using RackPeek.Domain.Persistence;
 
 namespace RackPeek.Domain.Resources.AccessPoints;
 
-public class UpdateAccessPointUseCase(IResourceCollection repository) : IUseCase
-{
+public class UpdateAccessPointUseCase(IResourceCollection repository) : IUseCase {
     public async Task ExecuteAsync(
         string name,
         string? model = null,
         double? speed = null,
         string? notes = null
-    )
-    {
+    ) {
         // ToDo validate / normalize all inputs
 
         name = Normalize.HardwareName(name);
@@ -20,14 +18,12 @@ public class UpdateAccessPointUseCase(IResourceCollection repository) : IUseCase
         if (ap == null)
             throw new NotFoundException($"Access point '{name}' not found.");
 
-        if (!string.IsNullOrWhiteSpace(model))
-        {
+        if (!string.IsNullOrWhiteSpace(model)) {
             ThrowIfInvalid.AccessPointModelName(model);
             ap.Model = model;
         }
 
-        if (speed.HasValue)
-        {
+        if (speed.HasValue) {
             ThrowIfInvalid.NetworkSpeed(speed.Value);
             ap.Speed = speed.Value;
         }
@@ -36,4 +32,4 @@ public class UpdateAccessPointUseCase(IResourceCollection repository) : IUseCase
 
         await repository.UpdateAsync(ap);
     }
-}
+}

+ 74 - 0
RackPeek.Domain/Resources/Connections/AddConnectionUseCase.cs

@@ -0,0 +1,74 @@
+using RackPeek.Domain.Helpers;
+using RackPeek.Domain.Persistence;
+using RackPeek.Domain.Resources.Servers;
+using RackPeek.Domain.Resources.SubResources;
+
+namespace RackPeek.Domain.Resources.Connections;
+
+public interface IAddConnectionUseCase {
+    Task ExecuteAsync(
+        PortReference a,
+        PortReference b,
+        string? label = null,
+        string? notes = null);
+}
+
+public class AddConnectionUseCase(IResourceCollection repository)
+    : IAddConnectionUseCase {
+    public async Task ExecuteAsync(
+        PortReference a,
+        PortReference b,
+        string? label,
+        string? notes) {
+        a.Resource = Normalize.HardwareName(a.Resource);
+        b.Resource = Normalize.HardwareName(b.Resource);
+
+        ThrowIfInvalid.ResourceName(a.Resource);
+        ThrowIfInvalid.ResourceName(b.Resource);
+
+        if (PortsMatch(a, b))
+            throw new InvalidOperationException(
+                "Cannot connect a port to itself.");
+
+        await ValidatePortReference(a);
+        await ValidatePortReference(b);
+
+        // Overwrite behavior:
+        // each PortReference may appear in only one connection,
+        // so remove any existing connection involving either endpoint.
+        await repository.RemoveConnectionsForPortAsync(a);
+        await repository.RemoveConnectionsForPortAsync(b);
+
+        var connection = new Connection {
+            A = a,
+            B = b,
+            Label = label,
+            Notes = notes
+        };
+
+        await repository.AddConnectionAsync(connection);
+    }
+
+    private async Task ValidatePortReference(PortReference port) {
+        Resource resource =
+            await repository.GetByNameAsync<Resource>(port.Resource)
+            ?? throw new NotFoundException($"Resource '{port.Resource}' not found.");
+
+        if (resource is not IPortResource pr || pr.Ports == null)
+            throw new InvalidOperationException($"Resource '{port.Resource}' has no ports.");
+
+        if (port.PortGroup < 0 || port.PortGroup >= pr.Ports.Count)
+            throw new NotFoundException($"Port group {port.PortGroup} not found.");
+
+        Port group = pr.Ports[port.PortGroup];
+
+        if (port.PortIndex < 0 || port.PortIndex >= (group.Count ?? 0))
+            throw new NotFoundException($"Port index {port.PortIndex} not found.");
+    }
+
+    private static bool PortsMatch(PortReference a, PortReference b) {
+        return a.Resource.Equals(b.Resource, StringComparison.OrdinalIgnoreCase)
+               && a.PortGroup == b.PortGroup
+               && a.PortIndex == b.PortIndex;
+    }
+}

+ 17 - 0
RackPeek.Domain/Resources/Connections/Connection.cs

@@ -0,0 +1,17 @@
+namespace RackPeek.Domain.Resources.Connections;
+
+public class Connection {
+    public PortReference A { get; set; } = null!;
+
+    public PortReference B { get; set; } = null!;
+
+    public string? Label { get; set; }
+
+    public string? Notes { get; set; }
+}
+
+public class PortReference {
+    public string Resource { get; set; } = null!;
+    public int PortGroup { get; set; }
+    public int PortIndex { get; set; }
+}

+ 11 - 0
RackPeek.Domain/Resources/Connections/ConnectionHelpers.cs

@@ -0,0 +1,11 @@
+namespace RackPeek.Domain.Resources.Connections;
+
+public static class ConnectionHelpers {
+    public static bool Matches(PortReference a, PortReference b) {
+        return a.Resource == b.Resource
+               && a.PortGroup == b.PortGroup
+               && a.PortIndex == b.PortIndex;
+    }
+
+    public static bool Contains(Connection c, PortReference port) => Matches(c.A, port) || Matches(c.B, port);
+}

+ 19 - 0
RackPeek.Domain/Resources/Connections/GetConnectionForPortUseCase.cs

@@ -0,0 +1,19 @@
+using RackPeek.Domain.Helpers;
+using RackPeek.Domain.Persistence;
+
+namespace RackPeek.Domain.Resources.Connections;
+
+public interface IGetConnectionForPortUseCase {
+    Task<Connection?> ExecuteAsync(PortReference port);
+}
+
+public class GetConnectionForPortUseCase(IResourceCollection repository)
+    : IGetConnectionForPortUseCase {
+    public async Task<Connection?> ExecuteAsync(PortReference port) {
+        port.Resource = Normalize.HardwareName(port.Resource);
+
+        ThrowIfInvalid.ResourceName(port.Resource);
+
+        return await repository.GetConnectionForPortAsync(port);
+    }
+}

+ 19 - 0
RackPeek.Domain/Resources/Connections/GetConnectionsForResourceUseCase.cs

@@ -0,0 +1,19 @@
+using RackPeek.Domain.Helpers;
+using RackPeek.Domain.Persistence;
+
+namespace RackPeek.Domain.Resources.Connections;
+
+public interface IGetConnectionsForResourceUseCase {
+    Task<IReadOnlyList<Connection>> ExecuteAsync(string resource);
+}
+
+public class GetConnectionsForResourceUseCase(IResourceCollection repository)
+    : IGetConnectionsForResourceUseCase {
+    public async Task<IReadOnlyList<Connection>> ExecuteAsync(string resource) {
+        resource = Normalize.HardwareName(resource);
+
+        ThrowIfInvalid.ResourceName(resource);
+
+        return await repository.GetConnectionsForResourceAsync(resource);
+    }
+}

+ 19 - 0
RackPeek.Domain/Resources/Connections/RemoveConnectionUseCase.cs

@@ -0,0 +1,19 @@
+using RackPeek.Domain.Helpers;
+using RackPeek.Domain.Persistence;
+
+namespace RackPeek.Domain.Resources.Connections;
+
+public interface IRemoveConnectionUseCase {
+    Task ExecuteAsync(PortReference port);
+}
+
+public class RemoveConnectionUseCase(IResourceCollection repository)
+    : IRemoveConnectionUseCase {
+    public async Task ExecuteAsync(PortReference port) {
+        port.Resource = Normalize.HardwareName(port.Resource);
+
+        ThrowIfInvalid.ResourceName(port.Resource);
+
+        await repository.RemoveConnectionsForPortAsync(port);
+    }
+}

+ 4 - 6
RackPeek.Domain/Resources/Desktops/DescribeDesktopUseCase.cs

@@ -14,10 +14,8 @@ public record DesktopDescription(
     Dictionary<string, string> Labels
 );
 
-public class DescribeDesktopUseCase(IResourceCollection repository) : IUseCase
-{
-    public async Task<DesktopDescription> ExecuteAsync(string name)
-    {
+public class DescribeDesktopUseCase(IResourceCollection repository) : IUseCase {
+    public async Task<DesktopDescription> ExecuteAsync(string name) {
         name = Normalize.HardwareName(name);
         ThrowIfInvalid.ResourceName(name);
 
@@ -35,9 +33,9 @@ public class DescribeDesktopUseCase(IResourceCollection repository) : IUseCase
             desktop.Cpus?.Count ?? 0,
             ramSummary,
             desktop.Drives?.Count ?? 0,
-            desktop.Nics?.Count ?? 0,
+            desktop.Ports?.Count ?? 0,
             desktop.Gpus?.Count ?? 0,
             desktop.Labels
         );
     }
-}
+}

+ 4 - 5
RackPeek.Domain/Resources/Desktops/Desktop.cs

@@ -3,13 +3,12 @@ using RackPeek.Domain.Resources.SubResources;
 
 namespace RackPeek.Domain.Resources.Desktops;
 
-public class Desktop : Hardware.Hardware, ICpuResource, IDriveResource, IGpuResource, INicResource
-{
+public class Desktop : Hardware.Hardware, ICpuResource, IDriveResource, IGpuResource, IPortResource {
     public const string KindLabel = "Desktop";
     public Ram? Ram { get; set; }
-    public string Model { get; set; }
+    public string? Model { get; set; }
     public List<Cpu>? Cpus { get; set; }
     public List<Drive>? Drives { get; set; }
     public List<Gpu>? Gpus { get; set; }
-    public List<Nic>? Nics { get; set; }
-}
+    public List<Port>? Ports { get; set; }
+}

+ 9 - 13
RackPeek.Domain/Resources/Desktops/DesktopHardwareReport.cs

@@ -19,14 +19,11 @@ public record DesktopHardwareRow(
     string GpuSummary
 );
 
-public class DesktopHardwareReportUseCase(IResourceCollection repository) : IUseCase
-{
-    public async Task<DesktopHardwareReport> ExecuteAsync()
-    {
-        var desktops = await repository.GetAllOfTypeAsync<Desktop>();
+public class DesktopHardwareReportUseCase(IResourceCollection repository) : IUseCase {
+    public async Task<DesktopHardwareReport> ExecuteAsync() {
+        IReadOnlyList<Desktop> desktops = await repository.GetAllOfTypeAsync<Desktop>();
 
-        var rows = desktops.Select(desktop =>
-        {
+        var rows = desktops.Select(desktop => {
             var totalCores = desktop.Cpus?.Sum(c => c.Cores) ?? 0;
             var totalThreads = desktop.Cpus?.Sum(c => c.Threads) ?? 0;
 
@@ -47,15 +44,14 @@ public class DesktopHardwareReportUseCase(IResourceCollection repository) : IUse
                 .Where(d => d.Type == "hdd")
                 .Sum(d => d.Size) ?? 0;
 
-            var nicSummary = desktop.Nics == null
+            var nicSummary = desktop.Ports == null
                 ? "Unknown"
                 : string.Join(", ",
-                    desktop.Nics
+                    desktop.Ports
                         .GroupBy(n => n.Speed ?? 0)
                         .OrderBy(g => g.Key)
-                        .Select(g =>
-                        {
-                            var count = g.Sum(n => n.Ports ?? 0);
+                        .Select(g => {
+                            var count = g.Sum(n => n.Count ?? 0);
                             return $"{count}×{g.Key}G";
                         }));
 
@@ -82,4 +78,4 @@ public class DesktopHardwareReportUseCase(IResourceCollection repository) : IUse
 
         return new DesktopHardwareReport(rows);
     }
-}
+}

+ 11 - 25
RackPeek.Domain/Resources/Desktops/UpdateDesktopUseCase.cs

@@ -4,16 +4,14 @@ using RackPeek.Domain.Resources.SubResources;
 
 namespace RackPeek.Domain.Resources.Desktops;
 
-public class UpdateDesktopUseCase(IResourceCollection repository) : IUseCase
-{
+public class UpdateDesktopUseCase(IResourceCollection repository) : IUseCase {
     public async Task ExecuteAsync(
         string name,
         string? model = null,
         double? ramGb = null,
         int? ramMts = null,
         string? notes = null
-    )
-    {
+    ) {
         // ToDo validate / normalize all inputs
 
         name = Normalize.HardwareName(name);
@@ -27,38 +25,26 @@ public class UpdateDesktopUseCase(IResourceCollection repository) : IUseCase
             desktop.Model = model;
 
         // ---- RAM ----
-        if (ramGb.HasValue)
-        {
+        if (ramGb.HasValue) {
             ThrowIfInvalid.RamGb(ramGb);
             desktop.Ram ??= new Ram();
             desktop.Ram.Size = ramGb.Value;
         }
 
-        if (ramMts.HasValue)
-        {
+        if (ramMts.HasValue) {
             desktop.Ram ??= new Ram();
             desktop.Ram.Mts = ramMts.Value;
         }
-        
-        if (desktop.Ram != null)
-        {
-            if (desktop.Ram.Size == 0)
-            {
-                desktop.Ram.Size = null;
-            }
-            
-            if (desktop.Ram.Mts == 0)
-            {
-                desktop.Ram.Mts = null;
-            }
 
-            if (desktop.Ram.Size == null && desktop.Ram.Mts == null)
-            {
-                desktop.Ram = null;
-            }
+        if (desktop.Ram != null) {
+            if (desktop.Ram.Size == 0) desktop.Ram.Size = null;
+
+            if (desktop.Ram.Mts == 0) desktop.Ram.Mts = null;
+
+            if (desktop.Ram.Size == null && desktop.Ram.Mts == null) desktop.Ram = null;
         }
 
         if (notes != null) desktop.Notes = notes;
         await repository.UpdateAsync(desktop);
     }
-}
+}

+ 6 - 9
RackPeek.Domain/Resources/Firewalls/DescribeFirewallUseCase.cs

@@ -15,10 +15,8 @@ public record FirewallDescription(
     Dictionary<string, string> Labels
 );
 
-public class DescribeFirewallUseCase(IResourceCollection repository) : IUseCase
-{
-    public async Task<FirewallDescription> ExecuteAsync(string name)
-    {
+public class DescribeFirewallUseCase(IResourceCollection repository) : IUseCase {
+    public async Task<FirewallDescription> ExecuteAsync(string name) {
         name = Normalize.HardwareName(name);
         ThrowIfInvalid.ResourceName(name);
 
@@ -27,7 +25,7 @@ public class DescribeFirewallUseCase(IResourceCollection repository) : IUseCase
             throw new NotFoundException($"Firewall '{name}' not found.");
 
         // If no ports exist, return defaults
-        var ports = firewallResource.Ports ?? new List<Port>();
+        List<Port> ports = firewallResource.Ports ?? new List<Port>();
 
         // Total ports count
         var totalPorts = ports.Sum(p => p.Count ?? 0);
@@ -36,10 +34,9 @@ public class DescribeFirewallUseCase(IResourceCollection repository) : IUseCase
         var totalSpeedGb = ports.Sum(p => (p.Speed ?? 0) * (p.Count ?? 0));
 
         // Build a port summary string
-        var portGroups = ports
+        IEnumerable<string> portGroups = ports
             .GroupBy(p => p.Type ?? "Unknown")
-            .Select(g =>
-            {
+            .Select(g => {
                 var count = g.Sum(x => x.Count ?? 0);
                 var speed = g.Sum(x => (x.Speed ?? 0) * (x.Count ?? 0));
                 return $"{g.Key}: {count} ports ({speed} Gb total)";
@@ -58,4 +55,4 @@ public class DescribeFirewallUseCase(IResourceCollection repository) : IUseCase
             firewallResource.Labels
         );
     }
-}
+}

+ 2 - 3
RackPeek.Domain/Resources/Firewalls/Firewall.cs

@@ -3,11 +3,10 @@ using RackPeek.Domain.Resources.SubResources;
 
 namespace RackPeek.Domain.Resources.Firewalls;
 
-public class Firewall : Hardware.Hardware, IPortResource
-{
+public class Firewall : Hardware.Hardware, IPortResource {
     public const string KindLabel = "Firewall";
     public string? Model { get; set; }
     public bool? Managed { get; set; }
     public bool? Poe { get; set; }
     public List<Port>? Ports { get; set; }
-}
+}

+ 7 - 11
RackPeek.Domain/Resources/Firewalls/FirewallHardwareReport.cs

@@ -16,14 +16,11 @@ public record FirewallHardwareRow(
     string PortSummary
 );
 
-public class FirewallHardwareReportUseCase(IResourceCollection repository) : IUseCase
-{
-    public async Task<FirewallHardwareReport> ExecuteAsync()
-    {
-        var firewalls = await repository.GetAllOfTypeAsync<Firewall>();
-
-        var rows = firewalls.Select(sw =>
-        {
+public class FirewallHardwareReportUseCase(IResourceCollection repository) : IUseCase {
+    public async Task<FirewallHardwareReport> ExecuteAsync() {
+        IReadOnlyList<Firewall> firewalls = await repository.GetAllOfTypeAsync<Firewall>();
+
+        var rows = firewalls.Select(sw => {
             var totalPorts = sw.Ports?.Sum(p => p.Count ?? 0) ?? 0;
 
             var maxSpeed = sw.Ports?
@@ -35,8 +32,7 @@ public class FirewallHardwareReportUseCase(IResourceCollection repository) : IUs
                     sw.Ports
                         .GroupBy(p => p.Speed ?? 0)
                         .OrderBy(g => g.Key)
-                        .Select(g =>
-                        {
+                        .Select(g => {
                             var count = g.Sum(p => p.Count ?? 0);
                             return $"{count}×{g.Key}G";
                         }));
@@ -54,4 +50,4 @@ public class FirewallHardwareReportUseCase(IResourceCollection repository) : IUs
 
         return new FirewallHardwareReport(rows);
     }
-}
+}

+ 3 - 5
RackPeek.Domain/Resources/Firewalls/UpdateFirewallUseCase.cs

@@ -3,16 +3,14 @@ using RackPeek.Domain.Persistence;
 
 namespace RackPeek.Domain.Resources.Firewalls;
 
-public class UpdateFirewallUseCase(IResourceCollection repository) : IUseCase
-{
+public class UpdateFirewallUseCase(IResourceCollection repository) : IUseCase {
     public async Task ExecuteAsync(
         string name,
         string? model = null,
         bool? managed = null,
         bool? poe = null,
         string? notes = null
-    )
-    {
+    ) {
         // ToDo validate / normalize all inputs
 
         name = Normalize.HardwareName(name);
@@ -33,4 +31,4 @@ public class UpdateFirewallUseCase(IResourceCollection repository) : IUseCase
         if (notes != null) firewallResource.Notes = notes;
         await repository.UpdateAsync(firewallResource);
     }
-}
+}

+ 10 - 17
RackPeek.Domain/Resources/Hardware/GetHardwareSystemTreeUseCase.cs

@@ -1,15 +1,12 @@
 using RackPeek.Domain.Helpers;
 using RackPeek.Domain.Persistence;
-using RackPeek.Domain.Resources.Services;
 using RackPeek.Domain.Resources.SystemResources;
 
 namespace RackPeek.Domain.Resources.Hardware;
 
 public class GetHardwareSystemTreeUseCase(
-    IResourceCollection repo) : IUseCase
-{
-    public async Task<HardwareDependencyTree> ExecuteAsync(string hardwareName)
-    {
+    IResourceCollection repo) : IUseCase {
+    public async Task<HardwareDependencyTree> ExecuteAsync(string hardwareName) {
         ThrowIfInvalid.ResourceName(hardwareName);
 
         var hardware = await repo.GetByNameAsync(hardwareName) as Hardware;
@@ -19,33 +16,29 @@ public class GetHardwareSystemTreeUseCase(
         return await BuildDependencyTreeAsync(hardware);
     }
 
-    private async Task<HardwareDependencyTree> BuildDependencyTreeAsync(Hardware hardware)
-    {
-        var systems = await repo.GetDependantsAsync(hardware.Name);
+    private async Task<HardwareDependencyTree> BuildDependencyTreeAsync(Hardware hardware) {
+        IReadOnlyList<Resource> systems = await repo.GetDependantsAsync(hardware.Name);
 
         var systemTrees = new List<SystemDependencyTree>();
-        foreach (var system in systems.OfType<SystemResource>())
+        foreach (SystemResource system in systems.OfType<SystemResource>())
             systemTrees.Add(await BuildSystemDependencyTreeAsync(system));
 
         return new HardwareDependencyTree(hardware, systemTrees);
     }
 
-    private async Task<SystemDependencyTree> BuildSystemDependencyTreeAsync(SystemResource system)
-    {
-        var services = await repo.GetDependantsAsync(system.Name);
+    private async Task<SystemDependencyTree> BuildSystemDependencyTreeAsync(SystemResource system) {
+        IReadOnlyList<Resource> services = await repo.GetDependantsAsync(system.Name);
 
         return new SystemDependencyTree(system, services);
     }
 }
 
-public sealed class HardwareDependencyTree(Hardware hardware, IEnumerable<SystemDependencyTree> systems)
-{
+public sealed class HardwareDependencyTree(Hardware hardware, IEnumerable<SystemDependencyTree> systems) {
     public Hardware Hardware { get; } = hardware;
     public IEnumerable<SystemDependencyTree> Systems { get; } = systems;
 }
 
-public sealed class SystemDependencyTree(SystemResource system, IEnumerable<Resource> childResources)
-{
+public sealed class SystemDependencyTree(SystemResource system, IEnumerable<Resource> childResources) {
     public SystemResource System { get; } = system;
     public IEnumerable<Resource> ChildResources { get; } = childResources;
-}
+}

+ 7 - 11
RackPeek.Domain/Resources/Hardware/GetHardwareUseCaseSummary.cs

@@ -1,11 +1,9 @@
 namespace RackPeek.Domain.Resources.Hardware;
 
-public sealed class HardwareSummary
-{
+public sealed class HardwareSummary {
     public HardwareSummary(
         int totalHardware,
-        IReadOnlyDictionary<string, int> hardwareByKind)
-    {
+        IReadOnlyDictionary<string, int> hardwareByKind) {
         TotalHardware = totalHardware;
         HardwareByKind = hardwareByKind;
     }
@@ -14,12 +12,10 @@ public sealed class HardwareSummary
     public IReadOnlyDictionary<string, int> HardwareByKind { get; }
 }
 
-public class GetHardwareUseCaseSummary(IHardwareRepository repository) : IUseCase
-{
-    public async Task<HardwareSummary> ExecuteAsync()
-    {
-        var totalCountTask = repository.GetCountAsync();
-        var kindCountTask = repository.GetKindCountAsync();
+public class GetHardwareUseCaseSummary(IHardwareRepository repository) : IUseCase {
+    public async Task<HardwareSummary> ExecuteAsync() {
+        Task<int> totalCountTask = repository.GetCountAsync();
+        Task<Dictionary<string, int>> kindCountTask = repository.GetKindCountAsync();
 
         await Task.WhenAll(totalCountTask, kindCountTask);
 
@@ -28,4 +24,4 @@ public class GetHardwareUseCaseSummary(IHardwareRepository repository) : IUseCas
             kindCountTask.Result
         );
     }
-}
+}

+ 2 - 3
RackPeek.Domain/Resources/Hardware/Hardware.cs

@@ -1,5 +1,4 @@
 namespace RackPeek.Domain.Resources.Hardware;
 
-public abstract class Hardware : Resource
-{
-}
+public abstract class Hardware : Resource {
+}

+ 4 - 7
RackPeek.Domain/Resources/Hardware/IHardwareRepository.cs

@@ -1,22 +1,19 @@
 namespace RackPeek.Domain.Resources.Hardware;
 
-public interface IHardwareRepository
-{
+public interface IHardwareRepository {
     Task<int> GetCountAsync();
     Task<Dictionary<string, int>> GetKindCountAsync();
 
     public Task<List<HardwareTree>> GetTreeAsync();
 }
 
-public class HardwareTree
-{
+public class HardwareTree {
     public required string HardwareName { get; set; }
     public required string Kind { get; set; }
     public required List<SystemTree> Systems { get; set; }
 }
 
-public class SystemTree
-{
+public class SystemTree {
     public required string SystemName { get; set; }
     public required List<string> Services { get; set; }
-}
+}

+ 2 - 3
RackPeek.Domain/Resources/IResourceRepository.cs

@@ -1,5 +1,4 @@
 namespace RackPeek.Domain.Resources;
 
-public interface IResourceRepository
-{
-}
+public interface IResourceRepository {
+}

+ 3 - 5
RackPeek.Domain/Resources/Laptops/DescribeLaptopUseCase.cs

@@ -3,10 +3,8 @@ using RackPeek.Domain.Persistence;
 
 namespace RackPeek.Domain.Resources.Laptops;
 
-public class DescribeLaptopUseCase(IResourceCollection repository) : IUseCase
-{
-    public async Task<LaptopDescription> ExecuteAsync(string name)
-    {
+public class DescribeLaptopUseCase(IResourceCollection repository) : IUseCase {
+    public async Task<LaptopDescription> ExecuteAsync(string name) {
         name = Normalize.HardwareName(name);
         ThrowIfInvalid.ResourceName(name);
 
@@ -36,4 +34,4 @@ public record LaptopDescription(
     int DriveCount,
     int GpuCount,
     Dictionary<string, string> Labels
-);
+);

+ 2 - 3
RackPeek.Domain/Resources/Laptops/Laptop.cs

@@ -3,12 +3,11 @@ using RackPeek.Domain.Resources.SubResources;
 
 namespace RackPeek.Domain.Resources.Laptops;
 
-public class Laptop : Hardware.Hardware, ICpuResource, IDriveResource, IGpuResource
-{
+public class Laptop : Hardware.Hardware, ICpuResource, IDriveResource, IGpuResource {
     public const string KindLabel = "Laptop";
     public Ram? Ram { get; set; }
     public string? Model { get; set; }
     public List<Cpu>? Cpus { get; set; }
     public List<Drive>? Drives { get; set; }
     public List<Gpu>? Gpus { get; set; }
-}
+}

+ 5 - 8
RackPeek.Domain/Resources/Laptops/LaptopHardwareReportUseCase.cs

@@ -2,14 +2,11 @@ using RackPeek.Domain.Persistence;
 
 namespace RackPeek.Domain.Resources.Laptops;
 
-public class LaptopHardwareReportUseCase(IResourceCollection repository) : IUseCase
-{
-    public async Task<LaptopHardwareReport> ExecuteAsync()
-    {
-        var laptops = await repository.GetAllOfTypeAsync<Laptop>();
+public class LaptopHardwareReportUseCase(IResourceCollection repository) : IUseCase {
+    public async Task<LaptopHardwareReport> ExecuteAsync() {
+        IReadOnlyList<Laptop> laptops = await repository.GetAllOfTypeAsync<Laptop>();
 
-        var rows = laptops.Select(laptop =>
-        {
+        var rows = laptops.Select(laptop => {
             var totalCores = laptop.Cpus?.Sum(c => c.Cores) ?? 0;
             var totalThreads = laptop.Cpus?.Sum(c => c.Threads) ?? 0;
 
@@ -68,4 +65,4 @@ public record LaptopHardwareRow(
     int SsdStorageGb,
     int HddStorageGb,
     string GpuSummary
-);
+);

+ 11 - 25
RackPeek.Domain/Resources/Laptops/UpdateLaptopUseCase.cs

@@ -4,16 +4,14 @@ using RackPeek.Domain.Resources.SubResources;
 
 namespace RackPeek.Domain.Resources.Laptops;
 
-public class UpdateLaptopUseCase(IResourceCollection repository) : IUseCase
-{
+public class UpdateLaptopUseCase(IResourceCollection repository) : IUseCase {
     public async Task ExecuteAsync(
         string name,
         string? model = null,
         double? ramGb = null,
         int? ramMts = null,
         string? notes = null
-    )
-    {
+    ) {
         // ToDo validate / normalize all inputs
 
         name = Normalize.HardwareName(name);
@@ -27,39 +25,27 @@ public class UpdateLaptopUseCase(IResourceCollection repository) : IUseCase
             laptop.Model = model;
 
         // ---- RAM ----
-        if (ramGb.HasValue)
-        {
+        if (ramGb.HasValue) {
             ThrowIfInvalid.RamGb(ramGb);
             laptop.Ram ??= new Ram();
             laptop.Ram.Size = ramGb.Value;
         }
 
-        if (ramMts.HasValue)
-        {
+        if (ramMts.HasValue) {
             laptop.Ram ??= new Ram();
             laptop.Ram.Mts = ramMts.Value;
         }
-        
-        if (laptop.Ram != null)
-        {
-            if (laptop.Ram.Size == 0)
-            {
-                laptop.Ram.Size = null;
-            }
-            
-            if (laptop.Ram.Mts == 0)
-            {
-                laptop.Ram.Mts = null;
-            }
 
-            if (laptop.Ram.Size == null && laptop.Ram.Mts == null)
-            {
-                laptop.Ram = null;
-            }
+        if (laptop.Ram != null) {
+            if (laptop.Ram.Size == 0) laptop.Ram.Size = null;
+
+            if (laptop.Ram.Mts == 0) laptop.Ram.Mts = null;
+
+            if (laptop.Ram.Size == null && laptop.Ram.Mts == null) laptop.Ram = null;
         }
 
 
         if (notes != null) laptop.Notes = notes;
         await repository.UpdateAsync(laptop);
     }
-}
+}

+ 28 - 40
RackPeek.Domain/Resources/Resource.cs

@@ -12,37 +12,11 @@ using RackPeek.Domain.Resources.UpsUnits;
 
 namespace RackPeek.Domain.Resources;
 
-public abstract class Resource
-{
-    private static readonly string[] HardwareTypes =
+public abstract class Resource {
+    private static readonly string[] _hardwareTypes =
         ["server", "switch", "firewall", "router", "accesspoint", "desktop", "laptop", "ups", "other"];
 
-    public static bool IsHardware(string kind)
-    {
-        kind = kind.Trim().ToLower();
-        return kind == "hardware" || HardwareTypes.Contains(kind);
-    } 
-        
-    public static string GetResourceUrl(string kind, string name)
-    {
-        var encoded = Uri.EscapeDataString(name);
-
-        kind = kind.Trim().ToLower();
-        if (IsHardware(kind))
-        {
-            return $"resources/hardware/{encoded}";
-        }else if (kind == "system")
-        {
-            return $"resources/systems/{encoded}";
-        }else if (kind == "service")
-        {
-            return $"resources/services/{encoded}";
-        }
-
-        return "#";
-    }
-    
-    private static readonly Dictionary<string, string> KindToPluralDictionary = new()
+    private static readonly Dictionary<string, string> _kindToPluralDictionary = new()
     {
         { "hardware", "hardware" },
         { "server", "servers" },
@@ -58,7 +32,7 @@ public abstract class Resource
         { "service", "services" }
     };
 
-    private static readonly Dictionary<Type, string> TypeToKindMap = new()
+    private static readonly Dictionary<Type, string> _typeToKindMap = new()
     {
         { typeof(Hardware.Hardware), "Hardware" },
         { typeof(Server), "Server" },
@@ -82,24 +56,38 @@ public abstract class Resource
     public Dictionary<string, string> Labels { get; set; } = new();
     public string? Notes { get; set; }
 
-    public List<string> RunsOn { get; set; } = new List<string>();
+    public List<string> RunsOn { get; set; } = new();
 
-    public static string KindToPlural(string kind)
-    {
-        return KindToPluralDictionary.GetValueOrDefault(kind.ToLower().Trim(), kind);
+    public static bool IsHardware(string kind) {
+        kind = kind.Trim().ToLower();
+        return kind == "hardware" || _hardwareTypes.Contains(kind);
     }
 
-    public static string GetKind<T>() where T : Resource
-    {
-        if (TypeToKindMap.TryGetValue(typeof(T), out var kind))
+    public static string GetResourceUrl(string kind, string name) {
+        var encoded = Uri.EscapeDataString(name);
+
+        kind = kind.Trim().ToLower();
+        if (IsHardware(kind)) return $"resources/hardware/{encoded}";
+
+        if (kind == "system") return $"resources/systems/{encoded}";
+
+        if (kind == "service") return $"resources/services/{encoded}";
+
+        return "#";
+    }
+
+    public static string KindToPlural(string kind) =>
+        _kindToPluralDictionary.GetValueOrDefault(kind.ToLower().Trim(), kind);
+
+    public static string GetKind<T>() where T : Resource {
+        if (_typeToKindMap.TryGetValue(typeof(T), out var kind))
             return kind;
 
         throw new InvalidOperationException(
             $"No kind mapping defined for type {typeof(T).Name}");
     }
 
-    public static bool CanRunOn<T>(Resource parent) where T : Resource
-    {
+    public static bool CanRunOn<T>(Resource parent) where T : Resource {
         var childKind = GetKind<T>().ToLowerInvariant();
         var parentKind = parent.Kind.ToLowerInvariant();
 
@@ -110,7 +98,7 @@ public abstract class Resource
         // System -> Hardware
         if (childKind == "system" && parent is Hardware.Hardware)
             return true;
-        
+
         // System -> System
         if (childKind == "system" && parent is SystemResource)
             return true;

+ 6 - 9
RackPeek.Domain/Resources/Routers/DescribeRouterUseCase.cs

@@ -15,10 +15,8 @@ public record RouterDescription(
     Dictionary<string, string> Labels
 );
 
-public class DescribeRouterUseCase(IResourceCollection repository) : IUseCase
-{
-    public async Task<RouterDescription> ExecuteAsync(string name)
-    {
+public class DescribeRouterUseCase(IResourceCollection repository) : IUseCase {
+    public async Task<RouterDescription> ExecuteAsync(string name) {
         name = Normalize.HardwareName(name);
         ThrowIfInvalid.ResourceName(name);
 
@@ -27,7 +25,7 @@ public class DescribeRouterUseCase(IResourceCollection repository) : IUseCase
             throw new NotFoundException($"Router '{name}' not found.");
 
         // If no ports exist, return defaults
-        var ports = routerResource.Ports ?? new List<Port>();
+        List<Port> ports = routerResource.Ports ?? new List<Port>();
 
         // Total ports count
         var totalPorts = ports.Sum(p => p.Count ?? 0);
@@ -36,10 +34,9 @@ public class DescribeRouterUseCase(IResourceCollection repository) : IUseCase
         var totalSpeedGb = ports.Sum(p => (p.Speed ?? 0) * (p.Count ?? 0));
 
         // Build a port summary string
-        var portGroups = ports
+        IEnumerable<string> portGroups = ports
             .GroupBy(p => p.Type ?? "Unknown")
-            .Select(g =>
-            {
+            .Select(g => {
                 var count = g.Sum(x => x.Count ?? 0);
                 var speed = g.Sum(x => (x.Speed ?? 0) * (x.Count ?? 0));
                 return $"{g.Key}: {count} ports ({speed} Gb total)";
@@ -58,4 +55,4 @@ public class DescribeRouterUseCase(IResourceCollection repository) : IUseCase
             routerResource.Labels
         );
     }
-}
+}

+ 2 - 3
RackPeek.Domain/Resources/Routers/Router.cs

@@ -3,11 +3,10 @@ using RackPeek.Domain.Resources.SubResources;
 
 namespace RackPeek.Domain.Resources.Routers;
 
-public class Router : Hardware.Hardware, IPortResource
-{
+public class Router : Hardware.Hardware, IPortResource {
     public const string KindLabel = "Router";
     public string? Model { get; set; }
     public bool? Managed { get; set; }
     public bool? Poe { get; set; }
     public List<Port>? Ports { get; set; }
-}
+}

+ 7 - 11
RackPeek.Domain/Resources/Routers/RouterHardwareReport.cs

@@ -16,14 +16,11 @@ public record RouterHardwareRow(
     string PortSummary
 );
 
-public class RouterHardwareReportUseCase(IResourceCollection repository) : IUseCase
-{
-    public async Task<RouterHardwareReport> ExecuteAsync()
-    {
-        var routers = await repository.GetAllOfTypeAsync<Router>();
-
-        var rows = routers.Select(sw =>
-        {
+public class RouterHardwareReportUseCase(IResourceCollection repository) : IUseCase {
+    public async Task<RouterHardwareReport> ExecuteAsync() {
+        IReadOnlyList<Router> routers = await repository.GetAllOfTypeAsync<Router>();
+
+        var rows = routers.Select(sw => {
             var totalPorts = sw.Ports?.Sum(p => p.Count ?? 0) ?? 0;
 
             var maxSpeed = sw.Ports?
@@ -35,8 +32,7 @@ public class RouterHardwareReportUseCase(IResourceCollection repository) : IUseC
                     sw.Ports
                         .GroupBy(p => p.Speed ?? 0)
                         .OrderBy(g => g.Key)
-                        .Select(g =>
-                        {
+                        .Select(g => {
                             var count = g.Sum(p => p.Count ?? 0);
                             return $"{count}×{g.Key}G";
                         }));
@@ -54,4 +50,4 @@ public class RouterHardwareReportUseCase(IResourceCollection repository) : IUseC
 
         return new RouterHardwareReport(rows);
     }
-}
+}

+ 3 - 5
RackPeek.Domain/Resources/Routers/UpdateRouterUseCase.cs

@@ -3,16 +3,14 @@ using RackPeek.Domain.Persistence;
 
 namespace RackPeek.Domain.Resources.Routers;
 
-public class UpdateRouterUseCase(IResourceCollection repository) : IUseCase
-{
+public class UpdateRouterUseCase(IResourceCollection repository) : IUseCase {
     public async Task ExecuteAsync(
         string name,
         string? model = null,
         bool? managed = null,
         bool? poe = null,
         string? notes = null
-    )
-    {
+    ) {
         // ToDo pass in properties as inputs, construct the entity in the usecase
         // ToDo validate / normalize all inputs
 
@@ -34,4 +32,4 @@ public class UpdateRouterUseCase(IResourceCollection repository) : IUseCase
         if (notes != null) routerResource.Notes = notes;
         await repository.UpdateAsync(routerResource);
     }
-}
+}

+ 4 - 6
RackPeek.Domain/Resources/Servers/DescribeServerUseCase.cs

@@ -14,10 +14,8 @@ public record ServerDescription(
     bool Ipmi
 );
 
-public class DescribeServerUseCase(IResourceCollection repository) : IUseCase
-{
-    public async Task<ServerDescription> ExecuteAsync(string name)
-    {
+public class DescribeServerUseCase(IResourceCollection repository) : IUseCase {
+    public async Task<ServerDescription> ExecuteAsync(string name) {
         name = Normalize.HardwareName(name);
         ThrowIfInvalid.ResourceName(name);
 
@@ -39,8 +37,8 @@ public class DescribeServerUseCase(IResourceCollection repository) : IUseCase
             server.Cpus?.Sum(c => c.Threads) ?? 0,
             server.Ram?.Size ?? 0,
             server.Drives?.Sum(d => d.Size) ?? 0,
-            server.Nics?.Sum(n => n.Ports) ?? 0,
+            server.Ports?.Sum(n => n.Count) ?? 0,
             server.Ipmi ?? false
         );
     }
-}
+}

+ 2 - 3
RackPeek.Domain/Resources/Servers/ICpuResource.cs

@@ -2,7 +2,6 @@ using RackPeek.Domain.Resources.SubResources;
 
 namespace RackPeek.Domain.Resources.Servers;
 
-public interface ICpuResource
-{
+public interface ICpuResource {
     public List<Cpu>? Cpus { get; set; }
-}
+}

+ 2 - 3
RackPeek.Domain/Resources/Servers/IDriveResource.cs

@@ -2,7 +2,6 @@ using RackPeek.Domain.Resources.SubResources;
 
 namespace RackPeek.Domain.Resources.Servers;
 
-public interface IDriveResource
-{
+public interface IDriveResource {
     public List<Drive>? Drives { get; set; }
-}
+}

+ 2 - 3
RackPeek.Domain/Resources/Servers/IGpuResource.cs

@@ -2,7 +2,6 @@ using RackPeek.Domain.Resources.SubResources;
 
 namespace RackPeek.Domain.Resources.Servers;
 
-public interface IGpuResource
-{
+public interface IGpuResource {
     public List<Gpu>? Gpus { get; set; }
-}
+}

+ 0 - 8
RackPeek.Domain/Resources/Servers/INicResource.cs

@@ -1,8 +0,0 @@
-using RackPeek.Domain.Resources.SubResources;
-
-namespace RackPeek.Domain.Resources.Servers;
-
-public interface INicResource
-{
-    public List<Nic>? Nics { get; set; }
-}

+ 2 - 3
RackPeek.Domain/Resources/Servers/IPortResource.cs

@@ -2,7 +2,6 @@ using RackPeek.Domain.Resources.SubResources;
 
 namespace RackPeek.Domain.Resources.Servers;
 
-public interface IPortResource
-{
+public interface IPortResource {
     public List<Port>? Ports { get; set; }
-}
+}

+ 3 - 4
RackPeek.Domain/Resources/Servers/Server.cs

@@ -2,13 +2,12 @@ using RackPeek.Domain.Resources.SubResources;
 
 namespace RackPeek.Domain.Resources.Servers;
 
-public class Server : Hardware.Hardware, ICpuResource, IDriveResource, IGpuResource, INicResource
-{
+public class Server : Hardware.Hardware, ICpuResource, IDriveResource, IGpuResource, IPortResource {
     public const string KindLabel = "Server";
     public Ram? Ram { get; set; }
     public bool? Ipmi { get; set; }
     public List<Cpu>? Cpus { get; set; }
     public List<Drive>? Drives { get; set; }
     public List<Gpu>? Gpus { get; set; }
-    public List<Nic>? Nics { get; set; }
-}
+    public List<Port>? Ports { get; set; }
+}

+ 24 - 29
RackPeek.Domain/Resources/Servers/ServerHardwareReport.cs

@@ -21,34 +21,29 @@ public record ServerHardwareRow(
     int GpuCount,
     int TotalGpuVramGb,
     string GpuSummary,
-    bool Ipmi, 
-    IReadOnlyList<Nic> Nics
-)
-{        
-public string NicSummary =>
-string.Join(", ",
-    (Nics ?? [])
-    .SelectMany(n =>
-    {
-        var ports = n.Ports ?? 1;
-        var speed = n.Speed ?? 0;
-        return Enumerable.Repeat(speed, ports);
-    })
-    .GroupBy(speed => speed)
-    .OrderByDescending(g => g.Key)
-    .Select(g => $"{g.Count()}×{g.Key}G")
-    .DefaultIfEmpty("none")
-);
+    bool Ipmi,
+    IReadOnlyList<Port> Ports
+) {
+    public string NicSummary =>
+        string.Join(", ",
+            (Ports ?? [])
+            .SelectMany(n => {
+                var ports = n.Count ?? 1;
+                var speed = n.Speed ?? 0;
+                return Enumerable.Repeat(speed, ports);
+            })
+            .GroupBy(speed => speed)
+            .OrderByDescending(g => g.Key)
+            .Select(g => $"{g.Count()}×{g.Key}G")
+            .DefaultIfEmpty("none")
+        );
 }
 
-public class ServerHardwareReportUseCase(IResourceCollection repository) : IUseCase
-{
-    public async Task<ServerHardwareReport> ExecuteAsync()
-    {
-        var servers = await repository.GetAllOfTypeAsync<Server>();
+public class ServerHardwareReportUseCase(IResourceCollection repository) : IUseCase {
+    public async Task<ServerHardwareReport> ExecuteAsync() {
+        IReadOnlyList<Server> servers = await repository.GetAllOfTypeAsync<Server>();
 
-        var rows = servers.Select(server =>
-        {
+        var rows = servers.Select(server => {
             var totalCores = server.Cpus?.Sum(c => c.Cores) ?? 0;
             var totalThreads = server.Cpus?.Sum(c => c.Threads) ?? 0;
 
@@ -69,8 +64,8 @@ public class ServerHardwareReportUseCase(IResourceCollection repository) : IUseC
                 .Where(d => d.Type == "hdd")
                 .Sum(d => d.Size) ?? 0;
 
-            var totalNicPorts = server.Nics?.Sum(n => n.Ports) ?? 0;
-            var maxNicSpeed = server.Nics?.Max(n => n.Speed) ?? 0;
+            var totalNicPorts = server.Ports?.Sum(n => n.Count) ?? 0;
+            var maxNicSpeed = server.Ports?.Max(n => n.Speed) ?? 0;
 
             var gpuCount = server.Gpus?.Count ?? 0;
 
@@ -100,10 +95,10 @@ public class ServerHardwareReportUseCase(IResourceCollection repository) : IUseC
                 totalGpuVram,
                 gpuSummary,
                 server.Ipmi ?? false,
-                server.Nics ?? new List<Nic>()
+                server.Ports ?? new List<Port>()
             );
         }).ToList();
 
         return new ServerHardwareReport(rows);
     }
-}
+}

+ 10 - 24
RackPeek.Domain/Resources/Servers/UpdateServerUseCase.cs

@@ -4,16 +4,14 @@ using RackPeek.Domain.Resources.SubResources;
 
 namespace RackPeek.Domain.Resources.Servers;
 
-public class UpdateServerUseCase(IResourceCollection repository) : IUseCase
-{
+public class UpdateServerUseCase(IResourceCollection repository) : IUseCase {
     public async Task ExecuteAsync(
         string name,
         double? ramGb = null,
         int? ramMts = null,
         bool? ipmi = null,
         string? notes = null
-    )
-    {
+    ) {
         // ToDo pass in properties as inputs, construct the entity in the usecase, ensure optional inputs are nullable
         // ToDo validate / normalize all inputs
 
@@ -25,35 +23,23 @@ public class UpdateServerUseCase(IResourceCollection repository) : IUseCase
             throw new NotFoundException($"Server '{name}' not found.");
 
         // ---- RAM ----
-        if (ramGb.HasValue)
-        {
+        if (ramGb.HasValue) {
             ThrowIfInvalid.RamGb(ramGb);
             server.Ram ??= new Ram();
             server.Ram.Size = ramGb.Value;
         }
 
-        if (ramMts.HasValue)
-        {
+        if (ramMts.HasValue) {
             server.Ram ??= new Ram();
             server.Ram.Mts = ramMts.Value;
         }
 
-        if (server.Ram != null)
-        {
-            if (server.Ram.Size == 0)
-            {
-                server.Ram.Size = null;
-            }
-            
-            if (server.Ram.Mts == 0)
-            {
-                server.Ram.Mts = null;
-            }
+        if (server.Ram != null) {
+            if (server.Ram.Size == 0) server.Ram.Size = null;
 
-            if (server.Ram.Size == null && server.Ram.Mts == null)
-            {
-                server.Ram = null;
-            }
+            if (server.Ram.Mts == 0) server.Ram.Mts = null;
+
+            if (server.Ram.Size == null && server.Ram.Mts == null) server.Ram = null;
         }
 
         // ---- IPMI ----
@@ -61,4 +47,4 @@ public class UpdateServerUseCase(IResourceCollection repository) : IUseCase
         if (notes != null) server.Notes = notes;
         await repository.UpdateAsync(server);
     }
-}
+}

+ 2 - 3
RackPeek.Domain/Resources/Services/IServiceRepository.cs

@@ -1,9 +1,8 @@
 namespace RackPeek.Domain.Resources.Services;
 
-public interface IServiceRepository
-{
+public interface IServiceRepository {
     Task<int> GetCountAsync();
     Task<int> GetIpAddressCountAsync();
 
     Task<IReadOnlyList<Service>> GetBySystemHostAsync(string name);
-}
+}

+ 6 - 15
RackPeek.Domain/Resources/Services/Networking/Cidr.cs

@@ -1,30 +1,21 @@
 namespace RackPeek.Domain.Resources.Services.Networking;
 
-public readonly struct Cidr
-{
+public readonly struct Cidr {
     public uint Network { get; }
     public uint Mask { get; }
     public int Prefix { get; }
 
-    public Cidr(uint network, uint mask, int prefix)
-    {
+    public Cidr(uint network, uint mask, int prefix) {
         Network = network;
         Mask = mask;
         Prefix = prefix;
     }
 
-    public bool Contains(uint ip)
-    {
-        return (ip & Mask) == Network;
-    }
+    public bool Contains(uint ip) => (ip & Mask) == Network;
 
-    public override string ToString()
-    {
-        return $"{IpHelper.ToIp(Network)}/{Prefix}";
-    }
+    public override string ToString() => $"{IpHelper.ToIp(Network)}/{Prefix}";
 
-    public static Cidr Parse(string cidr)
-    {
+    public static Cidr Parse(string cidr) {
         var parts = cidr.Split('/');
         if (parts.Length != 2)
             throw new ArgumentException($"CIDR must be in format a.b.c.d/nn: {cidr}");
@@ -37,4 +28,4 @@ public readonly struct Cidr
 
         return new Cidr(network, mask, prefix);
     }
-}
+}

+ 5 - 9
RackPeek.Domain/Resources/Services/Networking/IpHelper.cs

@@ -1,9 +1,7 @@
 namespace RackPeek.Domain.Resources.Services.Networking;
 
-public static class IpHelper
-{
-    public static uint ToUInt32(string ip)
-    {
+public static class IpHelper {
+    public static uint ToUInt32(string ip) {
         var parts = ip.Split('.');
         if (parts.Length != 4)
             throw new ArgumentException($"Invalid IPv4 address: {ip}");
@@ -15,8 +13,7 @@ public static class IpHelper
             int.Parse(parts[3]));
     }
 
-    public static string ToIp(uint ip)
-    {
+    public static string ToIp(uint ip) {
         return string.Join('.',
             (ip >> 24) & 0xFF,
             (ip >> 16) & 0xFF,
@@ -24,11 +21,10 @@ public static class IpHelper
             ip & 0xFF);
     }
 
-    public static uint MaskFromPrefix(int prefix)
-    {
+    public static uint MaskFromPrefix(int prefix) {
         if (prefix < 0 || prefix > 32)
             throw new ArgumentException($"Invalid CIDR prefix: {prefix}");
 
         return prefix == 0 ? 0 : uint.MaxValue << (32 - prefix);
     }
-}
+}

+ 6 - 11
RackPeek.Domain/Resources/Services/Service.cs

@@ -2,24 +2,20 @@ using System.Text;
 
 namespace RackPeek.Domain.Resources.Services;
 
-public class Service : Resource
-{
+public class Service : Resource {
     public const string KindLabel = "Service";
     public Network? Network { get; set; }
 
-    public string NetworkString()
-    {
+    public string NetworkString() {
         if (Network == null) return string.Empty;
 
         if (!string.IsNullOrEmpty(Network.Url)) return Network.Url;
 
         var stringBuilder = new StringBuilder();
-        if (!string.IsNullOrEmpty(Network.Ip))
-        {
+        if (!string.IsNullOrEmpty(Network.Ip)) {
             stringBuilder.Append("Ip: ");
             stringBuilder.Append(Network.Ip);
-            if (Network.Port.HasValue)
-            {
+            if (Network.Port.HasValue) {
                 stringBuilder.Append(':');
                 stringBuilder.Append(Network.Port.Value);
             }
@@ -31,10 +27,9 @@ public class Service : Resource
     }
 }
 
-public class Network
-{
+public class Network {
     public string? Ip { get; set; }
     public int? Port { get; set; }
     public string? Protocol { get; set; }
     public string? Url { get; set; }
-}
+}

+ 5 - 14
RackPeek.Domain/Resources/Services/UseCases/DescribeServiceUseCase.cs

@@ -15,30 +15,21 @@ public record ServiceDescription(
     Dictionary<string, string> Labels
 );
 
-public class DescribeServiceUseCase(IResourceCollection repository) : IUseCase
-{
-    public async Task<ServiceDescription> ExecuteAsync(string name)
-    {
+public class DescribeServiceUseCase(IResourceCollection repository) : IUseCase {
+    public async Task<ServiceDescription> ExecuteAsync(string name) {
         name = Normalize.ServiceName(name);
         ThrowIfInvalid.ResourceName(name);
         var service = await repository.GetByNameAsync(name) as Service;
         if (service is null)
             throw new NotFoundException($"Service '{name}' not found.");
 
-        List<string> runsOnPhysicalHost = new List<string>();
-        foreach (var systemName in service.RunsOn)
-        {
+        var runsOnPhysicalHost = new List<string>();
+        foreach (var systemName in service.RunsOn) {
             var systemResource = await repository.GetByNameAsync(systemName) as SystemResource;
             if (systemResource is not null)
-            {
-                foreach(var physicalName in systemResource.RunsOn)
-                {
+                foreach (var physicalName in systemResource.RunsOn)
                     if (!runsOnPhysicalHost.Contains(physicalName))
-                    {
                         runsOnPhysicalHost.Add(physicalName);
-                    }
-                }
-            }
         }
 
         return new ServiceDescription(

+ 6 - 9
RackPeek.Domain/Resources/Services/UseCases/GetServiceSummaryUseCase.cs

@@ -1,17 +1,14 @@
 namespace RackPeek.Domain.Resources.Services.UseCases;
 
-public sealed class AllServicesSummary(int totalServices, int totalIpAddresses)
-{
+public sealed class AllServicesSummary(int totalServices, int totalIpAddresses) {
     public int TotalServices { get; } = totalServices;
     public int TotalIpAddresses { get; } = totalIpAddresses;
 }
 
-public class GetServiceSummaryUseCase(IServiceRepository repository) : IUseCase
-{
-    public async Task<AllServicesSummary> ExecuteAsync()
-    {
-        var serviceCountTask = repository.GetCountAsync();
-        var ipAddressCountTask = repository.GetIpAddressCountAsync();
+public class GetServiceSummaryUseCase(IServiceRepository repository) : IUseCase {
+    public async Task<AllServicesSummary> ExecuteAsync() {
+        Task<int> serviceCountTask = repository.GetCountAsync();
+        Task<int> ipAddressCountTask = repository.GetIpAddressCountAsync();
 
         await Task.WhenAll(serviceCountTask, ipAddressCountTask);
 
@@ -20,4 +17,4 @@ public class GetServiceSummaryUseCase(IServiceRepository repository) : IUseCase
             ipAddressCountTask.Result
         );
     }
-}
+}

+ 10 - 19
RackPeek.Domain/Resources/Services/UseCases/ServiceReportUseCase.cs

@@ -16,29 +16,20 @@ public record ServiceReportRow(
     List<string>? RunsOnPhysicalHost
 );
 
-public class ServiceReportUseCase(IResourceCollection repository) : IUseCase
-{
-    public async Task<ServiceReport> ExecuteAsync()
-    {
-        var services = await repository.GetAllOfTypeAsync<Service>();
+public class ServiceReportUseCase(IResourceCollection repository) : IUseCase {
+    public async Task<ServiceReport> ExecuteAsync() {
+        IReadOnlyList<Service> services = await repository.GetAllOfTypeAsync<Service>();
 
-        var rows = services.Select(async s =>
-        {
-            List<string> runsOnPhysicalHost = new List<string>();
+        var rows = services.Select(async s => {
+            var runsOnPhysicalHost = new List<string>();
             if (s.RunsOn is not null)
-            {
-                foreach (var system in s.RunsOn)
-                {
-                    var systemResource = await repository.GetByNameAsync(system);
+                foreach (var system in s.RunsOn) {
+                    Resource? systemResource = await repository.GetByNameAsync(system);
                     if (systemResource?.RunsOn is not null)
-                    {
                         foreach (var parent in systemResource.RunsOn)
-                        {
-                            if (!runsOnPhysicalHost.Contains(parent)) runsOnPhysicalHost.Add(parent);
-                        }
-                    }
+                            if (!runsOnPhysicalHost.Contains(parent))
+                                runsOnPhysicalHost.Add(parent);
                 }
-            }
 
             return new ServiceReportRow(
                 s.Name,
@@ -51,7 +42,7 @@ public class ServiceReportUseCase(IResourceCollection repository) : IUseCase
             );
         }).ToList();
 
-        var result = await Task.WhenAll(rows);
+        ServiceReportRow[] result = await Task.WhenAll(rows);
         return new ServiceReport(result);
     }
 }

+ 12 - 25
RackPeek.Domain/Resources/Services/UseCases/ServiceSubnetsUseCase.cs

@@ -3,22 +3,17 @@ using RackPeek.Domain.Resources.Services.Networking;
 
 namespace RackPeek.Domain.Resources.Services.UseCases;
 
-public class ServiceSubnetsUseCase(IResourceCollection repo) : IUseCase
-{
-    public async Task<ServiceSubnetsResult> ExecuteAsync(string? cidr, int? prefix, CancellationToken token)
-    {
-        var services = await repo.GetAllOfTypeAsync<Service>();
+public class ServiceSubnetsUseCase(IResourceCollection repo) : IUseCase {
+    public async Task<ServiceSubnetsResult> ExecuteAsync(string? cidr, int? prefix, CancellationToken token) {
+        IReadOnlyList<Service> services = await repo.GetAllOfTypeAsync<Service>();
 
         // If CIDR is provided → filter mode
-        if (cidr is not null)
-        {
+        if (cidr is not null) {
             Cidr parsed;
-            try
-            {
+            try {
                 parsed = Cidr.Parse(cidr);
             }
-            catch
-            {
+            catch {
                 return ServiceSubnetsResult.InvalidCidr(cidr);
             }
 
@@ -56,8 +51,7 @@ public record SubnetSummary(string Cidr, int Count);
 
 public record ServiceSummary(string Name, string Ip, List<string>? RunsOn);
 
-public class ServiceSubnetsResult
-{
+public class ServiceSubnetsResult {
     public bool IsInvalidCidr { get; private set; }
     public string? InvalidCidrValue { get; private set; }
 
@@ -66,18 +60,11 @@ public class ServiceSubnetsResult
     public List<SubnetSummary> Subnets { get; private set; } = new();
     public List<ServiceSummary> Services { get; private set; } = new();
 
-    public static ServiceSubnetsResult InvalidCidr(string cidr)
-    {
-        return new ServiceSubnetsResult { IsInvalidCidr = true, InvalidCidrValue = cidr };
-    }
+    public static ServiceSubnetsResult InvalidCidr(string cidr) =>
+        new() { IsInvalidCidr = true, InvalidCidrValue = cidr };
 
-    public static ServiceSubnetsResult FromSubnets(List<SubnetSummary> subnets)
-    {
-        return new ServiceSubnetsResult { Subnets = subnets };
-    }
+    public static ServiceSubnetsResult FromSubnets(List<SubnetSummary> subnets) => new() { Subnets = subnets };
 
-    public static ServiceSubnetsResult FromServices(List<ServiceSummary> services, string cidr)
-    {
-        return new ServiceSubnetsResult { Services = services, FilteredCidr = cidr };
-    }
+    public static ServiceSubnetsResult FromServices(List<ServiceSummary> services, string cidr) =>
+        new() { Services = services, FilteredCidr = cidr };
 }

+ 9 - 17
RackPeek.Domain/Resources/Services/UseCases/UpdateServiceUseCase.cs

@@ -3,8 +3,7 @@ using RackPeek.Domain.Persistence;
 
 namespace RackPeek.Domain.Resources.Services.UseCases;
 
-public class UpdateServiceUseCase(IResourceCollection repository) : IUseCase
-{
+public class UpdateServiceUseCase(IResourceCollection repository) : IUseCase {
     public async Task ExecuteAsync(
         string name,
         string? ip = null,
@@ -13,8 +12,7 @@ public class UpdateServiceUseCase(IResourceCollection repository) : IUseCase
         string? url = null,
         List<string>? runsOn = null,
         string? notes = null
-    )
-    {
+    ) {
         // ToDo pass in properties as inputs, construct the entity in the usecase, ensure optional inputs are nullable
         // ToDo validate / normalize all inputs
 
@@ -24,42 +22,36 @@ public class UpdateServiceUseCase(IResourceCollection repository) : IUseCase
         if (service is null)
             throw new NotFoundException($"Service '{name}' not found.");
 
-        if (ip != null)
-        {
+        if (ip != null) {
             service.Network ??= new Network();
             service.Network.Ip = ip;
         }
 
-        if (protocol != null)
-        {
+        if (protocol != null) {
             service.Network ??= new Network();
             service.Network.Protocol = protocol;
         }
 
-        if (url != null)
-        {
+        if (url != null) {
             service.Network ??= new Network();
             service.Network.Url = url;
         }
 
-        if (port.HasValue)
-        {
+        if (port.HasValue) {
             service.Network ??= new Network();
             service.Network.Port = port.Value;
         }
 
-        if (runsOn is not null)
-        {
+        if (runsOn is not null) {
             var normalizedParents = new List<string>();
 
             foreach (var parent in runsOn
                          .Where(p => !string.IsNullOrWhiteSpace(p))
                          .Select(p => p.Trim())
-                         .Distinct(StringComparer.OrdinalIgnoreCase))
-            {
+                         .Distinct(StringComparer.OrdinalIgnoreCase)) {
                 ThrowIfInvalid.ResourceName(parent);
 
-                var parentSystem = await repository.GetByNameAsync(parent);
+                Resource? parentSystem = await repository.GetByNameAsync(parent);
 
                 if (parentSystem == null)
                     throw new NotFoundException($"Parent system '{parent}' not found.");

+ 3 - 7
RackPeek.Domain/Resources/SubResources/Cpu.cs

@@ -1,13 +1,9 @@
 namespace RackPeek.Domain.Resources.SubResources;
 
-public class Cpu
-{
+public class Cpu {
     public string? Model { get; set; }
     public int? Cores { get; set; }
     public int? Threads { get; set; }
 
-    public override string ToString()
-    {
-        return $"{Model} {Cores} {Threads}";
-    }
-}
+    public override string ToString() => $"{Model} {Cores} {Threads}";
+}

+ 2 - 3
RackPeek.Domain/Resources/SubResources/Drive.cs

@@ -1,7 +1,6 @@
 namespace RackPeek.Domain.Resources.SubResources;
 
-public class Drive
-{
+public class Drive {
     public static readonly string[] ValidDriveTypes =
     {
         // Flash storage
@@ -16,4 +15,4 @@ public class Drive
 
     public string? Type { get; set; }
     public int? Size { get; set; }
-}
+}

+ 2 - 3
RackPeek.Domain/Resources/SubResources/Gpu.cs

@@ -1,7 +1,6 @@
 namespace RackPeek.Domain.Resources.SubResources;
 
-public class Gpu
-{
+public class Gpu {
     public string? Model { get; set; }
     public int? Vram { get; set; }
-}
+}

+ 2 - 3
RackPeek.Domain/Resources/SubResources/Nic.cs

@@ -1,7 +1,6 @@
 namespace RackPeek.Domain.Resources.SubResources;
 
-public class Nic
-{
+public class Nic {
     public static readonly string[] ValidNicTypes =
     {
         // Copper Ethernet
@@ -32,4 +31,4 @@ public class Nic
     public string? Type { get; set; }
     public double? Speed { get; set; }
     public int? Ports { get; set; }
-}
+}

Niektoré súbory nie sú zobrazené, pretože je v týchto rozdielových dátach zmenené mnoho súborov