Quellcode durchsuchen

Improved logical diagram layout

Tim Jones vor 2 Wochen
Ursprung
Commit
98ad6de530

+ 0 - 1
.claude/scheduled_tasks.lock

@@ -1 +0,0 @@
-{"sessionId":"e854264c-60e0-4ad5-bb55-942375978d58","pid":72538,"procStart":"Tue Jun  9 18:39:18 2026","acquiredAt":1781095177636}

+ 22 - 2
RackPeek.Domain/Graph/Graph.cs

@@ -5,7 +5,14 @@ public record GraphNode(
     string Label,
     string Kind,
     string? Subtitle = null,
-    IReadOnlyDictionary<string, string>? Data = 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,
@@ -24,9 +31,22 @@ public record GraphGroup(
     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) {
+    IReadOnlyList<GraphGroup>? Groups = null,
+    GraphRenderHint RenderHint = GraphRenderHint.Standard) {
     public static Graph Empty { get; } = new([], [], null);
 }

+ 204 - 1
RackPeek.Domain/Graph/Serialisers/MermaidSerialiser.cs

@@ -20,6 +20,12 @@ public sealed class MermaidSerialiser {
     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
@@ -50,6 +56,9 @@ public sealed class MermaidSerialiser {
     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
@@ -66,8 +75,19 @@ public sealed class MermaidSerialiser {
         //
         // 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: {'flowchart': {'defaultRenderer': 'elk', 'curve': 'step', 'nodeSpacing': 60, 'rankSpacing': 80, 'padding': 20, 'subGraphTitleMargin': {'top': 12, 'bottom': 12}}, 'themeVariables': {'edgeLabelBackground': 'transparent', 'clusterBkg': 'transparent', 'clusterBorder': '" + _groupStroke + "'}}}%%");
+            "%%{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);
@@ -259,4 +279,187 @@ public sealed class MermaidSerialiser {
         _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;");
 }

+ 72 - 78
RackPeek.Domain/Graph/UseCases/BuildLogicalGraphUseCase.cs

@@ -8,9 +8,11 @@ using RackPeek.Domain.Resources.SystemResources;
 namespace RackPeek.Domain.Graph.UseCases;
 
 /// <summary>
-///     Logical / service-oriented view: services and systems grouped first
-///     by IP subnet (/24), then by their ultimate parent hardware. Edges
-///     show the immediate <c>runsOn</c> dependency.
+///     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;
@@ -25,63 +27,76 @@ public class BuildLogicalGraphUseCase(IResourceCollection repo) : IUseCase {
         foreach (SystemResource s in systems) byName[s.Name] = s;
         foreach (Service svc in services) byName[svc.Name] = svc;
 
-        // Classify each non-hardware resource: which subnet, which parent host,
-        // and what ip[:port] to show as the subtitle.
-        var entries = new List<Entry>();
-        foreach (Resource resource in services.Cast<Resource>().Concat(systems)) {
-            var ip = FindIp(resource, byName);
+        // 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);
-            Hardware? parentHw = FindParentHardware(resource, byName);
-            if (subnet is null) continue; // skip orphans with no IP anywhere up the chain
-            var subtitle = BuildSubtitle(resource, ip);
-            entries.Add(new Entry(resource, subnet, parentHw?.Name, subtitle));
+            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 = entries
+        var nodes = hostEntries
             .OrderBy(e => e.Subnet, StringComparer.Ordinal)
             .ThenBy(e => e.HardwareName ?? string.Empty, StringComparer.OrdinalIgnoreCase)
-            .ThenBy(e => e.Resource.Name, 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.Resource.Name, e.Resource.Name, NodeKind(e.Resource), e.Subtitle))
+                e.System.Name,
+                e.System.Name,
+                NodeKind(e.System),
+                e.Ip,
+                Rows: e.Rows.Count > 0 ? e.Rows : null))
             .ToList();
 
-        List<GraphGroup> groups = BuildGroups(entries);
-
-        // Edges from each resource to its immediate runsOn target if both
-        // ends are nodes in the graph. We omit edges that point at hardware
-        // (hardware is the grouping label, not a node).
-        HashSet<string> nodeIds = new(nodes.Select(n => n.Id), StringComparer.OrdinalIgnoreCase);
-        List<GraphEdge> edges = new();
-        foreach (Entry entry in entries) {
-            var parentName = entry.Resource.RunsOn?.FirstOrDefault();
-            if (parentName is null) continue;
-            if (!nodeIds.Contains(parentName)) continue;
-            edges.Add(new GraphEdge(entry.Resource.Name, parentName, null, "runsOn"));
-        }
+        List<GraphGroup> groups = BuildGroups(hostEntries);
 
-        return new Graph(nodes, edges, groups);
+        return new Graph(nodes, [], groups, GraphRenderHint.Compact);
     }
 
-    private static List<GraphGroup> BuildGroups(IReadOnlyList<Entry> entries) {
+    private static List<GraphGroup> BuildGroups(IReadOnlyList<HostEntry> entries) {
         var groups = new List<GraphGroup>();
 
-        IOrderedEnumerable<IGrouping<string, Entry>> bySubnet = entries
+        IOrderedEnumerable<IGrouping<string, HostEntry>> bySubnet = entries
             .GroupBy(e => e.Subnet, StringComparer.Ordinal)
             .OrderBy(g => g.Key, StringComparer.Ordinal);
 
-        foreach (IGrouping<string, Entry> subnetGroup in bySubnet) {
-            var subnetId = "g_" + Slug(subnetGroup.Key!);
+        foreach (IGrouping<string, HostEntry> subnetGroup in bySubnet) {
+            var subnetId = "g_" + Slug(subnetGroup.Key);
 
-            // Inner groups keyed by parent hardware. Entries with no parent
-            // hardware fall directly into the subnet group.
             var directNodes = new List<string>();
-            IOrderedEnumerable<IGrouping<string?, Entry>> byHardware = subnetGroup
+            IOrderedEnumerable<IGrouping<string?, HostEntry>> byHardware = subnetGroup
                 .GroupBy(e => e.HardwareName)
                 .OrderBy(g => g.Key ?? string.Empty, StringComparer.OrdinalIgnoreCase);
 
-            foreach (IGrouping<string?, Entry> hwGroup in byHardware) {
+            foreach (IGrouping<string?, HostEntry> hwGroup in byHardware) {
                 if (hwGroup.Key is null) {
-                    directNodes.AddRange(hwGroup.Select(e => e.Resource.Name));
+                    directNodes.AddRange(hwGroup.Select(e => e.System.Name));
                     continue;
                 }
 
@@ -89,33 +104,30 @@ public class BuildLogicalGraphUseCase(IResourceCollection repo) : IUseCase {
                 groups.Add(new GraphGroup(
                     hwGroupId,
                     hwGroup.Key,
-                    hwGroup.Select(e => e.Resource.Name).ToList(),
+                    hwGroup.Select(e => e.System.Name).ToList(),
                     subnetId));
             }
 
-            groups.Add(new GraphGroup(subnetId, subnetGroup.Key!, directNodes, null));
+            groups.Add(new GraphGroup(subnetId, subnetGroup.Key, directNodes, null));
         }
 
         return groups;
     }
 
-    private static string NodeKind(Resource resource) {
-        if (resource is Service) return "Service";
-        if (resource is SystemResource sys) {
-            if (string.IsNullOrWhiteSpace(sys.Type)) return "System";
-            // "vm" → "Vm", "hypervisor" → "Hypervisor"; we look these up in the
-            // serialiser's shape map case-insensitively, so casing doesn't
-            // matter — but a canonical form keeps test assertions tidy.
-            var t = sys.Type.Trim().ToLowerInvariant();
-            return t switch {
-                "hypervisor" => "Hypervisor",
-                "vm" => "Vm",
-                "container" => "Container",
-                _ => "System"
-            };
-        }
+    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"
+        };
+    }
 
-        return resource.Kind;
+    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) {
@@ -167,28 +179,10 @@ public class BuildLogicalGraphUseCase(IResourceCollection repo) : IUseCase {
         return new string(chars);
     }
 
-    private static string? BuildSubtitle(Resource resource, string? ip) {
-        // Services: ip:port (port from Network.Port if present). The ip is
-        // whatever the runsOn chain resolves to — it may belong to the host,
-        // not the service itself, but that's the relevant address for users.
-        if (resource is Service service) {
-            var port = service.Network?.Port;
-            if (!string.IsNullOrWhiteSpace(ip) && port.HasValue) return $"{ip}:{port}";
-            if (!string.IsNullOrWhiteSpace(ip)) return ip;
-            return port.HasValue ? $":{port}" : null;
-        }
-
-        // Systems: just their own IP. We don't show ports for systems because
-        // the data model doesn't track listening ports at the system level.
-        if (resource is SystemResource system && !string.IsNullOrWhiteSpace(system.Ip))
-            return system.Ip;
-
-        return null;
-    }
-
-    private readonly record struct Entry(
-        Resource Resource,
+    private readonly record struct HostEntry(
+        SystemResource System,
         string Subnet,
         string? HardwareName,
-        string? Subtitle);
+        string? Ip,
+        IReadOnlyList<GraphNodeRow> Rows);
 }

+ 23 - 12
RackPeek.Domain/Persistence/Yaml/YamlResourceCollection.cs

@@ -182,23 +182,34 @@ public sealed class YamlResourceCollection(
     }
 
     public async Task LoadAsync() {
-        var yaml = await fileStore.ReadAllTextAsync(filePath);
+        // 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);
 
-        YamlRoot 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();
+            resourceCollection.Connections.Clear();
 
-        if (root.Connections != null)
-            resourceCollection.Connections.AddRange(root.Connections);
+            if (root.Connections != null)
+                resourceCollection.Connections.AddRange(root.Connections);
+        }
+        finally {
+            resourceCollection.FileLock.Release();
+        }
     }
 
     public Task AddAsync(Resource resource) {

+ 7 - 5
Shared.Rcl/Visualise/VisualisePage.razor

@@ -42,9 +42,9 @@
             <div class="flex items-center gap-1 text-sm">
                 <button class="@ExportButtonClass"
                         disabled="@CannotExport"
-                        data-testid="visualise-export-svg"
-                        @onclick="ExportSvgAsync">
-                    Save SVG
+                        data-testid="visualise-export-png"
+                        @onclick="ExportPngAsync">
+                    Save PNG
                 </button>
                 <button class="@ExportButtonClass"
                         disabled="@CannotExport"
@@ -146,13 +146,15 @@
     private string ExportFilenameBase =>
         ActiveView == "logical" ? "rackpeek-logical" : "rackpeek-topology";
 
-    private async Task ExportSvgAsync()
+    private async Task ExportPngAsync()
     {
         if (CannotExport) return;
         await JS.InvokeVoidAsync(
-            "rackpeekGraph.downloadSvg", HostId, $"{ExportFilenameBase}.svg");
+            "rackpeekGraph.downloadPng", HostId, $"{ExportFilenameBase}.png", ExportBackground, 2);
     }
 
+    private const string ExportBackground = "#09090b";
+
     private async Task ExportSourceAsync()
     {
         if (CannotExport || _source is null) return;

+ 101 - 14
Shared.Rcl/YamlFileComponent.razor

@@ -1,4 +1,6 @@
 @using System.Collections.Specialized
+@using System.Text
+@using YamlDotNet.Core
 @using RackPeek.Domain.Persistence
 @using RackPeek.Domain.Persistence.Yaml
 @using RackPeek.Domain.Resources
@@ -59,10 +61,38 @@
         </textarea>
 
 
-        @if (!string.IsNullOrEmpty(_validationError))
+        @if (_error is not null)
         {
-            <div class="mt-2 text-red-400 text-xs">
-                @_validationError
+            <div class="mt-3 border border-red-500/40 bg-red-500/10 rounded p-3"
+                 data-testid="yaml-file-error"
+                 role="alert">
+
+                <div class="flex items-start gap-2">
+                    <svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 flex-shrink-0 text-red-400 mt-0.5"
+                         fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
+                        <path stroke-linecap="round" stroke-linejoin="round"
+                              d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"/>
+                    </svg>
+
+                    <div class="min-w-0 flex-1">
+                        <div class="text-red-400 text-sm font-semibold">
+                            @_error.Headline
+                        </div>
+
+                        @if (_error.Line is long line)
+                        {
+                            <div class="text-zinc-400 text-xs mt-1">
+                                Line @line@(_error.Column is long col ? $", column {col}" : "")
+                            </div>
+                        }
+
+                        @if (!string.IsNullOrEmpty(_error.Snippet))
+                        {
+                            <pre class="mt-2 p-2 rounded bg-zinc-950 border border-zinc-800 text-xs overflow-x-auto text-zinc-300"
+                                 data-testid="yaml-file-error-snippet">@_error.Snippet</pre>
+                        }
+                    </div>
+                </div>
             </div>
         }
     }
@@ -97,7 +127,7 @@
 
     string _currentText = "";
     string _editText = "";
-    string? _validationError;
+    YamlEditError? _error;
 
     protected override async Task OnParametersSetAsync()
     {
@@ -117,21 +147,21 @@
     void BeginEdit()
     {
         _editText = _currentText;
-        _validationError = null;
+        _error = null;
         _isEditing = true;
     }
 
     void Cancel()
     {
         _isEditing = false;
-        _validationError = null;
+        _error = null;
     }
 
     async Task Save()
     {
-        if (!ValidateYamlRoundTrip(_editText, out var error))
+        if (!ValidateYamlRoundTrip(_editText, out var err))
         {
-            _validationError = error;
+            _error = err;
             return;
         }
 
@@ -160,13 +190,13 @@
             await OnDeleted.InvokeAsync(Path);
     }
 
-    private bool ValidateYamlRoundTrip(string yaml, out string? error)
+    private bool ValidateYamlRoundTrip(string yaml, out YamlEditError? error)
     {
         try
         {
             if (string.IsNullOrWhiteSpace(yaml))
             {
-                error = "YAML is empty.";
+                error = new YamlEditError("YAML is empty.", null, null, null);
                 return false;
             }
 
@@ -197,7 +227,7 @@
 
             if (root?.Resources == null)
             {
-                error = "No resources section found.";
+                error = new YamlEditError("No resources section found.", null, null, null);
                 return false;
             }
 
@@ -218,7 +248,7 @@
 
             if (root2?.Resources == null)
             {
-                error = "Round-trip serialization failed.";
+                error = new YamlEditError("Round-trip serialization failed.", null, null, null);
                 return false;
             }
 
@@ -229,7 +259,7 @@
 
             if (dup != null)
             {
-                error = $"Duplicate resource name: '{dup.Key}'";
+                error = new YamlEditError($"Duplicate resource name: '{dup.Key}'", null, null, null);
                 return false;
             }
 
@@ -238,9 +268,66 @@
         }
         catch (Exception ex)
         {
-            error = $"YAML validation failed: {ex.Message}";
+            error = BuildEditError(ex, yaml);
             return false;
         }
     }
 
+    private static YamlEditError BuildEditError(Exception ex, string yaml)
+    {
+        YamlException? ye = FindYamlException(ex);
+        if (ye is not null)
+        {
+            long? line = ye.Start.Line > 0 ? ye.Start.Line : null;
+            long? col = ye.Start.Column > 0 ? ye.Start.Column : null;
+            return new YamlEditError(
+                $"YAML invalid: {FirstLine(ye.Message)}",
+                line,
+                col,
+                line is long l ? ExtractSnippet(yaml, (int)l) : null);
+        }
+
+        return new YamlEditError($"YAML validation failed: {FirstLine(ex.Message)}", null, null, null);
+    }
+
+    private static YamlException? FindYamlException(Exception? ex)
+    {
+        while (ex is not null)
+        {
+            if (ex is YamlException ye) return ye;
+            ex = ex.InnerException;
+        }
+        return null;
+    }
+
+    private static string FirstLine(string message)
+    {
+        if (string.IsNullOrEmpty(message)) return string.Empty;
+        var nl = message.IndexOf('\n');
+        return nl < 0 ? message.Trim() : message[..nl].Trim();
+    }
+
+    private static string ExtractSnippet(string yaml, int lineNumber, int context = 2)
+    {
+        var lines = yaml.Replace("\r\n", "\n").Split('\n');
+        if (lineNumber < 1 || lineNumber > lines.Length) return string.Empty;
+
+        var start = Math.Max(1, lineNumber - context);
+        var end = Math.Min(lines.Length, lineNumber + context);
+
+        var sb = new StringBuilder();
+        for (int i = start; i <= end; i++)
+        {
+            sb.Append(i == lineNumber ? "→ " : "  ")
+                .Append(i.ToString().PadLeft(4))
+                .Append("  ")
+                .Append(lines[i - 1]);
+            if (i < end) sb.Append('\n');
+        }
+
+        return sb.ToString();
+    }
+
+    private sealed record YamlEditError(string Headline, long? Line, long? Column, string? Snippet);
+
 }

+ 108 - 7
Shared.Rcl/YamlImportPage.razor

@@ -1,6 +1,9 @@
 @page "/yaml/import"
+@using System.ComponentModel.DataAnnotations
+@using System.Text
 @using RackPeek.Domain.Api
 @using RackPeek.Domain.Persistence
+@using YamlDotNet.Core
 <PageTitle>Yaml Import</PageTitle>
 
 @inject UpsertInventoryUseCase ImportUseCase
@@ -53,10 +56,39 @@
               @bind:after="ComputeDiff">
     </textarea>
 
-    @if (!string.IsNullOrEmpty(_validationError))
+    @if (_error is not null)
     {
-        <div class="text-red-400 text-xs mb-3">
-            @_validationError
+        <div class="border border-red-500/40 bg-red-500/10 rounded p-3 mb-3"
+             data-testid="yaml-import-error"
+             role="alert">
+
+            <div class="flex items-start gap-2">
+                <!-- alert icon -->
+                <svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 flex-shrink-0 text-red-400 mt-0.5"
+                     fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
+                    <path stroke-linecap="round" stroke-linejoin="round"
+                          d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"/>
+                </svg>
+
+                <div class="min-w-0 flex-1">
+                    <div class="text-red-400 text-sm font-semibold">
+                        @_error.Headline
+                    </div>
+
+                    @if (_error.Line is long line)
+                    {
+                        <div class="text-zinc-400 text-xs mt-1">
+                            Line @line@(_error.Column is long col ? $", column {col}" : "")
+                        </div>
+                    }
+
+                    @if (!string.IsNullOrEmpty(_error.Snippet))
+                    {
+                        <pre class="mt-2 p-2 rounded bg-zinc-950 border border-zinc-800 text-xs overflow-x-auto text-zinc-300"
+                             data-testid="yaml-import-error-snippet">@_error.Snippet</pre>
+                    }
+                </div>
+            </div>
         </div>
     }
 
@@ -177,7 +209,7 @@
     private Dictionary<string, string> _newYaml = new(StringComparer.OrdinalIgnoreCase);
 
     private string _inputYaml = "";
-    private string? _validationError;
+    private ImportError? _error;
     private bool _isValid;
 
     private MergeMode _mode = MergeMode.Merge;
@@ -190,7 +222,7 @@
 
     async Task ComputeDiff()
     {
-        _validationError = null;
+        _error = null;
         _isValid = false;
 
         _added.Clear();
@@ -222,7 +254,7 @@
         }
         catch (Exception ex)
         {
-            _validationError = $"YAML invalid: {ex.Message}";
+            _error = BuildError(ex, headlinePrefix: "YAML invalid");
         }
     }
 
@@ -249,8 +281,77 @@
         }
         catch (Exception ex)
         {
-            _validationError = $"Apply failed: {ex.Message}";
+            _error = BuildError(ex, headlinePrefix: "Apply failed");
+        }
+    }
+
+    private ImportError BuildError(Exception ex, string headlinePrefix)
+    {
+        // YamlDotNet wraps the underlying parser error; walk InnerExceptions
+        // to find the YamlException so we can extract Line/Column for the
+        // user.
+        YamlException? yamlEx = FindYamlException(ex);
+        if (yamlEx is not null)
+        {
+            long? line = yamlEx.Start.Line > 0 ? yamlEx.Start.Line : null;
+            long? col = yamlEx.Start.Column > 0 ? yamlEx.Start.Column : null;
+            return new ImportError(
+                $"{headlinePrefix}: {FirstLine(yamlEx.Message)}",
+                line,
+                col,
+                line is long l ? ExtractSnippet(_inputYaml, (int)l) : null);
         }
+
+        // Domain validation errors (duplicate names, missing version, etc.)
+        // are already user-friendly — just surface them prominently.
+        if (ex is ValidationException ve)
+            return new ImportError(ve.Message, null, null, null);
+
+        return new ImportError($"{headlinePrefix}: {FirstLine(ex.Message)}", null, null, null);
     }
 
+    private static YamlException? FindYamlException(Exception? ex)
+    {
+        while (ex is not null)
+        {
+            if (ex is YamlException ye) return ye;
+            ex = ex.InnerException;
+        }
+
+        return null;
+    }
+
+    private static string FirstLine(string message)
+    {
+        if (string.IsNullOrEmpty(message)) return string.Empty;
+        var nl = message.IndexOf('\n');
+        return nl < 0 ? message.Trim() : message[..nl].Trim();
+    }
+
+    private static string ExtractSnippet(string yaml, int lineNumber, int context = 2)
+    {
+        // Render `context` lines before and after the offending line, prefix
+        // each with its 1-based line number, and mark the bad line with an
+        // arrow so the eye lands on it instantly.
+        var lines = yaml.Replace("\r\n", "\n").Split('\n');
+        if (lineNumber < 1 || lineNumber > lines.Length) return string.Empty;
+
+        var start = Math.Max(1, lineNumber - context);
+        var end = Math.Min(lines.Length, lineNumber + context);
+
+        var sb = new StringBuilder();
+        for (int i = start; i <= end; i++)
+        {
+            sb.Append(i == lineNumber ? "→ " : "  ")
+                .Append(i.ToString().PadLeft(4))
+                .Append("  ")
+                .Append(lines[i - 1]);
+            if (i < end) sb.Append('\n');
+        }
+
+        return sb.ToString();
+    }
+
+    private sealed record ImportError(string Headline, long? Line, long? Column, string? Snippet);
+
 }

+ 160 - 2
Shared.Rcl/wwwroot/js/graph/index.js

@@ -172,7 +172,7 @@
         setTimeout(() => URL.revokeObjectURL(url), 1000);
     }
 
-    function downloadSvg(elementId, filename) {
+    function downloadSvg(elementId, filename, background) {
         const host = document.getElementById(elementId);
         if (!host) {
             console.warn(`[rackpeekGraph] element '${elementId}' not found`);
@@ -204,6 +204,28 @@
             }
         }
 
+        // Inject a full-bleed background rect so exports match the in-app
+        // appearance instead of rendering on a transparent canvas (which
+        // shows as white in most viewers / dark in others depending on OS).
+        const bg = (background ?? "#18181b").trim();
+        if (bg && bg.toLowerCase() !== "transparent" && bg.toLowerCase() !== "none") {
+            const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
+            const vbParts = (vb || "").split(/\s+/);
+            if (vbParts.length === 4) {
+                rect.setAttribute("x", vbParts[0]);
+                rect.setAttribute("y", vbParts[1]);
+                rect.setAttribute("width", vbParts[2]);
+                rect.setAttribute("height", vbParts[3]);
+            } else {
+                rect.setAttribute("x", "0");
+                rect.setAttribute("y", "0");
+                rect.setAttribute("width", "100%");
+                rect.setAttribute("height", "100%");
+            }
+            rect.setAttribute("fill", bg);
+            clone.insertBefore(rect, clone.firstChild);
+        }
+
         const serialiser = new XMLSerializer();
         const body = serialiser.serializeToString(clone);
         const xml = '<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n' + body;
@@ -216,5 +238,141 @@
             filename);
     }
 
-    window.rackpeekGraph = { render, downloadSvg, downloadText };
+    function buildExportSvg(host, background) {
+        const svg = host.querySelector("svg");
+        if (!svg) return null;
+
+        const clone = svg.cloneNode(true);
+        if (!clone.getAttribute("xmlns")) {
+            clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
+        }
+        if (!clone.getAttribute("xmlns:xlink")) {
+            clone.setAttribute("xmlns:xlink", "http://www.w3.org/1999/xlink");
+        }
+
+        // Prefer viewBox dimensions — Mermaid sets `width="100%"` on the
+        // live SVG so that the responsive page layout can size it. Parsing
+        // that as a number gives 100 (px), producing a postage-stamp PNG.
+        // The viewBox carries the real document dimensions.
+        const vb = clone.getAttribute("viewBox");
+        const vbParts = (vb || "").split(/\s+/);
+        let width = 0, height = 0;
+        if (vbParts.length === 4) {
+            width = parseFloat(vbParts[2]) || 0;
+            height = parseFloat(vbParts[3]) || 0;
+        }
+        if (!width || !height) {
+            const rect = svg.getBoundingClientRect();
+            if (!width) width = rect.width;
+            if (!height) height = rect.height;
+        }
+        // Pin explicit pixel dimensions so `new Image()` knows how to size
+        // the bitmap. Strip any % units inherited from the live element.
+        clone.setAttribute("width", String(width));
+        clone.setAttribute("height", String(height));
+        clone.removeAttribute("style");
+
+        const bg = (background ?? "#18181b").trim();
+        if (bg && bg.toLowerCase() !== "transparent" && bg.toLowerCase() !== "none") {
+            const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
+            if (vbParts.length === 4) {
+                rect.setAttribute("x", vbParts[0]);
+                rect.setAttribute("y", vbParts[1]);
+                rect.setAttribute("width", vbParts[2]);
+                rect.setAttribute("height", vbParts[3]);
+            } else {
+                rect.setAttribute("x", "0");
+                rect.setAttribute("y", "0");
+                rect.setAttribute("width", String(width));
+                rect.setAttribute("height", String(height));
+            }
+            rect.setAttribute("fill", bg);
+            clone.insertBefore(rect, clone.firstChild);
+        }
+
+        const serialiser = new XMLSerializer();
+        const body = serialiser.serializeToString(clone);
+        return { xml: body, width, height };
+    }
+
+    function downloadSvg(elementId, filename, background) {
+        const host = document.getElementById(elementId);
+        if (!host) {
+            console.warn(`[rackpeekGraph] element '${elementId}' not found`);
+            return;
+        }
+        const built = buildExportSvg(host, background);
+        if (!built) {
+            console.warn(`[rackpeekGraph] no SVG in element '${elementId}' to export`);
+            return;
+        }
+        const xml = '<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n' + built.xml;
+        triggerDownload(new Blob([xml], { type: "image/svg+xml;charset=utf-8" }), filename);
+    }
+
+    function downloadPng(elementId, filename, background, scale) {
+        const host = document.getElementById(elementId);
+        if (!host) {
+            console.warn(`[rackpeekGraph] element '${elementId}' not found`);
+            return Promise.resolve();
+        }
+        const built = buildExportSvg(host, background);
+        if (!built || !built.width || !built.height) {
+            console.warn(`[rackpeekGraph] cannot rasterise SVG in element '${elementId}'`);
+            return Promise.resolve();
+        }
+
+        // Render at 2× DPI by default so the PNG is sharp on retina displays
+        // and when zoomed in for documentation.
+        const ratio = scale && scale > 0 ? scale : 2;
+
+        // A data URL (vs Blob URL) avoids a class of foreignObject taint
+        // issues in some browsers — the SVG is treated as same-origin and
+        // doesn't get caught by the canvas security checks. The trade-off
+        // is a longer string, which is fine for diagram-sized payloads.
+        const url = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(built.xml);
+
+        return new Promise((resolve) => {
+            const img = new Image();
+            img.onload = () => {
+                try {
+                    const canvas = document.createElement("canvas");
+                    canvas.width = Math.ceil(built.width * ratio);
+                    canvas.height = Math.ceil(built.height * ratio);
+                    const ctx = canvas.getContext("2d");
+                    ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
+                    canvas.toBlob((png) => {
+                        if (png) {
+                            triggerDownload(png, filename);
+                        } else {
+                            // Tainted canvas — fall back to toDataURL which
+                            // throws SecurityError instead of returning null.
+                            try {
+                                const dataUrl = canvas.toDataURL("image/png");
+                                fetch(dataUrl)
+                                    .then(r => r.blob())
+                                    .then(b => triggerDownload(b, filename))
+                                    .catch(e => console.warn("[rackpeekGraph] PNG fallback failed", e))
+                                    .finally(resolve);
+                                return;
+                            } catch (e) {
+                                console.warn("[rackpeekGraph] canvas tainted, cannot export PNG", e);
+                            }
+                        }
+                        resolve();
+                    }, "image/png");
+                } catch (e) {
+                    console.warn("[rackpeekGraph] PNG render failed", e);
+                    resolve();
+                }
+            };
+            img.onerror = (err) => {
+                console.warn("[rackpeekGraph] SVG could not be loaded as image (likely a foreignObject/HTML-label rendering issue in this browser)", err);
+                resolve();
+            };
+            img.src = url;
+        });
+    }
+
+    window.rackpeekGraph = { render, downloadSvg, downloadPng, downloadText };
 })();

+ 1278 - 0
remove.conf

@@ -0,0 +1,1278 @@
+version: 3
+resources:
+  - kind: Server
+    ram:
+      size: 80
+    cpus:
+      - model: Intel(R) Core(TM) i7-10700 CPU @ 2.90GHz
+        cores: 8
+        threads: 16
+    drives:
+      - type: ssd
+        size: 1000
+    ports:
+      - type: rj45
+        speed: 1
+        count: 1
+      - type: rj45
+        speed: 1
+        count: 1
+    name: Rocinante
+    notes: ""
+  - kind: Service
+    network:
+      ip: 192.168.1.25
+      port: 2283
+      url: https://photos.somedomain.com
+    name: immich
+    tags:
+      - FQDN
+    runsOn:
+      - Docker VM
+  - kind: System
+    type: hypervisor
+    os: Proxmox
+    cores: 16
+    ram: 80
+    ip: 192.168.1.14
+    name: Proxmox
+    notes: URL is https://proxmox.somedomain.com
+    runsOn:
+      - Rocinante
+  - kind: System
+    type: vm
+    os: Ubuntu 22.04.5 LTS
+    cores: 6
+    ram: 12
+    ip: 192.168.1.25
+    name: Docker VM
+    notes: ""
+    runsOn:
+      - Proxmox
+  - kind: Service
+    network:
+      ip: 192.168.1.25
+      port: 5600
+      protocol: http
+      url: https://miniflux.somedomain.com
+    name: Miniflux
+    tags:
+      - FQDN
+    notes: "** Changelog **"
+    runsOn:
+      - Docker VM
+  - kind: Service
+    network:
+      ip: 192.168.1.25
+      port: 3552
+      protocol: http
+      url: https://arcane.somedomain.com
+    name: Arcane
+    tags:
+      - FQDN
+    notes: 2026-04-11
+    runsOn:
+      - Docker VM
+  - kind: Server
+    ram:
+      size: 32
+      mts: 1333
+    cpus:
+      - model: Intel® Core™ i7-4770K CPU @ 3.50GHz
+        cores: 4
+        threads: 8
+    drives:
+      - type: hdd
+        size: 18000
+    gpus:
+      - model: Intel i915
+    ports:
+      - type: rj45
+        speed: 1
+        count: 1
+    name: Unicomplex
+    notes: "Motherboard: ASUSTeK COMPUTER INC. Z87-PRO"
+  - kind: System
+    type: baremetal
+    os: Unraid server Pro, version 7.2.0
+    cores: 4
+    ram: 32
+    ip: 192.168.1.12
+    name: Unraid
+    notes: ""
+    runsOn:
+      - Unicomplex
+  - kind: Service
+    network:
+      ip: 192.168.1.12
+      port: 3306
+    name: mariadb
+    notes: "**2026-05-11**"
+    runsOn:
+      - Unraid
+  - kind: Service
+    network:
+      ip: 192.168.1.12
+      port: 8765
+      protocol: http
+      url: http://192.168.1.12:8765
+    name: RackPeek
+    runsOn:
+      - Unraid
+  - kind: Service
+    network:
+      ip: 192.168.1.12
+      port: 6380
+    name: Redis-fred
+    runsOn:
+      - Unraid
+  - kind: Service
+    network:
+      ip: 192.168.1.12
+      port: 6381
+    name: Redis
+    runsOn:
+      - Unraid
+  - kind: Service
+    network:
+      ip: 192.168.1.12
+      port: 5432
+    name: Postgres11
+    tags:
+      - technical-debt
+    notes: "## 2026-05-12"
+    runsOn:
+      - Unraid
+  - kind: Service
+    network:
+      ip: 192.168.1.12
+      port: 5439
+    name: postgresql17
+    tags:
+      - technical-debt
+    notes: "## 2026-05-12"
+    runsOn:
+      - Unraid
+  - kind: Service
+    network:
+      ip: 192.168.1.12
+      port: 11111
+      url: http://192.168.1.12:11111
+    name: Jelu
+    runsOn:
+      - Unraid
+  - kind: Service
+    network:
+      ip: 192.168.1.12
+      port: 9873
+      url: https://calibre.somedomain.com
+    name: calibre
+    tags:
+      - FQDN
+    runsOn:
+      - Unraid
+  - kind: Service
+    network:
+      ip: 192.168.1.12
+      port: 8058
+      url: http://192.168.1.12:8058
+    name: Komga
+    runsOn:
+      - Unraid
+  - kind: Service
+    network:
+      ip: 192.168.1.12
+      port: 8683
+      url: https://calibreweb.somedomain.com
+    name: calibre-web-automated
+    tags:
+      - FQDN
+    runsOn:
+      - Unraid
+  - kind: Service
+    network:
+      ip: 192.168.1.12
+      port: 8083
+      protocol: http
+      url: http://192.168.1.12:8083
+    name: calibre-web
+    runsOn:
+      - Unraid
+  - kind: Service
+    network:
+      ip: 192.168.1.12
+      port: 13378
+      url: https://abs.somedomain.com
+    name: audiobookshelf
+    tags:
+      - FQDN
+    runsOn:
+      - Unraid
+  - kind: Service
+    network:
+      ip: 192.168.1.12
+      port: 8989
+      url: https://tv.somedomain.com
+    name: Sonarr (binhex-sonarr)
+    tags:
+      - arr-stack
+      - FQDN
+    runsOn:
+      - Unraid
+  - kind: Service
+    network:
+      ip: 192.168.1.12
+      port: 7878
+      protocol: http
+      url: https://movies.somedomain.com
+    name: Radarr (binhex-radarr)
+    tags:
+      - arr-stack
+      - FQDN
+    runsOn:
+      - Unraid
+  - kind: Service
+    network:
+      ip: 192.168.1.12
+      port: 6767
+      url: https://subtitles.somedomain.com
+    name: bazarr
+    tags:
+      - arr-stack
+      - FQDN
+    runsOn:
+      - Unraid
+  - kind: Service
+    network:
+      ip: 192.168.1.12
+    name: recyclarr
+    tags:
+      - arr-stack
+    runsOn:
+      - Unraid
+  - kind: Service
+    network:
+      ip: 192.168.1.12
+      port: 9696
+      url: http://192.168.1.12:9696
+    name: Prowlarr (binhex-prowlarr)
+    tags:
+      - arr-stack
+    runsOn:
+      - Unraid
+  - kind: Service
+    network:
+      ip: 192.168.1.12
+      port: 5055
+      protocol: TCP
+      url: http://192.168.1.12:5055
+    name: jellyseerr
+    tags:
+      - decommissioned
+      - deleted
+    runsOn:
+      - Unraid
+  - kind: Service
+    network:
+      ip: 192.168.1.12
+      port: 3080
+      protocol: TCP
+      url: http://192.168.1.12:3080
+    name: Watcharr
+    tags:
+      - arr-stack
+    labels:
+      status: decommissioned
+    notes: ""
+    runsOn:
+      - Unraid
+  - kind: Service
+    name: bitwarden-secure-sync
+    runsOn:
+      - Unraid
+  - kind: Service
+    network:
+      ip: 192.168.1.12
+      port: 8007
+      url: http://192.168.1.12:8007
+    name: proxmox-backup-server
+    runsOn:
+      - Unraid
+  - kind: Service
+    network:
+      ip: 192.168.1.12
+      port: 8413
+      url: https://subs.somedomain.com
+    name: Wallos
+    tags:
+      - FQDN
+    runsOn:
+      - Unraid
+  - kind: Service
+    network:
+      ip: 192.168.1.12
+      port: 8384
+      url: http://192.168.1.12:8384
+    name: syncthing
+    tags:
+      - technical-debt
+    notes: |-
+      ## 2026-05-12
+      Shut down, nobody is using this.
+    runsOn:
+      - Unraid
+  - kind: Service
+    network:
+      ip: 192.168.1.12
+      port: 8333
+      url: http://192.168.1.12:8333
+    name: syncthing (binhex-syncthing)
+    tags:
+      - technical-debt
+    notes: ""
+    runsOn:
+      - Unraid
+  - kind: Service
+    network:
+      ip: 192.168.1.25
+      port: 6977
+      url: http://192.168.1.25:6977
+    name: Firefly-Pico
+    tags:
+      - technical-debt
+    notes: |
+      ## 2026-05-12 17:00
+    runsOn:
+      - Docker VM
+  - kind: Service
+    network:
+      ip: 192.168.1.12
+      port: 8012
+      url: http://192.168.1.12:8012
+    name: baikal
+    runsOn:
+      - Unraid
+  - kind: Service
+    network:
+      ip: 192.168.1.12
+      port: 9078
+      url: http://192.168.1.12:9078
+    name: multi-scrobbler
+    runsOn:
+      - Unraid
+  - kind: Service
+    network:
+      ip: 192.168.1.12
+      port: 9299
+      url: https://food.somedomain.com
+    name: mealiev1
+    tags:
+      - FQDN
+    notes: "## 2026-05-12"
+    runsOn:
+      - Unraid
+  - kind: Service
+    network:
+      ip: 192.168.1.12
+      port: 8066
+      protocol: TCP
+    name: Jellyfin
+    notes: "## 2026-05-12"
+    runsOn:
+      - Unraid
+  - kind: Service
+    network:
+      ip: 192.168.1.12
+      port: 8096
+      url: https://emby.somedomain.com
+    name: EmbyServer
+    tags:
+      - FQDN
+    runsOn:
+      - Unraid
+  - kind: Service
+    name: Jellystat
+    notes: ""
+    runsOn:
+      - Unraid
+  - kind: Service
+    network:
+      ip: 192.168.1.12
+      port: 4533
+      url: https://music.somedomain.com
+    name: navidrome
+    tags:
+      - FQDN
+    runsOn:
+      - Unraid
+  - kind: Service
+    network:
+      ip: 192.168.1.12
+      port: 32400
+      url: https://plex.somedomain.com
+    name: Plex-Media-Server
+    tags:
+      - FQDN
+    runsOn:
+      - Unraid
+  - kind: Service
+    network:
+      ip: 192.168.1.12
+      port: 8181
+      url: http://192.168.1.12:8181
+    name: tautulli
+    runsOn:
+      - Unraid
+  - kind: Service
+    name: Kometa
+    runsOn:
+      - Unraid
+  - kind: Service
+    network:
+      ip: 192.168.1.12
+      port: 8147
+      url: https://deemix.somedomain.com
+    name: deemix
+    tags:
+      - FQDN
+    runsOn:
+      - Unraid
+  - kind: Service
+    network:
+      ip: 192.168.1.12
+      port: 42010
+      url: https://scrobble.somedomain.com
+    name: maloja
+    tags:
+      - FQDN
+    runsOn:
+      - Unraid
+  - kind: Service
+    network:
+      ip: 192.168.1.12
+      port: 8456
+      url: http://192.168.1.12:8456
+    name: ddns-updater
+    runsOn:
+      - Unraid
+  - kind: Service
+    network:
+      ip: 192.168.1.12
+      port: 6881
+      url: http://192.168.1.12:6881
+    name: qbittorrentvpn (binhex)
+    runsOn:
+      - Unraid
+  - kind: Service
+    network:
+      ip: 192.168.1.12
+      port: 8015
+      url: https://paste.somedomain.com
+    name: microbin
+    tags:
+      - FQDN
+    runsOn:
+      - Unraid
+  - kind: Service
+    network:
+      ip: 192.168.1.12
+      port: 3100
+      url: https://stuff.somedomain.com
+    name: Homebox
+    tags:
+      - FQDN
+    runsOn:
+      - Unraid
+  - kind: Service
+    network:
+      ip: 192.168.1.12
+      port: 3200
+      url: https://mystuff.somedomain.com
+    name: Homebox-fred
+    tags:
+      - FQDN
+    runsOn:
+      - Unraid
+  - kind: Service
+    network:
+      ip: 192.168.1.12
+      port: 8443
+      url: https://write.somedomain.com
+    name: code-server
+    tags:
+      - FQDN
+    runsOn:
+      - Unraid
+  - kind: Service
+    network:
+      ip: 192.168.1.12
+      port: 5030
+      url: https://start.somedomain.com
+    name: flame
+    tags:
+      - FQDN
+    runsOn:
+      - Unraid
+  - kind: Service
+    network:
+      ip: 192.168.1.12
+      port: 9090
+      url: https://links.somedomain.com
+    name: linkding
+    tags:
+      - FQDN
+    runsOn:
+      - Unraid
+  - kind: Service
+    network:
+      ip: 192.168.1.12
+      port: 4743
+      url: https://pw.somedomain.com
+    name: vaultwarden
+    tags:
+      - FQDN
+    runsOn:
+      - Unraid
+  - kind: Service
+    name: external-ip
+    notes: "## 2026-05-12"
+    runsOn:
+      - Unraid
+  - kind: Service
+    network:
+      ip: 192.168.1.12
+      port: 5076
+      protocol: TCP
+      url: https://hydra.somedomain.com
+    name: nzbhydra2 (binhex)
+    tags:
+      - review
+      - FQDN
+    labels:
+      status: decommissioned
+    notes: |-
+      2026-02-26
+
+      Shut down and deleted this docker.
+    runsOn:
+      - Unraid
+  - kind: Service
+    network:
+      ip: 192.168.1.12
+      port: 8080
+      url: http://192.168.1.12:8080
+    name: sabnzbd (binhex)
+    runsOn:
+      - Unraid
+  - kind: Service
+    network:
+      ip: 192.168.1.12
+      port: 8008
+      url: http://192.168.1.12:8008
+    name: apprise-api
+    runsOn:
+      - Unraid
+  - kind: Service
+    network:
+      ip: 192.168.1.12
+      port: 8982
+      url: http://192.168.1.12:8982
+    name: Dozzle
+    runsOn:
+      - Unraid
+  - kind: Service
+    network:
+      ip: 192.168.1.12
+      port: 8088
+      url: https://firefly.somedomain.com
+    name: Firefly-III
+    tags:
+      - FQDN
+    runsOn:
+      - Unraid
+  - kind: Service
+    name: Firefly-Importer
+    runsOn:
+      - Unraid
+  - kind: Service
+    network:
+      ip: 192.168.1.12
+      port: 9283
+      url: https://grocy.somedomain.com
+    name: grocy
+    tags:
+      - FQDN
+    runsOn:
+      - Unraid
+  - kind: Service
+    network:
+      ip: 192.168.1.12
+      port: 8956
+      url: https://monica.somedomain.com
+    name: monica
+    tags:
+      - FQDN
+    notes: ""
+    runsOn:
+      - Unraid
+  - kind: Service
+    network:
+      ip: 192.168.1.12
+      port: 444
+      url: https://cloud.somedomain.com
+    name: nextcloud
+    tags:
+      - FQDN
+    notes: "## 2026-05-11"
+    runsOn:
+      - Unraid
+  - kind: Service
+    network:
+      ip: 192.168.1.12
+      port: 8006
+      url: https://paperless.somedomain.com
+    name: paperless-ng
+    tags:
+      - FQDN
+    runsOn:
+      - Unraid
+  - kind: Service
+    network:
+      ip: 192.168.1.12
+      port: 8016
+      url: https://pdf.somedomain.com
+    name: paperless-ng-fred
+    tags:
+      - FQDN
+    runsOn:
+      - Unraid
+  - kind: Service
+    network:
+      ip: 192.168.1.12
+      port: 8029
+      url: http://192.168.1.12:8029
+    name: rss-bridge
+    tags:
+      - review
+    labels:
+      status: under review
+    runsOn:
+      - Unraid
+  - kind: Service
+    network:
+      ip: 192.168.1.25
+      port: 3000
+      url: https://rss.somedomain.com
+    name: rss-bridge (second)
+    tags:
+      - FQDN
+    notes: "**2026-02-20**  "
+    runsOn:
+      - Docker VM
+  - kind: Service
+    network:
+      ip: 192.168.1.12
+      port: 8975
+      url: http://192.168.1.12:8975
+    name: scrutiny
+    runsOn:
+      - Unraid
+  - kind: Service
+    network:
+      ip: 192.168.1.12
+      port: 6500
+      url: http://wallabag.somedomain.com
+    name: wallabag
+    tags:
+      - FQDN
+    runsOn:
+      - Unraid
+  - kind: System
+    type: container
+    os: Debian GNU/Linux 12
+    cores: 1
+    ram: 0.5
+    name: (100) wikijs LXC
+    runsOn:
+      - Proxmox
+  - kind: Service
+    name: bitwarden-portal
+    runsOn:
+      - Docker VM
+  - kind: Service
+    network:
+      ip: 192.168.1.25
+      port: 5000
+      url: http://192.168.1.25:5000
+    name: changedetection
+    runsOn:
+      - Docker VM
+  - kind: Service
+    name: cloudflaretunnel
+    tags:
+      - review
+    runsOn:
+      - Docker VM
+  - kind: Service
+    network:
+      ip: 192.168.1.25
+      port: 3300
+      url: https://dawarich.somedomain.com
+    name: dawarich
+    tags:
+      - FQDN
+    runsOn:
+      - Docker VM
+  - kind: Service
+    network:
+      ip: 192.168.1.25
+      port: 5001
+      url: http://192.168.1.25:5001
+    name: dockge
+    tags:
+      - review
+    labels:
+      status: decommissioned
+    runsOn:
+      - Docker VM
+  - kind: Service
+    network:
+      ip: 192.168.1.25
+      port: 3010
+      url: https://hoard.somedomain.com
+    name: hoarder (karakeep)
+    tags:
+      - FQDN
+    runsOn:
+      - Docker VM
+  - kind: Service
+    network:
+      ip: 192.168.1.25
+      port: 3020
+      url: http://192.168.1.25:3020
+    name: koinsight
+    tags:
+      - decommissioned
+    notes: 2026-05-23 10:30
+    runsOn:
+      - Docker VM
+  - kind: Service
+    network:
+      ip: 192.168.1.25
+      port: 5050
+      url: http://192.168.1.25:5050
+    name: may
+    tags:
+      - decommissioned
+      - fds
+    labels:
+      status: decommissioned
+    notes: ""
+    runsOn:
+      - Docker VM
+  - kind: Service
+    network:
+      ip: 192.168.1.25
+      port: 5230
+      url: http://192.168.1.25:5230
+    name: memopoland2
+    runsOn:
+      - Docker VM
+  - kind: Service
+    network:
+      ip: 192.168.1.25
+      port: 5240
+      url: https://memo.somedomain.com
+    name: memos
+    tags:
+      - FQDN
+    runsOn:
+      - Docker VM
+  - kind: Service
+    network:
+      ip: 192.168.1.25
+      port: 2000
+      url: http://192.168.1.25:2000
+    name: reactflux
+    runsOn:
+      - Docker VM
+  - kind: Service
+    network:
+      ip: 192.168.1.25
+      port: 1200
+      url: http://192.168.1.25:1200
+    name: rsshub
+    runsOn:
+      - Docker VM
+  - kind: Service
+    network:
+      ip: 192.168.1.25
+      port: 8083
+      url: http://192.168.1.25:8083
+    name: subtrackr
+    tags:
+      - review
+    runsOn:
+      - Docker VM
+  - kind: Service
+    network:
+      ip: 192.168.1.25
+      port: 8675
+      protocol: http
+      url: http://192.168.1.25:8675
+    name: upvoterss
+    notes: ""
+    runsOn:
+      - Docker VM
+  - kind: Service
+    network:
+      ip: 192.168.1.25
+      port: 9412
+      url: http://192.168.1.25:9412
+    name: tugtainer
+    tags:
+      - review
+    labels:
+      status: decommissioned
+    runsOn:
+      - Docker VM
+  - kind: Service
+    name: tunnelcouk
+    tags:
+      - review
+    runsOn:
+      - Docker VM
+  - kind: Service
+    name: couchdb-for-ols
+    tags:
+      - review
+    labels:
+      status: decommissioned
+    notes: 2026-02-24
+    runsOn:
+      - Docker VM
+  - kind: Service
+    network:
+      ip: 192.168.1.25
+      port: 9000
+      url: http://192.168.1.25:9000
+    name: thelounge
+    tags:
+      - review
+    runsOn:
+      - Docker VM
+  - kind: Switch
+    model: US-8-60W
+    managed: true
+    poe: true
+    ports:
+      - type: rj45
+        speed: 1
+        count: 8
+    name: Unifi US-8-60W
+  - kind: Switch
+    model: Flex Mini
+    ports:
+      - type: rj45
+        speed: 1
+        count: 5
+    name: Unifi USW Flex Mini
+  - kind: AccessPoint
+    model: NanoHD
+    speed: 2
+    ports:
+      - type: rj45
+        speed: 1
+        count: 1
+    name: Unifi NanoHD AP
+    notes: |
+      1733 Mb/s 5G
+      300 Mb/s 2,4G
+  - kind: Service
+    network:
+      ip: 192.168.1.60
+      port: 3000
+      url: https://wiki.somedomain.com
+    name: wikijs
+    tags:
+      - LXC
+      - FQDN
+    runsOn:
+      - (100) wikijs LXC
+  - kind: Desktop
+    ram:
+      size: 32
+      mts: 3200
+    cpus:
+      - model: Intel Core i9-9900K
+        cores: 8
+        threads: 16
+    gpus:
+      - model: NVIDIA GeForce GTX 1070
+        vram: 8
+    name: Archie
+  - kind: System
+    os: Arch Linux
+    cores: 8
+    ram: 32
+    ip: 192.168.1.50
+    name: Arch Linux
+    runsOn:
+      - Archie
+  - kind: System
+    type: container
+    os: Debian GNU/Linux 12
+    cores: 1
+    ram: 0.5
+    name: (101) pihole LXC
+    runsOn:
+      - Proxmox
+  - kind: Service
+    network:
+      ip: 192.168.1.6
+      port: 80
+      url: https://192.168.1.6/admin/login
+    name: pihole
+    tags:
+      - LXC
+    runsOn:
+      - (101) pihole LXC
+  - kind: System
+    type: container
+    os: Debian GNU/Linux 12
+    cores: 2
+    ram: 2
+    name: (102) unifi LXC
+    runsOn:
+      - Proxmox
+  - kind: Service
+    network:
+      ip: 192.168.1.45
+      port: 8443
+      url: https://192.168.1.45:8443/
+    name: unifi
+    runsOn:
+      - (102) unifi LXC
+  - kind: Service
+    network:
+      ip: 192.168.1.12
+      port: 5095
+      url: http://192.168.1.12:5095
+    name: seerr (binhex)
+    tags:
+      - arr-stack
+    runsOn:
+      - Unraid
+  - kind: System
+    type: container
+    os: Debian GNU/Linux 12
+    cores: 1
+    ram: 0.5
+    name: lubelogger (105)
+    runsOn:
+      - Proxmox
+  - kind: System
+    type: container
+    os: Debian GNU/Linux 11
+    cores: 1
+    ram: 0.5
+    name: adguard (106)
+    runsOn:
+      - Proxmox
+  - kind: System
+    type: container
+    os: Debian GNU/Linux 12
+    cores: 1
+    ram: 0.5
+    name: uptimekuma (116)
+    runsOn:
+      - Proxmox
+  - kind: System
+    type: container
+    os: Debian GNU/Linux 12
+    cores: 1
+    ram: 0.5
+    name: bookstack (118)
+    runsOn:
+      - Proxmox
+  - kind: System
+    type: container
+    os: Debian GNU/Linux 12
+    cores: 1
+    ram: 1
+    name: vikunja (104)
+    runsOn:
+      - Proxmox
+  - kind: System
+    type: container
+    os: Debian GNU/Linux 12
+    cores: 1
+    ram: 1
+    name: homebox (120)
+    runsOn:
+      - Proxmox
+  - kind: System
+    type: container
+    os: Debian GNU/Linux 12
+    cores: 2
+    ram: 4
+    name: karakeep (121)
+    runsOn:
+      - Proxmox
+  - kind: System
+    type: container
+    os: Debian GNU/Linux 12
+    cores: 1
+    ram: 1
+    name: pulse (122)
+    labels:
+      status: decommissioned
+    notes: 2026-02-24
+    runsOn:
+      - Proxmox
+  - kind: Service
+    network:
+      ip: 192.168.1.65
+      port: 7655
+      protocol: TCP
+      url: http://192.168.1.65:7655
+    name: pulse
+    tags:
+      - review
+    labels:
+      status: decommissioned
+    notes: 2026-02-24 (Review)
+    runsOn:
+      - pulse (122)
+  - kind: Service
+    network:
+      ip: 192.168.1.62
+      port: 5000
+      protocol: TCP
+      url: http://192.168.1.62:5000
+    name: lubelogger
+    runsOn:
+      - lubelogger (105)
+  - kind: Service
+    network:
+      ip: 192.168.1.5
+      port: 80
+      protocol: TCP
+      url: http://192.168.1.5
+    name: adguard
+    runsOn:
+      - adguard (106)
+  - kind: Service
+    network:
+      ip: 192.168.1.61
+      port: 3001
+      protocol: TCP
+      url: https://uptime.somedomain.com
+    name: uptimekuma
+    runsOn:
+      - uptimekuma (116)
+  - kind: Service
+    network:
+      ip: 192.168.1.218
+      port: 80
+      protocol: TCP
+      url: http://192.168.1.218
+    name: bookstack
+    runsOn:
+      - bookstack (118)
+  - kind: Service
+    network:
+      ip: 192.168.1.63
+      port: 3456
+      protocol: TCP
+      url: https://tasks.somedomain.com
+    name: vikunja
+    tags:
+      - testing
+      - FQDN
+    labels:
+      status: testing
+    notes: ""
+    runsOn:
+      - vikunja (104)
+  - kind: Service
+    network:
+      ip: 192.168.1.64
+      port: 7745
+      protocol: TCP
+      url: http://192.168.1.64:7745
+    name: HomeBox LXC
+    tags:
+      - review
+    notes: 2026-02-24 (Review)
+    runsOn:
+      - homebox (120)
+  - kind: Service
+    network:
+      ip: 192.168.1.67
+      port: 3000
+      protocol: TCP
+      url: https://hoard.somedomain.com
+    name: karakeep
+    tags:
+      - FQDN
+    runsOn:
+      - karakeep (121)
+  - kind: Service
+    network:
+      ip: 192.168.1.25
+      port: 4110
+      protocol: TCP
+      url: https://koito.somedomain.com
+    name: koito
+    notes: ""
+    runsOn:
+      - Docker VM
+  - kind: Service
+    network:
+      ip: 192.168.1.12
+      port: 8070
+      protocol: TCP
+      url: https://gotify.somedomain.com
+    name: gotify
+    runsOn:
+      - Unraid
+  - kind: Service
+    network:
+      ip: 192.168.1.12
+      port: 8154
+      protocol: TCP
+    name: TandoorRecipes
+    runsOn:
+      - Unraid
+  - kind: Service
+    network:
+      ip: 192.168.1.12
+      port: 8155
+      protocol: TCP
+    name: TandoorRecipes2
+    runsOn:
+      - Unraid
+  - kind: Service
+    network:
+      ip: 192.168.1.12
+      port: 9898
+      protocol: TCP
+    name: backrest
+    tags:
+      - testing
+    labels:
+      status: testing
+    runsOn:
+      - Unraid
+  - kind: System
+    os: Debian 13
+    cores: 1
+    ram: 0.5
+    name: Technititum DNS (114)
+    runsOn:
+      - Proxmox
+  - kind: Service
+    network:
+      ip: 192.168.1.7
+      port: 5380
+      protocol: TCP
+      url: https://dns.somedomain.com
+    name: Technititum DNS
+    tags:
+      - core infrastrucure
+    notes: ""
+    runsOn:
+      - Technititum DNS (114)
+  - kind: System
+    os: Debian 13
+    cores: 2
+    ram: 2048
+    name: nametag (115)
+    tags:
+      - testing
+    runsOn:
+      - Proxmox
+  - kind: Service
+    network:
+      ip: 192.168.1.64
+      port: 3000
+      protocol: TCP
+      url: https://nametag.somedomain.com
+    name: nametag
+    runsOn:
+      - nametag (115)
+  - kind: System
+    type: container
+    os: Debian 13
+    cores: 2
+    ram: 2048
+    ip: 192.168.1.65
+    name: homelable (116)
+    tags:
+      - testing
+    runsOn:
+      - Proxmox
+  - kind: Service
+    network:
+      ip: 192.168.1.65
+      port: 3000
+      protocol: TCP
+    name: homelable
+    tags:
+      - testing
+    runsOn:
+      - homelable (116)
+  - kind: Service
+    network:
+      protocol: TCP
+    name: MariaDB (firefly-db)
+    notes: "**Deployed 2026-05-11**"
+    runsOn:
+      - Unraid
+  - kind: Service
+    network:
+      protocol: TCP
+    name: MariaDB (nextcloud-db)
+    notes: "**Deployed 2026-05-11**"
+    runsOn:
+      - Unraid
+  - kind: Service
+    name: MariaDB (monica-db)
+    notes: ""
+    runsOn:
+      - Unraid
+  - kind: Service
+    network:
+      ip: 192.168.1.25
+      port: 3030
+      protocol: TCP
+    name: koreader-sync
+    notes: "**2026-05-24**"
+    runsOn:
+      - Docker VM
+connections:
+  - a:
+      resource: Unifi USW Flex Mini
+      portGroup: 0
+      portIndex: 4
+    b:
+      resource: Unicomplex
+      portGroup: 0
+      portIndex: 0
+  - a:
+      resource: Unifi US-8-60W
+      portGroup: 0
+      portIndex: 6
+    b:
+      resource: Unifi NanoHD AP
+      portGroup: 0
+      portIndex: 0
+  - a:
+      resource: Unifi USW Flex Mini
+      portGroup: 0
+      portIndex: 0
+    b:
+      resource: Unifi US-8-60W
+      portGroup: 0
+      portIndex: 7