SubnetBrowser.razor 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323
  1. @page "/subnets"
  2. @using RackPeek.Domain.Persistence
  3. @using RackPeek.Domain.Resources
  4. @using RackPeek.Domain.Resources.SystemResources
  5. @inject IResourceCollection ResourceCollection
  6. <div class="min-h-screen bg-zinc-950 text-zinc-200 font-mono p-6 space-y-6"
  7. data-testid="subnet-browser-root">
  8. <h1 class="text-lg text-zinc-100"
  9. data-testid="subnet-browser-title">
  10. Subnet Browser
  11. </h1>
  12. <!-- Filter -->
  13. <div>
  14. <input
  15. data-testid="subnet-browser-filter"
  16. placeholder="Filter by IP or :port (e.g. 10.0.99 or :8080)"
  17. class="w-full px-3 py-2 rounded-md
  18. bg-zinc-800 text-zinc-100
  19. border border-zinc-700
  20. focus:outline-none focus:ring-2 focus:ring-emerald-500"
  21. @bind="Filter"
  22. @bind:event="oninput"/>
  23. </div>
  24. @if (_grouped is not null && _grouped.Any())
  25. {
  26. var nextFreeIp = GetNextFreeIp();
  27. var nextFreePort = GetNextFreePort();
  28. <div class="text-xs text-zinc-600 bg-zinc-900 border border-zinc-800 rounded-md px-3 py-2">
  29. <div>
  30. next free ip:
  31. <span class="text-emerald-400">@nextFreeIp</span>
  32. </div>
  33. <div>
  34. next free port:
  35. <span class="text-emerald-400">@nextFreePort</span>
  36. </div>
  37. </div>
  38. }
  39. @if (_grouped is null)
  40. {
  41. <div class="text-zinc-500"
  42. data-testid="subnet-browser-loading">
  43. loading…
  44. </div>
  45. }
  46. else if (!_grouped.Any())
  47. {
  48. <div class="text-zinc-500"
  49. data-testid="subnet-browser-empty">
  50. no matching IPs found
  51. </div>
  52. }
  53. else
  54. {
  55. <div class="space-y-6"
  56. data-testid="subnet-browser-list">
  57. @foreach (var subnetGroup in _grouped.OrderBy(x => IpToSortable(x.Key.Replace(".x", ".0"))))
  58. {
  59. <div data-testid=@($"subnet-group-{subnetGroup.Key.Replace('.', '-')}")>
  60. <!-- Subnet Header -->
  61. @{
  62. var addresses = subnetGroup.Value.Count;
  63. var services = subnetGroup.Value
  64. .SelectMany(x => x.Value)
  65. .Count(x => x.Item1 is Service);
  66. // /24 assumption (x subnet)
  67. const int subnetCapacity = 256;
  68. var percentFull = (int)Math.Round((double)addresses / subnetCapacity * 100);
  69. }
  70. <div class="text-xs text-zinc-500 uppercase tracking-wider mb-3 flex items-baseline gap-3">
  71. <span>@subnetGroup.Key</span>
  72. <span class="text-zinc-600 normal-case tracking-normal">
  73. (@addresses addresses,
  74. @services services,
  75. @percentFull% full)
  76. </span>
  77. </div>
  78. <ul class="ml-2 border-l border-zinc-800 pl-4 space-y-3">
  79. @foreach (var ipGroup in subnetGroup.Value.OrderBy(x => IpToSortable(x.Key)))
  80. {
  81. <li>
  82. <!-- IP -->
  83. <div class="text-zinc-100">
  84. ├─ @ipGroup.Key
  85. </div>
  86. <!-- Resources on this IP -->
  87. <ul class="ml-4 mt-2 border-l border-zinc-800 pl-4 space-y-1">
  88. @foreach (var (resource, _) in ipGroup.Value)
  89. {
  90. var url = GetResourceUrl(resource);
  91. var port = resource is Service
  92. {
  93. Network.Port: not null
  94. } service
  95. ? service.Network!.Port
  96. : null;
  97. var typeName = resource.GetType().Name.Replace("Resource", "");
  98. <li class="text-zinc-500 hover:text-emerald-300">
  99. <NavLink href="@url" class="block">
  100. @resource.Name
  101. @if (port.HasValue)
  102. {
  103. <span class="text-emerald-400">:@port</span>
  104. }
  105. (@typeName)
  106. </NavLink>
  107. </li>
  108. }
  109. </ul>
  110. </li>
  111. }
  112. </ul>
  113. </div>
  114. }
  115. </div>
  116. }
  117. </div>
  118. @code {
  119. private string _filter = string.Empty;
  120. private IReadOnlyList<(Resource resource, string ip)> _all = [];
  121. private Dictionary<string, Dictionary<string, List<(Resource, string)>>>? _grouped;
  122. protected override async Task OnInitializedAsync()
  123. {
  124. _all = await ResourceCollection.GetResourceIpsAsync();
  125. ApplyFilter();
  126. }
  127. private string Filter
  128. {
  129. get => _filter;
  130. set
  131. {
  132. if (_filter == value)
  133. return;
  134. _filter = value;
  135. ApplyFilter();
  136. }
  137. }
  138. private void ApplyFilter()
  139. {
  140. var filter = _filter?.Trim();
  141. IEnumerable<(Resource resource, string ip)> filtered = _all;
  142. if (!string.IsNullOrWhiteSpace(filter))
  143. {
  144. // PORT MODE (":22" or ":2" etc.) — prefix match as user types
  145. if (filter.StartsWith(":"))
  146. {
  147. var portText = filter[1..].Trim();
  148. if (string.IsNullOrEmpty(portText))
  149. {
  150. // ":" alone -> show everything with a port
  151. filtered = _all.Where(x =>
  152. x.resource is Service
  153. {
  154. Network.Port: not null
  155. });
  156. }
  157. else if (portText.All(char.IsDigit))
  158. {
  159. filtered = _all.Where(x =>
  160. x.resource is Service
  161. {
  162. Network.Port: not null
  163. } service
  164. && service.Network!.Port!.Value.ToString().StartsWith(portText, StringComparison.Ordinal));
  165. }
  166. else
  167. {
  168. // ":abc" -> no matches
  169. filtered = [];
  170. }
  171. }
  172. else
  173. {
  174. // IP OR PORT MATCH (non ":" input)
  175. filtered = _all.Where(x =>
  176. x.ip.Contains(filter, StringComparison.OrdinalIgnoreCase)
  177. ||
  178. (
  179. x.resource is Service
  180. {
  181. Network.Port: not null
  182. } service
  183. && service.Network!.Port!.Value.ToString()
  184. .Contains(filter, StringComparison.Ordinal)
  185. )
  186. );
  187. }
  188. }
  189. _grouped = filtered
  190. .Where(x => !string.IsNullOrWhiteSpace(x.ip))
  191. .GroupBy(x => GetSubnet(x.ip))
  192. .ToDictionary(
  193. g => g.Key,
  194. g => g.GroupBy(x => x.ip)
  195. .ToDictionary(
  196. ip => ip.Key,
  197. ip => ip.ToList()
  198. )
  199. );
  200. StateHasChanged();
  201. }
  202. private string GetSubnet(string ip)
  203. {
  204. var parts = ip.Split('.');
  205. return parts.Length == 4
  206. ? $"{parts[0]}.{parts[1]}.{parts[2]}.x"
  207. : "unknown";
  208. }
  209. private string GetResourceUrl(Resource resource)
  210. {
  211. return resource switch
  212. {
  213. SystemResource
  214. => $"resources/systems/{Uri.EscapeDataString(resource.Name)}",
  215. Service
  216. => $"resources/services/{Uri.EscapeDataString(resource.Name)}",
  217. _ => "#"
  218. };
  219. }
  220. private string GetNextFreeIp()
  221. {
  222. if (_grouped is null || !_grouped.Any())
  223. return "n/a";
  224. foreach (var subnet in _grouped.OrderBy(x => x.Key))
  225. {
  226. var usedHosts = subnet.Value.Keys
  227. .Select(ip => ip.Split('.').Last())
  228. .Select(part => int.TryParse(part, out var n) ? n : -1)
  229. .Where(n => n >= 0)
  230. .ToHashSet();
  231. for (var host = 1; host < 255; host++)
  232. {
  233. if (!usedHosts.Contains(host))
  234. {
  235. var baseSubnet = subnet.Key.Replace(".x", "");
  236. return $"{baseSubnet}.{host}";
  237. }
  238. }
  239. }
  240. return "full";
  241. }
  242. private int GetNextFreePort()
  243. {
  244. if (_grouped is null)
  245. return 1024;
  246. var usedPorts = _grouped
  247. .SelectMany(s => s.Value)
  248. .SelectMany(ip => ip.Value)
  249. .Select(x => x.Item1)
  250. .OfType<Service>()
  251. .Where(s => s.Network?.Port is not null)
  252. .Select(s => s.Network!.Port!.Value)
  253. .ToHashSet();
  254. for (var port = 1024; port < 65535; port++)
  255. {
  256. if (!usedPorts.Contains(port))
  257. return port;
  258. }
  259. return -1;
  260. }
  261. private static uint IpToSortable(string ip)
  262. {
  263. var parts = ip.Split('.')
  264. .Select(p => byte.TryParse(p, out var b) ? b : (byte)0)
  265. .ToArray();
  266. if (parts.Length != 4)
  267. return 0;
  268. return ((uint)parts[0] << 24)
  269. | ((uint)parts[1] << 16)
  270. | ((uint)parts[2] << 8)
  271. | parts[3];
  272. }
  273. }