From dc4d82c828d0fdd8a7f3854b93fa9b0065b36fb0 Mon Sep 17 00:00:00 2001 From: oddlama Date: Sun, 31 Mar 2024 17:19:26 +0200 Subject: [PATCH] feat(topology): add automatic lazy network propagation --- flake.nix | 15 +- hosts/ward/default.nix | 21 ++ topology/README.md | 11 + topology/icons/interfaces/ethernet.svg | 2 +- topology/nixos/extractors/wireguard.nix | 10 +- topology/options/interfaces.nix | 266 +++++++++++++++++++- topology/options/nodes.nix | 34 +-- topology/topology/renderers/elk/default.nix | 102 ++++---- topology/topology/renderers/svg/default.nix | 88 +++---- 9 files changed, 412 insertions(+), 137 deletions(-) create mode 100644 topology/README.md diff --git a/flake.nix b/flake.nix index 6d2ee90..432675d 100644 --- a/flake.nix +++ b/flake.nix @@ -207,6 +207,7 @@ nodes.fritzbox = { name = "FritzBox"; deviceType = "router"; + hardware.info = "FRITZ!Box 7520"; hardware.image = ./fritzbox.png; # interfaces.wan0.network = "internet"; interfaces.wan0 = {}; @@ -229,14 +230,20 @@ cidrv4 = "192.168.178.0/24"; color = "#f1cf8a"; }; + nodes.ward.interfaces.lan.network = "home-lan"; - nodes.ward.interfaces.wan.network = "home-fritzbox"; nodes.fritzbox.interfaces.eth0.network = "home-fritzbox"; nodes.switch-attic = { name = "Switch Attic"; deviceType = "switch"; + hardware.info = "D-Link DGS-1016D"; hardware.image = ./dlink-dgs1016d.png; + + interfaces.eth0.sharesNetworkWith = _: true; + interfaces.eth1.sharesNetworkWith = _: true; + interfaces.eth2.sharesNetworkWith = _: true; + interfaces.eth0.physicalConnections = [ { node = "ward"; @@ -255,7 +262,13 @@ nodes.switch-bedroom-1 = { name = "Switch Bedroom 1"; deviceType = "switch"; + hardware.info = "D-Link DGS-105"; hardware.image = ./dlink-dgs105.png; + + interfaces.eth0.sharesNetworkWith = _: true; + interfaces.eth1.sharesNetworkWith = _: true; + interfaces.eth2.sharesNetworkWith = _: true; + interfaces.eth0.physicalConnections = [ { node = "switch-attic"; diff --git a/hosts/ward/default.nix b/hosts/ward/default.nix index 6622525..9b4a928 100644 --- a/hosts/ward/default.nix +++ b/hosts/ward/default.nix @@ -22,6 +22,27 @@ ]; topology.self.hardware.image = ../../odroid-h3.png; + topology.self.hardware.info = "ODROID H3, 64GB RAM"; + # TODO FIXME topology bogus + topology.self.interfaces.lan-self.physicalConnections = [ + { + node = config.node.name; + interface = "lan"; + } + ]; + topology.self.interfaces.lan.physicalConnections = + lib.flip map [ + "adguardhome" + "forgejo" + "kanidm" + "radicale" + "vaultwarden" + ] ( + x: { + node = "ward-${x}"; + interface = "lan"; + } + ); boot.mode = "efi"; boot.initrd.availableKernelModules = ["xhci_pci" "ahci" "nvme" "usbhid" "usb_storage" "sd_mod" "sdhci_pci" "r8169"]; diff --git a/topology/README.md b/topology/README.md new file mode 100644 index 0000000..1894766 --- /dev/null +++ b/topology/README.md @@ -0,0 +1,11 @@ +## Options + +## Renderers + +### svg + +### elk + +## NixOS Extractors + +## Network propagation diff --git a/topology/icons/interfaces/ethernet.svg b/topology/icons/interfaces/ethernet.svg index 7deaca0..c1afca8 100644 --- a/topology/icons/interfaces/ethernet.svg +++ b/topology/icons/interfaces/ethernet.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/topology/nixos/extractors/wireguard.nix b/topology/nixos/extractors/wireguard.nix index 1bb1774..09840a0 100644 --- a/topology/nixos/extractors/wireguard.nix +++ b/topology/nixos/extractors/wireguard.nix @@ -13,7 +13,6 @@ mkIf mkMerge filter - optionals ; headOrNull = xs: @@ -49,7 +48,6 @@ in { wgName: wgCfg: let inherit (lib.wireguard inputs wgName) - participatingClientNodes participatingServerNodes wgCfgOf ; @@ -57,11 +55,6 @@ in { isServer = wgCfg.server.host != null; filterSelf = filter (x: x != config.node.name); - # All nodes that use our node as the via into the wireguard network - ourClientNodes = - optionals isServer - (filter (n: (wgCfgOf n).client.via == config.node.name) participatingClientNodes); - # The list of peers that are "physically" connected in the wireguard network, # meaning they communicate directly with each other. connectedPeers = @@ -69,13 +62,12 @@ in { then # Other servers in the same network filterSelf participatingServerNodes - # Our clients - ++ ourClientNodes else [wgCfg.client.via]; in { ${wgCfg.linkName} = { network = networkId wgName; virtual = true; + renderer.hidePhysicalConnections = true; physicalConnections = flip map connectedPeers (peer: { node = inputs.self.nodes.${peer}.config.topology.id; interface = (wgCfgOf peer).linkName; diff --git a/topology/options/interfaces.nix b/topology/options/interfaces.nix index 93fdf88..63909a2 100644 --- a/topology/options/interfaces.nix +++ b/topology/options/interfaces.nix @@ -1,23 +1,231 @@ f: { lib, config, + options, ... }: let inherit (lib) + all + assertMsg + attrNames attrValues + concatLines + concatMap + concatStringsSep + const + elem flatten flip + foldl' + isAttrs + length + mapAttrsToList mkDefault mkIf mkOption + mkOptionType optional + optionalAttrs + recursiveUpdate + reverseList + showOption types + unique + warnIf ; + + # Checks whether the value is a lazy value without causing + # it's value to be evaluated + isLazyValue = x: isAttrs x && x ? _lazyValue; + # Constructs a lazy value holding the given value. + lazyValue = value: {_lazyValue = value;}; + + # Represents a lazy value of the given type, which + # holds the actual value as an attrset like { _lazyValue = ; }. + # This allows the option to be defined and filtered from a defintion + # list without evaluating the value. + lazyValueOf = type: + mkOptionType rec { + name = "lazyValueOf ${type.name}"; + inherit (type) description descriptionClass emptyValue getSubOptions getSubModules; + check = isLazyValue; + merge = loc: defs: + assert assertMsg + (all (x: type.check x._lazyValue) defs) + "The option `${showOption loc}` is defined with a lazy value holding an invalid type"; + types.mergeOneOption loc defs; + substSubModules = m: types.uniq (type.substSubModules m); + functor = (types.defaultFunctor name) // {wrapped = type;}; + nestedTypes.elemType = type; + }; + + # Represents a value or lazy value of the given type that will + # automatically be coerced to the given type when merged. + lazyOf = type: types.coercedTo (lazyValueOf type) (x: x._lazyValue) type; + + allNodes = attrNames config.nodes; + allInterfacesOf = node: attrNames config.nodes.${node}.interfaces; + allNodeInterfacePairs = concatMap (node: map (interface: {inherit node interface;}) (allInterfacesOf node)) allNodes; + + # Helper to add a value to a list if it doesn't already hold it + addIfNotExists = list: x: + if elem x list + then list + else list ++ [x]; + + # The list of networks that were specifically assigned by the user + # to other interfaces which are sharing their network with us. + networkDefiningInterfaces = foldl' recursiveUpdate {} (flatten ( + flip map options.nodes.definitions (mapAttrsToList ( + nodeId: node: + flip mapAttrsToList (node.interfaces or {}) ( + interfaceId: interface: + optional (interface ? network && !isLazyValue interface.network) { + ${nodeId}.${interfaceId} = interface.network; + } + ) + )) + )); + + # A list of all connections between all interfaces. Bidirectional. + connectionList = unique (flatten (flip mapAttrsToList config.nodes ( + nodeId: node: + flip mapAttrsToList node.interfaces ( + interfaceId: interface: + flip map interface.physicalConnections (conn: [ + { + src = { + node = nodeId; + interface = interfaceId; + }; + dst = conn; + } + { + src = conn; + dst = { + node = nodeId; + interface = interfaceId; + }; + } + ]) + ) + ))); + + # A map of all connections constructed from the connection list + connections = foldl' (acc: { + src, + dst, + }: + recursiveUpdate acc { + ${src.node}.${src.interface} = addIfNotExists (acc.${src.node}.${src.interface} or []) dst; + }) {} + connectionList; + + # Propagates all networks from the source interface to the destination interface + propagateNetworks = state: snode: sinterface: dnode: dinterface: + #builtins.trace " propagate all nets (${toString (attrNames state.${snode}.${sinterface})}) of ${snode}.${sinterface} to ${dnode}.${dinterface}" + ( + # Fold over each network that the source interface shares + # and propagate it if the destination doesn't already have this network + foldl' ( + acc: net: + recursiveUpdate acc ( + optionalAttrs (!(acc ? ${dnode}.${dinterface}.${net})) + #builtins.trace " adding ${net} to ${dnode}.${dinterface} via ${toString (acc.${snode}.${sinterface}.${net} ++ ["${snode}.${sinterface}"])}" + { + # Create the network on the destination interface and append our interface + # to the list which indicates from where the network was received + ${dnode}.${dinterface}.${net} = acc.${snode}.${sinterface}.${net} ++ ["${snode}.${sinterface}"]; + } + ) + ) + state (attrNames state.${snode}.${sinterface}) + ); + + # Propagates all shared networks for the given interface to other interfaces connected to it + propagateToConnections = state: src: + #builtins.trace "propagate via connections of ${src.node}.${src.interface}" + ( + # Fold over each connection of the current interface + foldl' ( + acc: dst: + propagateNetworks acc src.node src.interface dst.node dst.interface + ) + state (connections.${src.node}.${src.interface} or []) + ); + + # Propagates all shared networks for the given interface to other interfaces on the same + # node to which the network is shared + propagateLocally = state: src: + #builtins.trace "propagate locally on ${src.node}.${src.interface}" + ( + # Fold over each local interface of the current node + foldl' ( + acc: dstInterface: + if src.interface != dstInterface && config.nodes.${src.node}.interfaces.${src.interface}.sharesNetworkWith dstInterface + then propagateNetworks acc src.node src.interface src.node dstInterface + else acc + ) + state (allInterfacesOf src.node) + ); + + # Assigns each interface a list of networks that were propagated to it + # from another interface or connection. + propagatedNetworks = let + # Initially set sharedNetworks based on whether the interface has a network assigned to it. + # This list will then be expaned iteratively. + initial = foldl' recursiveUpdate {} (flatten (flip map allNodes ( + node: + flip map (allInterfacesOf node) (interface: { + ${node}.${interface} = optionalAttrs (networkDefiningInterfaces ? ${node}.${interface}) { + ${networkDefiningInterfaces.${node}.${interface}} = []; + }; + }) + ))); + + # Takes a state and propagates networks via local sharing on the same node + propagateEachLocally = state: foldl' propagateLocally state allNodeInterfacePairs; + # Takes a state and propagates networks via sharing over connections + propagateEachToConnections = state: foldl' propagateToConnections state allNodeInterfacePairs; + + # The update function that propagates state from all interfaces to all neighbors. + # The fixpoint of this function is the solution. + update = state: propagateEachToConnections (propagateEachLocally state); + converged = lib.converge update initial; + + # Extract all interfaces that were assigned multiple interfaces, to issue a warning + interfacesWithMultipleNets = flatten ( + flip mapAttrsToList converged ( + node: ifs: + flip mapAttrsToList ifs ( + interface: nets: + optional (length (attrNames nets) > 1) { + inherit node interface nets; + } + ) + ) + ); + in + warnIf (interfacesWithMultipleNets != []) '' + topology: Some interfaces have received multiple networks via network propagation! + This is an error in your network configuration that must be addressed. + Evaluation can still continue by considering only one of them (effectively at random). + The affected interfaces are: + ${concatLines (flip map interfacesWithMultipleNets ( + x: + " - ${x.node}.${x.interface}:\n" + + concatLines ( + flip mapAttrsToList x.nets ( + net: assignments: " ${net} assigned via ${concatStringsSep " -> " (reverseList assignments)}" + ) + ) + ))}'' + converged; in f { options.nodes = mkOption { - type = types.attrsOf (types.submodule { + type = types.attrsOf (types.submodule (nodeSubmod: { options = { interfaces = mkOption { description = "TODO"; @@ -67,10 +275,38 @@ in type = types.listOf types.str; }; - network = mkOption { - description = "The id of the network to which this interface belongs, if any."; - default = null; - type = types.nullOr types.str; + network = + mkOption { + description = "The id of the network to which this interface belongs, if any."; + type = lazyOf (types.nullOr types.str); + } + // optionalAttrs config.topology.isMainModule { + default = let + sharedNetworks = propagatedNetworks.${nodeSubmod.config.id}.${submod.config.id} or {}; + sharedNetwork = + if sharedNetworks == {} + then null + else builtins.head (attrNames sharedNetworks); + in + lazyValue sharedNetwork; + }; + + sharesNetworkWith = mkOption { + description = '' + Defines a predicate that determines whether this interface shares its connected network with another provided local interface. + The predicates takes the name of another interface and returns true if our network should be shared with the given interface. + + Sharing here means that if a network is set on this interface, it will also be set as the network for any + shared interface. Setting the same predicate on multiple interfaces causes them to share a network regardless + on which port the network is actually defined. + + An unmanaged switch for example would set this to `const true`, effectively + propagating the network set on one port to all other ports. Having two assigned + networks within one predicate group will cause a warning to be issued. + ''; + default = const false; + defaultText = ''const false''; + type = types.functionTo types.bool; }; physicalConnections = mkOption { @@ -90,6 +326,21 @@ in }; }); }; + + # Rendering related hints and settings + renderer = { + hidePhysicalConnections = mkOption { + description = '' + Whether to hide physical connections of this interface in renderings. + Affects both outgoing connections defined here and incoming connections + defined on other interfaces. + + Usually only affects rendering of the main topology view, not network-centric views. + ''; + type = types.bool; + default = false; + }; + }; }; config = { @@ -101,10 +352,13 @@ in })); }; }; - }); + })); }; config = { + lib.a.a = connections; + lib.a.b = networkDefiningInterfaces; + lib.a.c = propagatedNetworks; assertions = flatten (flip map (attrValues config.nodes) ( node: flip map (attrValues node.interfaces) ( diff --git a/topology/options/nodes.nix b/topology/options/nodes.nix index 1d2f5a4..fbe3c0c 100644 --- a/topology/options/nodes.nix +++ b/topology/options/nodes.nix @@ -41,8 +41,8 @@ in }; hardware = { - description = mkOption { - description = "A description of this node's hardware. Usually the model name or a description the most important components."; + info = mkOption { + description = "A single line of information about this node's hardware. Usually the model name or a description the most important components."; type = types.str; default = ""; }; @@ -64,7 +64,7 @@ in description = '' The device type of the node. This can be set to anything, but some special values exist that will automatically set some other defaults, most notably - the deviceIcon and preferredRenderType. + the deviceIcon and renderer.preferredType. ''; type = types.either (types.enum ["nixos" "internet" "router" "switch" "device"]) types.str; }; @@ -87,15 +87,18 @@ in type = types.nullOr types.str; }; - preferredRenderType = mkOption { - description = '' - An optional hint to the renderer to specify whether this node should preferrably - rendered as a full card, or just as an image with name. If there is no hardware - image, this will usually still render a small card. - ''; - type = types.enum ["card" "image"]; - default = "card"; - defaultText = ''"card" # defaults to card but is also derived from the deviceType if possible.''; + # Rendering related hints and settings + renderer = { + preferredType = mkOption { + description = '' + An optional hint to the renderer to specify whether this node should preferrably + rendered as a full card, or just as an image with name. If there is no hardware + image, this will usually still render a small card. + ''; + type = types.enum ["card" "image"]; + default = "card"; + defaultText = ''"card" # defaults to card but is also derived from the deviceType if possible.''; + }; }; }; @@ -103,16 +106,19 @@ in nodeCfg = nodeSubmod.config; in mkIf config.topology.isMainModule (mkMerge [ - # Set the default icon, if an icon exists with a matching name { + # Set the default icon, if an icon exists with a matching name deviceIcon = mkIf (config.icons.devices ? ${nodeCfg.deviceType}) ( mkDefault ("devices." + nodeCfg.deviceType) ); + + # Set the hardware info to the guest type if nothing else was set + hardware.info = mkIf (nodeCfg.guestType != null) (mkDefault nodeCfg.guestType); } # If the device type is not a full nixos node, try to render it as an image with name. (mkIf (elem nodeCfg.deviceType ["internet" "router" "switch" "device"]) { - preferredRenderType = mkDefault "image"; + renderer.preferredType = mkDefault "image"; }) ]); })); diff --git a/topology/topology/renderers/elk/default.nix b/topology/topology/renderers/elk/default.nix index 65d5cda..81b1c21 100644 --- a/topology/topology/renderers/elk/default.nix +++ b/topology/topology/renderers/elk/default.nix @@ -20,7 +20,6 @@ mkOption optional optionalAttrs - optionals recursiveUpdate stringLength types @@ -64,6 +63,13 @@ // extra; }; + mkPort = recursiveUpdate { + width = 8; + height = 8; + style.stroke = "#485263"; + style.fill = "#b6beca"; + }; + mkLabel = text: scale: extraStyle: { height = scale * 12; width = scale * 7.2 * (stringLength text); @@ -83,82 +89,72 @@ scale = 0.8; }; properties."portLabels.placement" = "OUTSIDE"; + + ports.default = mkPort { + labels."00-name" = mkLabel "*" 1 {}; + }; }; } ]; idForInterface = node: interfaceId: "children.node:${node.id}.ports.interface:${interfaceId}"; - nodeInterfaceToElk = node: interface: + nodeInterfaceToElk = node: interface: let + interfaceLabels = + { + "00-name" = mkLabel interface.id 1 {}; + } + // optionalAttrs (interface.mac != null) { + "50-mac" = mkLabel interface.mac 1 {fill = "#70a5eb";}; + } + // optionalAttrs (interface.addresses != []) { + "60-addrs" = mkLabel (toString interface.addresses) 1 {fill = "#f9a872";}; + }; + in [ # Interface for node in main view { - children."node:${node.id}".ports."interface:${interface.id}" = { - properties = optionalAttrs (node.preferredRenderType == "card") { + children."node:${node.id}".ports."interface:${interface.id}" = mkPort { + properties = optionalAttrs (node.renderer.preferredType == "card") { "port.side" = "WEST"; }; - width = 8; - height = 8; - style.stroke = "#485263"; # FIXME: TODO color based on attached network color (autoshade)? - style.fill = "#b6beca"; - labels = - { - "00-name" = mkLabel interface.id 1 {}; - } - // optionalAttrs (interface.mac != null) { - "50-mac" = mkLabel interface.mac 1 {fill = "#70a5eb";}; - } - // optionalAttrs (interface.addresses != []) { - "60-addrs" = mkLabel (toString interface.addresses) 1 {fill = "#f9a872";}; - }; + labels = interfaceLabels; }; } + # Interface for node in network-centric view { - children.network.children."node:${node.id}".ports."interface:${interface.id}" = { - # FIXME: TODO: deduplicate, same as above - # FIXME: TODO: deduplicate, same as above - # FIXME: TODO: deduplicate, same as above - width = 8; - height = 8; - style.stroke = "#485263"; # FIXME: TODO color based on attached network color (autoshade)? - style.fill = "#b6beca"; - labels = - { - "00-name" = mkLabel interface.id 1 {}; - } - // optionalAttrs (interface.mac != null) { - "50-mac" = mkLabel interface.mac 1 {fill = "#70a5eb";}; - } - // optionalAttrs (interface.addresses != []) { - "60-addrs" = mkLabel (toString interface.addresses) 1 {fill = "#f9a872";}; - }; + children.network.children."node:${node.id}".ports."interface:${interface.id}" = mkPort { + labels = interfaceLabels; }; } # Edge in network-centric view (optionalAttrs (interface.network != null) ( - mkEdge ("children.network." + idForInterface node interface.id) "children.network.children.net:${interface.network}" { + mkEdge ("children.network." + idForInterface node interface.id) "children.network.children.net:${interface.network}.ports.default" { style.stroke = config.networks.${interface.network}.color; } )) ] - ++ optionals (!interface.virtual) (flip map interface.physicalConnections ( - conn: + ++ flatten (flip map interface.physicalConnections ( + conn: let + otherInterface = config.nodes.${conn.node}.interfaces.${conn.interface}; + in optionalAttrs ( - (!any (y: y.node == node.id && y.interface == interface.id) config.nodes.${conn.node}.interfaces.${conn.interface}.physicalConnections) + (!any (y: y.node == node.id && y.interface == interface.id) otherInterface.physicalConnections) || (node.id < conn.node) ) ( - # Edge in main view - mkEdge - (idForInterface node interface.id) - (idForInterface config.nodes.${conn.node} conn.interface) - { - # FIXME: in interface definition ensure that the two ends of physical connections don't have different networks (output warning only) - style = optionalAttrs (interface.network != null) { - stroke = config.networks.${interface.network}.color; - }; - } + optional (!interface.renderer.hidePhysicalConnections && !otherInterface.renderer.hidePhysicalConnections) ( + # Edge in main view + mkEdge + (idForInterface node interface.id) + (idForInterface config.nodes.${conn.node} conn.interface) + { + style = optionalAttrs (interface.network != null) { + stroke = config.networks.${interface.network}.color; + }; + } + ) ) )); @@ -175,7 +171,7 @@ { "portLabels.placement" = "OUTSIDE"; } - // optionalAttrs (node.preferredRenderType == "card") { + // optionalAttrs (node.renderer.preferredType == "card") { "portConstraints" = "FIXED_SIDE"; }; }; @@ -193,10 +189,8 @@ ] ++ optional (node.parent != null) ( { - children."node:${node.parent}".ports.guests = { + children."node:${node.parent}".ports.guests = mkPort { properties."port.side" = "EAST"; - width = 8; - height = 8; style.stroke = "#49d18d"; style.fill = "#78dba9"; labels."00-name" = mkLabel "guests" 1 {}; diff --git a/topology/topology/renderers/svg/default.nix b/topology/topology/renderers/svg/default.nix index c54a104..0bbc5bc 100644 --- a/topology/topology/renderers/svg/default.nix +++ b/topology/topology/renderers/svg/default.nix @@ -1,27 +1,20 @@ # TODO: -# - stack interfaces horizontally, information is now on the ports anyway. -# - systemd extractor remove cidr mask # - address port label render make newline capable (multiple port labels) -# - mac address show! -# - split network layout or make rectpacking of childs # - NAT indication -# - bottom hw image distorted in card view (move to top anyway) # - embed font globally, try removing satori embed? # - network overview card (list all networks with name and cidr, legend style) -# - colors! -# - ip labels on edges -# - network centric view -# - better layout for interfaces in svg -# - sevice infos -# - disks (from disko) + render +# - stable pseudorandom colors from palette with no-reuse until necessary +# - network centric view as standalone +# - split network layout or make rectpacking of childs # - hardware info (image small top and image big bottom and full (no card), maybe just image and render position) # - more service info +# - disks (from disko) + render # - impermanence render? # - nixos nftables firewall render? -# - stable pseudorandom colors from palette with no-reuse until necessary -# - search todo and do # - podman / docker harvesting +# - systemd extractor remove cidr mask # - nixos-container extractor +# - search todo and do { config, lib, @@ -144,24 +137,16 @@ }; node = rec { - mkInterface = interface: let - color = - if interface.virtual - then "#7a899f" - else "#70a5eb"; - in - /* - html - */ - '' -
-
- ${mkImage "w-6 h-6 mr-2" (config.lib.icons.get interface.icon)} - ${interface.id} -
- ${toString interface.addresses} -
- ''; + mkInterface = interface: + /* + html + */ + '' +
+ ${mkImage "w-8 h-8 m-1" (config.lib.icons.get interface.icon)} + ${interface.id} +
+ ''; serviceDetail = detail: /* @@ -217,19 +202,6 @@ ''; - mkTitle = node: - /* - html - */ - '' -
- ${mkImageMaybe "w-8 h-8 mr-4" (config.lib.icons.get node.icon)} -

${node.name}

-
- ${mkImageMaybe "w-12 h-12 ml-4" (config.lib.icons.get node.deviceIcon)} -
- ''; - mkCard = node: let services = filter (x: !x.hidden) (attrValues node.services); guests = filter (x: x.parent == node.id) (attrValues config.nodes); @@ -241,18 +213,25 @@ html */ '' - ${mkTitle node} +
+ ${mkImageMaybe "w-8 h-8 mr-4" (config.lib.icons.get node.icon)} +
+ ${node.name} + ${optionalString (node.hardware.info != null) ''${node.hardware.info}''} +
+
+ ${mkImageMaybe "w-12 h-12 ml-4" (config.lib.icons.get node.deviceIcon)} +
+ ${optionalString (node.interfaces != {}) ''
''} ${concatLines (map mkInterface (attrValues node.interfaces))} - ${optionalString (node.interfaces != {}) spacingMt2} + ${optionalString (node.interfaces != {}) ''
''} ${concatLines (map mkGuest guests)} ${optionalString (guests != []) spacingMt2} ${concatLines (map (mkService {}) services)} ${optionalString (services != []) spacingMt2} - - ${mkImageMaybe "w-full h-24" node.hardware.image} ''; }; @@ -267,21 +246,26 @@ ''
${mkImageMaybe "w-8 h-8" (config.lib.icons.get node.icon)} -

${node.name}

- ${optionalString (node.hardware.image != null -> deviceIconImage != node.hardware.image) +
+ ${node.name} + ${optionalString (node.hardware.info != null) ''${node.hardware.info}''} +
+ ${optionalString (deviceIconImage != null && node.hardware.image != null -> deviceIconImage != node.hardware.image) ''
${mkImageMaybe "w-12 h-12" deviceIconImage} ''}
- ${mkImageMaybe "h-24" node.hardware.image} +
+ ${mkImageMaybe "h-24" node.hardware.image} +
''; }; mkPreferredRender = node: ( - if node.preferredRenderType == "image" && node.hardware.image != null + if node.renderer.preferredType == "image" && node.hardware.image != null then mkImageWithName else mkCard )