YamlFileComponent.razor 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246
  1. @using System.Collections.Specialized
  2. @using RackPeek.Domain.Persistence
  3. @using RackPeek.Domain.Persistence.Yaml
  4. @using RackPeek.Domain.Resources
  5. @using RackPeek.Domain.Resources.AccessPoints
  6. @using RackPeek.Domain.Resources.Desktops
  7. @using RackPeek.Domain.Resources.Firewalls
  8. @using RackPeek.Domain.Resources.Laptops
  9. @using RackPeek.Domain.Resources.Servers
  10. @using RackPeek.Domain.Resources.Switches
  11. @using RackPeek.Domain.Resources.SystemResources
  12. @using YamlDotNet.Serialization
  13. @using YamlDotNet.Serialization.NamingConventions
  14. @using Router = RackPeek.Domain.Resources.Routers.Router
  15. @inject ITextFileStore FileStore
  16. @inject IResourceCollection Resources
  17. <div class="border border-zinc-800 rounded p-4 bg-zinc-900">
  18. <div class="flex justify-between items-center mb-3">
  19. <div class="text-zinc-100">
  20. @Title
  21. </div>
  22. <div class="flex gap-3 text-xs">
  23. @if (!_isEditing)
  24. {
  25. <button class="text-zinc-400 hover:text-zinc-200"
  26. @onclick="BeginEdit">
  27. Edit
  28. </button>
  29. }
  30. else
  31. {
  32. <button class="text-emerald-400 hover:text-emerald-300"
  33. @onclick="Save">
  34. Save
  35. </button>
  36. <button class="text-zinc-500 hover:text-zinc-300"
  37. @onclick="Cancel">
  38. Cancel
  39. </button>
  40. }
  41. </div>
  42. </div>
  43. @if (!_exists)
  44. {
  45. <div class="text-red-400 text-sm">
  46. File does not exist.
  47. </div>
  48. }
  49. else if (_isEditing)
  50. {
  51. <textarea class="w-full input font-mono text-xs"
  52. style="min-height: 40rem"
  53. @bind="_editText">
  54. </textarea>
  55. @if (!string.IsNullOrEmpty(_validationError))
  56. {
  57. <div class="mt-2 text-red-400 text-xs">
  58. @_validationError
  59. </div>
  60. }
  61. }
  62. else
  63. {
  64. <pre class="text-zinc-300 text-xs whitespace-pre-wrap">@_currentText</pre>
  65. }
  66. </div>
  67. <ConfirmModal
  68. IsOpen="_confirmDeleteOpen"
  69. IsOpenChanged="v => _confirmDeleteOpen = v"
  70. Title="Delete File"
  71. ConfirmText="Delete"
  72. ConfirmClass="bg-red-600 hover:bg-red-500"
  73. OnConfirm="DeleteFile"
  74. TestIdPrefix="File">>
  75. Are you sure you want to delete <strong>@Path</strong>?
  76. </ConfirmModal>
  77. @code {
  78. [Parameter] [EditorRequired] public string Path { get; set; } = default!;
  79. [Parameter] public string Title { get; set; } = "Edit YAML";
  80. [Parameter] public EventCallback<string> OnDeleted { get; set; }
  81. bool _isEditing;
  82. bool _exists;
  83. bool _confirmDeleteOpen;
  84. string _currentText = "";
  85. string _editText = "";
  86. string? _validationError;
  87. protected override async Task OnParametersSetAsync()
  88. {
  89. await Load();
  90. }
  91. async Task Load()
  92. {
  93. _exists = await FileStore.ExistsAsync(Path);
  94. if (!_exists)
  95. return;
  96. _currentText = await FileStore.ReadAllTextAsync(Path);
  97. }
  98. void BeginEdit()
  99. {
  100. _editText = _currentText;
  101. _validationError = null;
  102. _isEditing = true;
  103. }
  104. void Cancel()
  105. {
  106. _isEditing = false;
  107. _validationError = null;
  108. }
  109. async Task Save()
  110. {
  111. if (!ValidateYamlRoundTrip(_editText, out var error))
  112. {
  113. _validationError = error;
  114. return;
  115. }
  116. await FileStore.WriteAllTextAsync(Path, _editText);
  117. await Resources.LoadAsync();
  118. _currentText = _editText;
  119. _isEditing = false;
  120. }
  121. void ConfirmDelete()
  122. {
  123. _confirmDeleteOpen = true;
  124. }
  125. async Task DeleteFile()
  126. {
  127. _confirmDeleteOpen = false;
  128. // if your store supports delete, call it here
  129. await FileStore.WriteAllTextAsync(Path, "");
  130. if (OnDeleted.HasDelegate)
  131. await OnDeleted.InvokeAsync(Path);
  132. }
  133. private bool ValidateYamlRoundTrip(string yaml, out string? error)
  134. {
  135. try
  136. {
  137. if (string.IsNullOrWhiteSpace(yaml))
  138. {
  139. error = "YAML is empty.";
  140. return false;
  141. }
  142. // ---------- DESERIALIZER (same as resource loader) ----------
  143. var deserializer = new DeserializerBuilder()
  144. .WithNamingConvention(CamelCaseNamingConvention.Instance)
  145. .WithCaseInsensitivePropertyMatching()
  146. .WithTypeConverter(new StorageSizeYamlConverter())
  147. .WithTypeDiscriminatingNodeDeserializer(options =>
  148. {
  149. options.AddKeyValueTypeDiscriminator<Resource>("kind", new Dictionary<string, Type>
  150. {
  151. { Server.KindLabel, typeof(Server) },
  152. { Switch.KindLabel, typeof(Switch) },
  153. { Firewall.KindLabel, typeof(Firewall) },
  154. { Router.KindLabel, typeof(Router) },
  155. { Desktop.KindLabel, typeof(Desktop) },
  156. { Laptop.KindLabel, typeof(Laptop) },
  157. { AccessPoint.KindLabel, typeof(AccessPoint) },
  158. { RackPeek.Domain.Resources.UpsUnits.Ups.KindLabel, typeof(RackPeek.Domain.Resources.UpsUnits.Ups) },
  159. { SystemResource.KindLabel, typeof(SystemResource) },
  160. { Service.KindLabel, typeof(Service) }
  161. });
  162. })
  163. .Build();
  164. var root = deserializer.Deserialize<YamlRoot>(yaml);
  165. if (root?.Resources == null)
  166. {
  167. error = "No resources section found.";
  168. return false;
  169. }
  170. // ---------- SERIALIZE AGAIN ----------
  171. var serializer = new SerializerBuilder()
  172. .WithNamingConvention(CamelCaseNamingConvention.Instance)
  173. .Build();
  174. var payload = new OrderedDictionary
  175. {
  176. ["resources"] = root.Resources
  177. };
  178. var roundTripYaml = serializer.Serialize(payload);
  179. // ---------- DESERIALIZE AGAIN ----------
  180. var root2 = deserializer.Deserialize<YamlRoot>(roundTripYaml);
  181. if (root2?.Resources == null)
  182. {
  183. error = "Round-trip serialization failed.";
  184. return false;
  185. }
  186. // ---------- DUPLICATE NAME CHECK ----------
  187. var dup = root2.Resources
  188. .GroupBy(r => r.Name, StringComparer.OrdinalIgnoreCase)
  189. .FirstOrDefault(g => g.Count() > 1);
  190. if (dup != null)
  191. {
  192. error = $"Duplicate resource name: '{dup.Key}'";
  193. return false;
  194. }
  195. error = null;
  196. return true;
  197. }
  198. catch (Exception ex)
  199. {
  200. error = $"YAML validation failed: {ex.Message}";
  201. return false;
  202. }
  203. }
  204. }