LabelPage.razor 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239
  1. @page "/labels/{LabelName}"
  2. @using RackPeek.Domain.Persistence
  3. @using RackPeek.Domain.Resources
  4. @using RackPeek.Domain.Resources.SystemResources
  5. @inject IResourceCollection ResourceRepository
  6. <PageTitle>Label: @LabelName</PageTitle>
  7. <div class="min-h-screen bg-zinc-950 text-zinc-200 font-mono p-6 space-y-6"
  8. data-testid="label-page-root">
  9. <!-- Header -->
  10. <div class="space-y-2">
  11. <h1 class="text-lg text-zinc-100"
  12. data-testid="label-page-title">
  13. Label: <span class="text-emerald-400">@LabelName</span>
  14. </h1>
  15. </div>
  16. <!-- Controls -->
  17. @if (_resources is not null && _resources.Count > 0)
  18. {
  19. <div class="flex flex-wrap gap-4 items-center text-xs">
  20. <!-- Filter -->
  21. <input placeholder="filter resources..."
  22. class="bg-zinc-900 border border-zinc-800 px-3 py-1 rounded focus:outline-none focus:border-emerald-600"
  23. data-testid="label-page-filter"
  24. @bind="_filter"/>
  25. <!-- Sort By -->
  26. <select class="bg-zinc-900 border border-zinc-800 px-2 py-1 rounded"
  27. data-testid="label-page-sort"
  28. @bind="_sortBy">
  29. <option value="Value">Value</option>
  30. <option value="Name">Name</option>
  31. <option value="Kind">Kind</option>
  32. </select>
  33. <!-- Direction -->
  34. <button class="border border-zinc-800 px-3 py-1 rounded hover:border-emerald-600"
  35. data-testid="label-page-sort-direction"
  36. @onclick="ToggleSortDirection">
  37. @(_ascending ? "Asc ↑" : "Desc ↓")
  38. </button>
  39. </div>
  40. }
  41. <!-- Content -->
  42. @if (_resources is null)
  43. {
  44. <div class="text-zinc-500">loading resources…</div>
  45. }
  46. else if (_resources.Count == 0)
  47. {
  48. <div class="text-zinc-500"
  49. data-testid="label-page-empty">
  50. no resources found for this label
  51. </div>
  52. }
  53. else if (!GetProcessed().Any())
  54. {
  55. <div class="text-zinc-500"
  56. data-testid="label-page-no-results">
  57. no results match your filter
  58. </div>
  59. }
  60. else
  61. {
  62. <div class="space-y-3"
  63. data-testid="label-page-list">
  64. @foreach (var item in GetProcessed())
  65. {
  66. var resource = item.Resource;
  67. var value = item.Value;
  68. <div class="border border-zinc-800 rounded p-3 bg-zinc-900 hover:border-emerald-700 transition"
  69. data-testid=@($"label-page-item-{resource.Name.Replace(" ", "-")}")>
  70. <NavLink href="@GetResourceUrl(resource)"
  71. class="block hover:text-emerald-300">
  72. <div class="flex justify-between items-center">
  73. <div class="text-zinc-100">
  74. @resource.Name
  75. </div>
  76. <div class="text-xs text-zinc-500 uppercase tracking-wide">
  77. @resource.Kind
  78. </div>
  79. </div>
  80. <!-- Label value -->
  81. <div class="mt-2 text-xs text-emerald-400 flex items-center gap-2"
  82. data-testid="label-page-item-value">
  83. <span>
  84. Value: @FormatValue(value)
  85. </span>
  86. <span class="text-zinc-500 uppercase text-[10px] tracking-wide">
  87. [@DetectType(value)]
  88. </span>
  89. </div>
  90. </NavLink>
  91. </div>
  92. }
  93. </div>
  94. }
  95. </div>
  96. @code {
  97. [Parameter] public string LabelName { get; set; } = string.Empty;
  98. private IReadOnlyList<(Resource Resource, string Value)>? _resources;
  99. private string _sortBy = "Value";
  100. private bool _ascending = true;
  101. private string _filter = string.Empty;
  102. protected override async Task OnParametersSetAsync()
  103. {
  104. var decoded = Uri.UnescapeDataString(LabelName);
  105. _resources = await ResourceRepository.GetByLabelAsync(decoded);
  106. }
  107. private IEnumerable<(Resource Resource, string Value)> GetProcessed()
  108. {
  109. if (_resources is null)
  110. return [];
  111. var query = _resources.AsEnumerable();
  112. // Filtering
  113. if (!string.IsNullOrWhiteSpace(_filter))
  114. {
  115. query = query.Where(r =>
  116. r.Resource.Name.Contains(_filter, StringComparison.OrdinalIgnoreCase) ||
  117. r.Value.Contains(_filter, StringComparison.OrdinalIgnoreCase));
  118. }
  119. // Sorting
  120. query = _sortBy switch
  121. {
  122. "Value" => query
  123. .OrderBy(r => GetSortKey(r.Value))
  124. .ThenBy(r => r.Resource.Name),
  125. "Kind" => query.OrderBy(r => r.Resource.Kind),
  126. _ => query.OrderBy(r => r.Resource.Name)
  127. };
  128. if (!_ascending)
  129. query = query.Reverse();
  130. return query.ToList();
  131. }
  132. private void ToggleSortDirection()
  133. {
  134. _ascending = !_ascending;
  135. }
  136. private static string DetectType(string value)
  137. {
  138. if (DateTime.TryParse(value, out _)) return "DATE";
  139. if (bool.TryParse(value, out _)) return "BOOL";
  140. if (decimal.TryParse(value, out _)) return "NUMBER";
  141. return "TEXT";
  142. }
  143. private static string FormatValue(string value)
  144. {
  145. if (DateTime.TryParse(value, out var dt))
  146. return dt.ToString("yyyy-MM-dd");
  147. return value;
  148. }
  149. private string GetResourceUrl(Resource resource)
  150. {
  151. if (resource.Kind == SystemResource.KindLabel)
  152. return $"resources/systems/{Uri.EscapeDataString(resource.Name)}";
  153. if (resource.Kind == Service.KindLabel)
  154. return $"resources/services/{Uri.EscapeDataString(resource.Name)}";
  155. return $"resources/hardware/{Uri.EscapeDataString(resource.Name)}";
  156. }
  157. private readonly record struct SortKey(int TypeRank, object Value, string Fallback)
  158. : IComparable<SortKey>
  159. {
  160. public int CompareTo(SortKey other)
  161. {
  162. var rank = TypeRank.CompareTo(other.TypeRank);
  163. if (rank != 0) return rank;
  164. var valueCompare = TypeRank switch
  165. {
  166. 0 => ((int)Value).CompareTo((int)other.Value),
  167. 1 => ((DateTime)Value).CompareTo((DateTime)other.Value),
  168. 2 => ((decimal)Value).CompareTo((decimal)other.Value),
  169. 3 => ((bool)Value).CompareTo((bool)other.Value),
  170. _ => string.Compare((string)Value, (string)other.Value, StringComparison.Ordinal)
  171. };
  172. if (valueCompare != 0) return valueCompare;
  173. // Deterministic tie-breaker
  174. return string.Compare(Fallback, other.Fallback, StringComparison.Ordinal);
  175. }
  176. }
  177. private static SortKey GetSortKey(string? raw)
  178. {
  179. var s = raw?.Trim() ?? string.Empty;
  180. if (string.IsNullOrEmpty(s))
  181. return new SortKey(0, 0, "");
  182. // Date
  183. if (DateTime.TryParse(s, out var dt))
  184. return new SortKey(1, dt, s);
  185. // Number
  186. if (decimal.TryParse(s, out var dec))
  187. return new SortKey(2, dec, s);
  188. // Bool
  189. if (bool.TryParse(s, out var b))
  190. return new SortKey(3, b, s);
  191. // Text (case-insensitive sort by normalizing)
  192. return new SortKey(4, s.ToLowerInvariant(), s);
  193. }
  194. }