YamlImportPage.razor 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387
  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. && !_connectionsAdded.Any() && !_connectionsRemoved.Any())
  96. {
  97. <div class="text-zinc-500 italic mt-2">
  98. No changes detected.
  99. </div>
  100. }
  101. @if (_added.Any())
  102. {
  103. <div class="text-emerald-400 mb-2">
  104. + @_added.Count added
  105. </div>
  106. @foreach (var name in _added)
  107. {
  108. <div class="ml-4 text-zinc-300 mb-4">
  109. <div class="font-bold">+ @name</div>
  110. <pre class="bg-zinc-800 p-2 rounded text-xs overflow-x-auto">
  111. @_newYaml[name]
  112. </pre>
  113. </div>
  114. }
  115. }
  116. @if (_updated.Any())
  117. {
  118. <div class="text-amber-400 mt-4 mb-2">
  119. ~ @_updated.Count updated
  120. </div>
  121. @foreach (var name in _updated)
  122. {
  123. <div class="ml-4 mb-6">
  124. <div class="font-bold text-zinc-300 mb-2">~ @name</div>
  125. <div class="grid grid-cols-2 gap-4">
  126. <div>
  127. <div class="text-zinc-500 mb-1">Current</div>
  128. <pre class="bg-zinc-800 p-2 rounded text-xs overflow-x-auto">
  129. @_oldYaml[name]
  130. </pre>
  131. </div>
  132. <div>
  133. <div class="text-emerald-500 mb-1">Incoming (Merged)</div>
  134. <pre class="bg-zinc-800 p-2 rounded text-xs overflow-x-auto">
  135. @_newYaml[name]
  136. </pre>
  137. </div>
  138. </div>
  139. </div>
  140. }
  141. }
  142. @if (_replaced.Any())
  143. {
  144. <div class="text-red-400 mt-4 mb-2">
  145. ! @_replaced.Count replaced
  146. </div>
  147. @foreach (var name in _replaced)
  148. {
  149. <div class="ml-4 mb-6">
  150. <div class="font-bold text-zinc-300 mb-2">! @name</div>
  151. <div class="grid grid-cols-2 gap-4">
  152. <div>
  153. <div class="text-zinc-500 mb-1">Current</div>
  154. <pre class="bg-zinc-800 p-2 rounded text-xs overflow-x-auto">
  155. @_oldYaml[name]
  156. </pre>
  157. </div>
  158. <div>
  159. <div class="text-red-400 mb-1">Incoming (Replacement)</div>
  160. <pre class="bg-zinc-800 p-2 rounded text-xs overflow-x-auto">
  161. @_newYaml[name]
  162. </pre>
  163. </div>
  164. </div>
  165. </div>
  166. }
  167. }
  168. @if (_connectionsAdded.Any() || _connectionsRemoved.Any())
  169. {
  170. <div class="text-zinc-400 mt-4 mb-2" data-testid="import-connections-summary">
  171. Connections
  172. </div>
  173. @foreach (var connection in _connectionsAdded)
  174. {
  175. <div class="ml-4 text-emerald-400" data-testid="import-connection-added">
  176. + @connection
  177. </div>
  178. }
  179. @foreach (var connection in _connectionsRemoved)
  180. {
  181. <div class="ml-4 text-red-400" data-testid="import-connection-removed">
  182. - @connection
  183. </div>
  184. }
  185. }
  186. </div>
  187. }
  188. </div>
  189. @code {
  190. private List<string> _added = new();
  191. private List<string> _updated = new();
  192. private List<string> _replaced = new();
  193. private List<string> _connectionsAdded = new();
  194. private List<string> _connectionsRemoved = new();
  195. private Dictionary<string, string> _oldYaml = new(StringComparer.OrdinalIgnoreCase);
  196. private Dictionary<string, string> _newYaml = new(StringComparer.OrdinalIgnoreCase);
  197. private string _inputYaml = "";
  198. private ImportError? _error;
  199. private bool _isValid;
  200. private MergeMode _mode = MergeMode.Merge;
  201. async Task OnModeChanged(MergeMode mode)
  202. {
  203. _mode = mode;
  204. await ComputeDiff();
  205. }
  206. async Task ComputeDiff()
  207. {
  208. _error = null;
  209. _isValid = false;
  210. _added.Clear();
  211. _updated.Clear();
  212. _replaced.Clear();
  213. _connectionsAdded.Clear();
  214. _connectionsRemoved.Clear();
  215. _oldYaml.Clear();
  216. _newYaml.Clear();
  217. if (string.IsNullOrWhiteSpace(_inputYaml))
  218. return;
  219. try
  220. {
  221. var result = await ImportUseCase.ExecuteAsync(new ImportYamlRequest
  222. {
  223. Yaml = _inputYaml,
  224. Mode = _mode,
  225. DryRun = true
  226. });
  227. _added = result.Added;
  228. _updated = result.Updated;
  229. _replaced = result.Replaced;
  230. _connectionsAdded = result.ConnectionsAdded;
  231. _connectionsRemoved = result.ConnectionsRemoved;
  232. _oldYaml = result.OldYaml;
  233. _newYaml = result.NewYaml;
  234. _isValid = true;
  235. }
  236. catch (Exception ex)
  237. {
  238. _error = BuildError(ex, headlinePrefix: "YAML invalid");
  239. }
  240. }
  241. async Task Apply()
  242. {
  243. if (!_isValid)
  244. return;
  245. try
  246. {
  247. await ImportUseCase.ExecuteAsync(new ImportYamlRequest
  248. {
  249. Yaml = _inputYaml,
  250. Mode = _mode,
  251. DryRun = false
  252. });
  253. _inputYaml = "";
  254. _isValid = false;
  255. _added.Clear();
  256. _updated.Clear();
  257. _replaced.Clear();
  258. _connectionsAdded.Clear();
  259. _connectionsRemoved.Clear();
  260. }
  261. catch (Exception ex)
  262. {
  263. _error = BuildError(ex, headlinePrefix: "Apply failed");
  264. }
  265. }
  266. private ImportError BuildError(Exception ex, string headlinePrefix)
  267. {
  268. // YamlDotNet wraps the underlying parser error; walk InnerExceptions
  269. // to find the YamlException so we can extract Line/Column for the
  270. // user.
  271. YamlException? yamlEx = FindYamlException(ex);
  272. if (yamlEx is not null)
  273. {
  274. long? line = yamlEx.Start.Line > 0 ? yamlEx.Start.Line : null;
  275. long? col = yamlEx.Start.Column > 0 ? yamlEx.Start.Column : null;
  276. return new ImportError(
  277. $"{headlinePrefix}: {FirstLine(yamlEx.Message)}",
  278. line,
  279. col,
  280. line is long l ? ExtractSnippet(_inputYaml, (int)l) : null);
  281. }
  282. // Domain validation errors (duplicate names, missing version, etc.)
  283. // are already user-friendly — just surface them prominently.
  284. if (ex is ValidationException ve)
  285. return new ImportError(ve.Message, null, null, null);
  286. return new ImportError($"{headlinePrefix}: {FirstLine(ex.Message)}", null, null, null);
  287. }
  288. private static YamlException? FindYamlException(Exception? ex)
  289. {
  290. while (ex is not null)
  291. {
  292. if (ex is YamlException ye) return ye;
  293. ex = ex.InnerException;
  294. }
  295. return null;
  296. }
  297. private static string FirstLine(string message)
  298. {
  299. if (string.IsNullOrEmpty(message)) return string.Empty;
  300. var nl = message.IndexOf('\n');
  301. return nl < 0 ? message.Trim() : message[..nl].Trim();
  302. }
  303. private static string ExtractSnippet(string yaml, int lineNumber, int context = 2)
  304. {
  305. // Render `context` lines before and after the offending line, prefix
  306. // each with its 1-based line number, and mark the bad line with an
  307. // arrow so the eye lands on it instantly.
  308. var lines = yaml.Replace("\r\n", "\n").Split('\n');
  309. if (lineNumber < 1 || lineNumber > lines.Length) return string.Empty;
  310. var start = Math.Max(1, lineNumber - context);
  311. var end = Math.Min(lines.Length, lineNumber + context);
  312. var sb = new StringBuilder();
  313. for (int i = start; i <= end; i++)
  314. {
  315. sb.Append(i == lineNumber ? "→ " : " ")
  316. .Append(i.ToString().PadLeft(4))
  317. .Append(" ")
  318. .Append(lines[i - 1]);
  319. if (i < end) sb.Append('\n');
  320. }
  321. return sb.ToString();
  322. }
  323. private sealed record ImportError(string Headline, long? Line, long? Column, string? Snippet);
  324. }