YamlImportPage.razor 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357
  1. @page "/yaml/import"
  2. @using System.ComponentModel.DataAnnotations
  3. @using System.Text
  4. @using RackPeek.Domain.Api
  5. @using RackPeek.Domain.Persistence
  6. @using YamlDotNet.Core
  7. <PageTitle>Yaml Import</PageTitle>
  8. @inject UpsertInventoryUseCase ImportUseCase
  9. <div class="border border-zinc-800 rounded p-4 bg-zinc-900 mt-6">
  10. <div class="text-zinc-100 mb-3">
  11. Import YAML
  12. </div>
  13. <!-- Mode selector -->
  14. <div class="mb-4 flex gap-6 text-xs">
  15. <label class="flex items-start gap-2 cursor-pointer">
  16. <input type="radio"
  17. name="mergeMode"
  18. checked="@(_mode == MergeMode.Merge)"
  19. @onchange="() => OnModeChanged(MergeMode.Merge)"/>
  20. <div>
  21. <div class="text-emerald-400">Merge</div>
  22. <div class="text-zinc-500">
  23. Update matching resources. Properties not specified remain unchanged.
  24. </div>
  25. </div>
  26. </label>
  27. <label class="flex items-start gap-2 cursor-pointer">
  28. <input type="radio"
  29. name="mergeMode"
  30. checked="@(_mode == MergeMode.Replace)"
  31. @onchange="() => OnModeChanged(MergeMode.Replace)"/>
  32. <div>
  33. <div class="text-red-400">Replace</div>
  34. <div class="text-zinc-500">
  35. Completely replace matching resources.
  36. </div>
  37. </div>
  38. </label>
  39. </div>
  40. <!-- YAML Input -->
  41. <textarea class="w-full input font-mono text-xs mb-3"
  42. style="min-height: 18rem"
  43. placeholder="Paste YAML here..."
  44. @bind="_inputYaml"
  45. @bind:event="oninput"
  46. @bind:after="ComputeDiff">
  47. </textarea>
  48. @if (_error is not null)
  49. {
  50. <div class="border border-red-500/40 bg-red-500/10 rounded p-3 mb-3"
  51. data-testid="yaml-import-error"
  52. role="alert">
  53. <div class="flex items-start gap-2">
  54. <!-- alert icon -->
  55. <svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 flex-shrink-0 text-red-400 mt-0.5"
  56. fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
  57. <path stroke-linecap="round" stroke-linejoin="round"
  58. d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"/>
  59. </svg>
  60. <div class="min-w-0 flex-1">
  61. <div class="text-red-400 text-sm font-semibold">
  62. @_error.Headline
  63. </div>
  64. @if (_error.Line is long line)
  65. {
  66. <div class="text-zinc-400 text-xs mt-1">
  67. Line @line@(_error.Column is long col ? $", column {col}" : "")
  68. </div>
  69. }
  70. @if (!string.IsNullOrEmpty(_error.Snippet))
  71. {
  72. <pre class="mt-2 p-2 rounded bg-zinc-950 border border-zinc-800 text-xs overflow-x-auto text-zinc-300"
  73. data-testid="yaml-import-error-snippet">@_error.Snippet</pre>
  74. }
  75. </div>
  76. </div>
  77. </div>
  78. }
  79. <!-- Apply -->
  80. <div class="flex gap-3 text-xs mb-4">
  81. <button class="text-amber-400 hover:text-amber-300 disabled:opacity-40"
  82. disabled="@(!_isValid)"
  83. @onclick="Apply">
  84. Apply
  85. </button>
  86. </div>
  87. <!-- Summary + Diff -->
  88. @if (_isValid)
  89. {
  90. <div class="mt-4 text-xs">
  91. <div class="mb-2 text-zinc-400">
  92. Import Summary
  93. </div>
  94. @if (!_added.Any() && !_updated.Any() && !_replaced.Any())
  95. {
  96. <div class="text-zinc-500 italic mt-2">
  97. No changes detected.
  98. </div>
  99. }
  100. @if (_added.Any())
  101. {
  102. <div class="text-emerald-400 mb-2">
  103. + @_added.Count added
  104. </div>
  105. @foreach (var name in _added)
  106. {
  107. <div class="ml-4 text-zinc-300 mb-4">
  108. <div class="font-bold">+ @name</div>
  109. <pre class="bg-zinc-800 p-2 rounded text-xs overflow-x-auto">
  110. @_newYaml[name]
  111. </pre>
  112. </div>
  113. }
  114. }
  115. @if (_updated.Any())
  116. {
  117. <div class="text-amber-400 mt-4 mb-2">
  118. ~ @_updated.Count updated
  119. </div>
  120. @foreach (var name in _updated)
  121. {
  122. <div class="ml-4 mb-6">
  123. <div class="font-bold text-zinc-300 mb-2">~ @name</div>
  124. <div class="grid grid-cols-2 gap-4">
  125. <div>
  126. <div class="text-zinc-500 mb-1">Current</div>
  127. <pre class="bg-zinc-800 p-2 rounded text-xs overflow-x-auto">
  128. @_oldYaml[name]
  129. </pre>
  130. </div>
  131. <div>
  132. <div class="text-emerald-500 mb-1">Incoming (Merged)</div>
  133. <pre class="bg-zinc-800 p-2 rounded text-xs overflow-x-auto">
  134. @_newYaml[name]
  135. </pre>
  136. </div>
  137. </div>
  138. </div>
  139. }
  140. }
  141. @if (_replaced.Any())
  142. {
  143. <div class="text-red-400 mt-4 mb-2">
  144. ! @_replaced.Count replaced
  145. </div>
  146. @foreach (var name in _replaced)
  147. {
  148. <div class="ml-4 mb-6">
  149. <div class="font-bold text-zinc-300 mb-2">! @name</div>
  150. <div class="grid grid-cols-2 gap-4">
  151. <div>
  152. <div class="text-zinc-500 mb-1">Current</div>
  153. <pre class="bg-zinc-800 p-2 rounded text-xs overflow-x-auto">
  154. @_oldYaml[name]
  155. </pre>
  156. </div>
  157. <div>
  158. <div class="text-red-400 mb-1">Incoming (Replacement)</div>
  159. <pre class="bg-zinc-800 p-2 rounded text-xs overflow-x-auto">
  160. @_newYaml[name]
  161. </pre>
  162. </div>
  163. </div>
  164. </div>
  165. }
  166. }
  167. </div>
  168. }
  169. </div>
  170. @code {
  171. private List<string> _added = new();
  172. private List<string> _updated = new();
  173. private List<string> _replaced = new();
  174. private Dictionary<string, string> _oldYaml = new(StringComparer.OrdinalIgnoreCase);
  175. private Dictionary<string, string> _newYaml = new(StringComparer.OrdinalIgnoreCase);
  176. private string _inputYaml = "";
  177. private ImportError? _error;
  178. private bool _isValid;
  179. private MergeMode _mode = MergeMode.Merge;
  180. async Task OnModeChanged(MergeMode mode)
  181. {
  182. _mode = mode;
  183. await ComputeDiff();
  184. }
  185. async Task ComputeDiff()
  186. {
  187. _error = null;
  188. _isValid = false;
  189. _added.Clear();
  190. _updated.Clear();
  191. _replaced.Clear();
  192. _oldYaml.Clear();
  193. _newYaml.Clear();
  194. if (string.IsNullOrWhiteSpace(_inputYaml))
  195. return;
  196. try
  197. {
  198. var result = await ImportUseCase.ExecuteAsync(new ImportYamlRequest
  199. {
  200. Yaml = _inputYaml,
  201. Mode = _mode,
  202. DryRun = true
  203. });
  204. _added = result.Added;
  205. _updated = result.Updated;
  206. _replaced = result.Replaced;
  207. _oldYaml = result.OldYaml;
  208. _newYaml = result.NewYaml;
  209. _isValid = true;
  210. }
  211. catch (Exception ex)
  212. {
  213. _error = BuildError(ex, headlinePrefix: "YAML invalid");
  214. }
  215. }
  216. async Task Apply()
  217. {
  218. if (!_isValid)
  219. return;
  220. try
  221. {
  222. await ImportUseCase.ExecuteAsync(new ImportYamlRequest
  223. {
  224. Yaml = _inputYaml,
  225. Mode = _mode,
  226. DryRun = false
  227. });
  228. _inputYaml = "";
  229. _isValid = false;
  230. _added.Clear();
  231. _updated.Clear();
  232. _replaced.Clear();
  233. }
  234. catch (Exception ex)
  235. {
  236. _error = BuildError(ex, headlinePrefix: "Apply failed");
  237. }
  238. }
  239. private ImportError BuildError(Exception ex, string headlinePrefix)
  240. {
  241. // YamlDotNet wraps the underlying parser error; walk InnerExceptions
  242. // to find the YamlException so we can extract Line/Column for the
  243. // user.
  244. YamlException? yamlEx = FindYamlException(ex);
  245. if (yamlEx is not null)
  246. {
  247. long? line = yamlEx.Start.Line > 0 ? yamlEx.Start.Line : null;
  248. long? col = yamlEx.Start.Column > 0 ? yamlEx.Start.Column : null;
  249. return new ImportError(
  250. $"{headlinePrefix}: {FirstLine(yamlEx.Message)}",
  251. line,
  252. col,
  253. line is long l ? ExtractSnippet(_inputYaml, (int)l) : null);
  254. }
  255. // Domain validation errors (duplicate names, missing version, etc.)
  256. // are already user-friendly — just surface them prominently.
  257. if (ex is ValidationException ve)
  258. return new ImportError(ve.Message, null, null, null);
  259. return new ImportError($"{headlinePrefix}: {FirstLine(ex.Message)}", null, null, null);
  260. }
  261. private static YamlException? FindYamlException(Exception? ex)
  262. {
  263. while (ex is not null)
  264. {
  265. if (ex is YamlException ye) return ye;
  266. ex = ex.InnerException;
  267. }
  268. return null;
  269. }
  270. private static string FirstLine(string message)
  271. {
  272. if (string.IsNullOrEmpty(message)) return string.Empty;
  273. var nl = message.IndexOf('\n');
  274. return nl < 0 ? message.Trim() : message[..nl].Trim();
  275. }
  276. private static string ExtractSnippet(string yaml, int lineNumber, int context = 2)
  277. {
  278. // Render `context` lines before and after the offending line, prefix
  279. // each with its 1-based line number, and mark the bad line with an
  280. // arrow so the eye lands on it instantly.
  281. var lines = yaml.Replace("\r\n", "\n").Split('\n');
  282. if (lineNumber < 1 || lineNumber > lines.Length) return string.Empty;
  283. var start = Math.Max(1, lineNumber - context);
  284. var end = Math.Min(lines.Length, lineNumber + context);
  285. var sb = new StringBuilder();
  286. for (int i = start; i <= end; i++)
  287. {
  288. sb.Append(i == lineNumber ? "→ " : " ")
  289. .Append(i.ToString().PadLeft(4))
  290. .Append(" ")
  291. .Append(lines[i - 1]);
  292. if (i < end) sb.Append('\n');
  293. }
  294. return sb.ToString();
  295. }
  296. private sealed record ImportError(string Headline, long? Line, long? Column, string? Snippet);
  297. }