| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323 |
- @page "/subnets"
- @using RackPeek.Domain.Persistence
- @using RackPeek.Domain.Resources
- @using RackPeek.Domain.Resources.SystemResources
- @inject IResourceCollection ResourceCollection
- <div class="min-h-screen bg-zinc-950 text-zinc-200 font-mono p-6 space-y-6"
- data-testid="subnet-browser-root">
- <h1 class="text-lg text-zinc-100"
- data-testid="subnet-browser-title">
- Subnet Browser
- </h1>
- <!-- Filter -->
- <div>
- <input
- data-testid="subnet-browser-filter"
- placeholder="Filter by IP or :port (e.g. 10.0.99 or :8080)"
- class="w-full px-3 py-2 rounded-md
- bg-zinc-800 text-zinc-100
- border border-zinc-700
- focus:outline-none focus:ring-2 focus:ring-emerald-500"
- @bind="Filter"
- @bind:event="oninput"/>
- </div>
- @if (_grouped is not null && _grouped.Any())
- {
- var nextFreeIp = GetNextFreeIp();
- var nextFreePort = GetNextFreePort();
- <div class="text-xs text-zinc-600 bg-zinc-900 border border-zinc-800 rounded-md px-3 py-2">
- <div>
- next free ip:
- <span class="text-emerald-400">@nextFreeIp</span>
- </div>
- <div>
- next free port:
- <span class="text-emerald-400">@nextFreePort</span>
- </div>
- </div>
- }
- @if (_grouped is null)
- {
- <div class="text-zinc-500"
- data-testid="subnet-browser-loading">
- loading…
- </div>
- }
- else if (!_grouped.Any())
- {
- <div class="text-zinc-500"
- data-testid="subnet-browser-empty">
- no matching IPs found
- </div>
- }
- else
- {
- <div class="space-y-6"
- data-testid="subnet-browser-list">
- @foreach (var subnetGroup in _grouped.OrderBy(x => IpToSortable(x.Key.Replace(".x", ".0"))))
- {
- <div data-testid=@($"subnet-group-{subnetGroup.Key.Replace('.', '-')}")>
- <!-- Subnet Header -->
- @{
- var addresses = subnetGroup.Value.Count;
- var services = subnetGroup.Value
- .SelectMany(x => x.Value)
- .Count(x => x.Item1 is Service);
- // /24 assumption (x subnet)
- const int subnetCapacity = 256;
- var percentFull = (int)Math.Round((double)addresses / subnetCapacity * 100);
- }
- <div class="text-xs text-zinc-500 uppercase tracking-wider mb-3 flex items-baseline gap-3">
- <span>@subnetGroup.Key</span>
- <span class="text-zinc-600 normal-case tracking-normal">
- (@addresses addresses,
- @services services,
- @percentFull% full)
- </span>
- </div>
- <ul class="ml-2 border-l border-zinc-800 pl-4 space-y-3">
- @foreach (var ipGroup in subnetGroup.Value.OrderBy(x => IpToSortable(x.Key)))
- {
- <li>
- <!-- IP -->
- <div class="text-zinc-100">
- ├─ @ipGroup.Key
- </div>
- <!-- Resources on this IP -->
- <ul class="ml-4 mt-2 border-l border-zinc-800 pl-4 space-y-1">
- @foreach (var (resource, _) in ipGroup.Value)
- {
- var url = GetResourceUrl(resource);
- var port = resource is Service
- {
- Network.Port: not null
- } service
- ? service.Network!.Port
- : null;
- var typeName = resource.GetType().Name.Replace("Resource", "");
- <li class="text-zinc-500 hover:text-emerald-300">
- <NavLink href="@url" class="block">
- @resource.Name
- @if (port.HasValue)
- {
- <span class="text-emerald-400">:@port</span>
- }
- (@typeName)
- </NavLink>
- </li>
- }
- </ul>
- </li>
- }
- </ul>
- </div>
- }
- </div>
- }
- </div>
- @code {
- private string _filter = string.Empty;
- private IReadOnlyList<(Resource resource, string ip)> _all = [];
- private Dictionary<string, Dictionary<string, List<(Resource, string)>>>? _grouped;
- protected override async Task OnInitializedAsync()
- {
- _all = await ResourceCollection.GetResourceIpsAsync();
- ApplyFilter();
- }
- private string Filter
- {
- get => _filter;
- set
- {
- if (_filter == value)
- return;
- _filter = value;
- ApplyFilter();
- }
- }
- private void ApplyFilter()
- {
- var filter = _filter?.Trim();
- IEnumerable<(Resource resource, string ip)> filtered = _all;
- if (!string.IsNullOrWhiteSpace(filter))
- {
- // PORT MODE (":22" or ":2" etc.) — prefix match as user types
- if (filter.StartsWith(":"))
- {
- var portText = filter[1..].Trim();
- if (string.IsNullOrEmpty(portText))
- {
- // ":" alone -> show everything with a port
- filtered = _all.Where(x =>
- x.resource is Service
- {
- Network.Port: not null
- });
- }
- else if (portText.All(char.IsDigit))
- {
- filtered = _all.Where(x =>
- x.resource is Service
- {
- Network.Port: not null
- } service
- && service.Network!.Port!.Value.ToString().StartsWith(portText, StringComparison.Ordinal));
- }
- else
- {
- // ":abc" -> no matches
- filtered = [];
- }
- }
- else
- {
- // IP OR PORT MATCH (non ":" input)
- filtered = _all.Where(x =>
- x.ip.Contains(filter, StringComparison.OrdinalIgnoreCase)
- ||
- (
- x.resource is Service
- {
- Network.Port: not null
- } service
- && service.Network!.Port!.Value.ToString()
- .Contains(filter, StringComparison.Ordinal)
- )
- );
- }
- }
- _grouped = filtered
- .Where(x => !string.IsNullOrWhiteSpace(x.ip))
- .GroupBy(x => GetSubnet(x.ip))
- .ToDictionary(
- g => g.Key,
- g => g.GroupBy(x => x.ip)
- .ToDictionary(
- ip => ip.Key,
- ip => ip.ToList()
- )
- );
- StateHasChanged();
- }
- private string GetSubnet(string ip)
- {
- var parts = ip.Split('.');
- return parts.Length == 4
- ? $"{parts[0]}.{parts[1]}.{parts[2]}.x"
- : "unknown";
- }
- private string GetResourceUrl(Resource resource)
- {
- return resource switch
- {
- SystemResource
- => $"resources/systems/{Uri.EscapeDataString(resource.Name)}",
- Service
- => $"resources/services/{Uri.EscapeDataString(resource.Name)}",
- _ => "#"
- };
- }
- private string GetNextFreeIp()
- {
- if (_grouped is null || !_grouped.Any())
- return "n/a";
- foreach (var subnet in _grouped.OrderBy(x => x.Key))
- {
- var usedHosts = subnet.Value.Keys
- .Select(ip => ip.Split('.').Last())
- .Select(part => int.TryParse(part, out var n) ? n : -1)
- .Where(n => n >= 0)
- .ToHashSet();
- for (var host = 1; host < 255; host++)
- {
- if (!usedHosts.Contains(host))
- {
- var baseSubnet = subnet.Key.Replace(".x", "");
- return $"{baseSubnet}.{host}";
- }
- }
- }
- return "full";
- }
- private int GetNextFreePort()
- {
- if (_grouped is null)
- return 1024;
- var usedPorts = _grouped
- .SelectMany(s => s.Value)
- .SelectMany(ip => ip.Value)
- .Select(x => x.Item1)
- .OfType<Service>()
- .Where(s => s.Network?.Port is not null)
- .Select(s => s.Network!.Port!.Value)
- .ToHashSet();
- for (var port = 1024; port < 65535; port++)
- {
- if (!usedPorts.Contains(port))
- return port;
- }
- return -1;
- }
-
- private static uint IpToSortable(string ip)
- {
- var parts = ip.Split('.')
- .Select(p => byte.TryParse(p, out var b) ? b : (byte)0)
- .ToArray();
- if (parts.Length != 4)
- return 0;
- return ((uint)parts[0] << 24)
- | ((uint)parts[1] << 16)
- | ((uint)parts[2] << 8)
- | parts[3];
- }
- }
|