| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357 |
- @page "/yaml/import"
- @using System.ComponentModel.DataAnnotations
- @using System.Text
- @using RackPeek.Domain.Api
- @using RackPeek.Domain.Persistence
- @using YamlDotNet.Core
- <PageTitle>Yaml Import</PageTitle>
- @inject UpsertInventoryUseCase ImportUseCase
- <div class="border border-zinc-800 rounded p-4 bg-zinc-900 mt-6">
- <div class="text-zinc-100 mb-3">
- Import YAML
- </div>
- <!-- Mode selector -->
- <div class="mb-4 flex gap-6 text-xs">
- <label class="flex items-start gap-2 cursor-pointer">
- <input type="radio"
- name="mergeMode"
- checked="@(_mode == MergeMode.Merge)"
- @onchange="() => OnModeChanged(MergeMode.Merge)"/>
- <div>
- <div class="text-emerald-400">Merge</div>
- <div class="text-zinc-500">
- Update matching resources. Properties not specified remain unchanged.
- </div>
- </div>
- </label>
- <label class="flex items-start gap-2 cursor-pointer">
- <input type="radio"
- name="mergeMode"
- checked="@(_mode == MergeMode.Replace)"
- @onchange="() => OnModeChanged(MergeMode.Replace)"/>
- <div>
- <div class="text-red-400">Replace</div>
- <div class="text-zinc-500">
- Completely replace matching resources.
- </div>
- </div>
- </label>
- </div>
- <!-- YAML Input -->
- <textarea class="w-full input font-mono text-xs mb-3"
- style="min-height: 18rem"
- placeholder="Paste YAML here..."
- @bind="_inputYaml"
- @bind:event="oninput"
- @bind:after="ComputeDiff">
- </textarea>
- @if (_error is not null)
- {
- <div class="border border-red-500/40 bg-red-500/10 rounded p-3 mb-3"
- data-testid="yaml-import-error"
- role="alert">
- <div class="flex items-start gap-2">
- <!-- alert icon -->
- <svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 flex-shrink-0 text-red-400 mt-0.5"
- fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
- <path stroke-linecap="round" stroke-linejoin="round"
- d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"/>
- </svg>
- <div class="min-w-0 flex-1">
- <div class="text-red-400 text-sm font-semibold">
- @_error.Headline
- </div>
- @if (_error.Line is long line)
- {
- <div class="text-zinc-400 text-xs mt-1">
- Line @line@(_error.Column is long col ? $", column {col}" : "")
- </div>
- }
- @if (!string.IsNullOrEmpty(_error.Snippet))
- {
- <pre class="mt-2 p-2 rounded bg-zinc-950 border border-zinc-800 text-xs overflow-x-auto text-zinc-300"
- data-testid="yaml-import-error-snippet">@_error.Snippet</pre>
- }
- </div>
- </div>
- </div>
- }
- <!-- Apply -->
- <div class="flex gap-3 text-xs mb-4">
- <button class="text-amber-400 hover:text-amber-300 disabled:opacity-40"
- disabled="@(!_isValid)"
- @onclick="Apply">
- Apply
- </button>
- </div>
- <!-- Summary + Diff -->
- @if (_isValid)
- {
- <div class="mt-4 text-xs">
- <div class="mb-2 text-zinc-400">
- Import Summary
- </div>
- @if (!_added.Any() && !_updated.Any() && !_replaced.Any())
- {
- <div class="text-zinc-500 italic mt-2">
- No changes detected.
- </div>
- }
- @if (_added.Any())
- {
- <div class="text-emerald-400 mb-2">
- + @_added.Count added
- </div>
- @foreach (var name in _added)
- {
- <div class="ml-4 text-zinc-300 mb-4">
- <div class="font-bold">+ @name</div>
- <pre class="bg-zinc-800 p-2 rounded text-xs overflow-x-auto">
- @_newYaml[name]
- </pre>
- </div>
- }
- }
- @if (_updated.Any())
- {
- <div class="text-amber-400 mt-4 mb-2">
- ~ @_updated.Count updated
- </div>
- @foreach (var name in _updated)
- {
- <div class="ml-4 mb-6">
- <div class="font-bold text-zinc-300 mb-2">~ @name</div>
- <div class="grid grid-cols-2 gap-4">
- <div>
- <div class="text-zinc-500 mb-1">Current</div>
- <pre class="bg-zinc-800 p-2 rounded text-xs overflow-x-auto">
- @_oldYaml[name]
- </pre>
- </div>
- <div>
- <div class="text-emerald-500 mb-1">Incoming (Merged)</div>
- <pre class="bg-zinc-800 p-2 rounded text-xs overflow-x-auto">
- @_newYaml[name]
- </pre>
- </div>
- </div>
- </div>
- }
- }
- @if (_replaced.Any())
- {
- <div class="text-red-400 mt-4 mb-2">
- ! @_replaced.Count replaced
- </div>
- @foreach (var name in _replaced)
- {
- <div class="ml-4 mb-6">
- <div class="font-bold text-zinc-300 mb-2">! @name</div>
- <div class="grid grid-cols-2 gap-4">
- <div>
- <div class="text-zinc-500 mb-1">Current</div>
- <pre class="bg-zinc-800 p-2 rounded text-xs overflow-x-auto">
- @_oldYaml[name]
- </pre>
- </div>
- <div>
- <div class="text-red-400 mb-1">Incoming (Replacement)</div>
- <pre class="bg-zinc-800 p-2 rounded text-xs overflow-x-auto">
- @_newYaml[name]
- </pre>
- </div>
- </div>
- </div>
- }
- }
- </div>
- }
- </div>
- @code {
- private List<string> _added = new();
- private List<string> _updated = new();
- private List<string> _replaced = new();
- private Dictionary<string, string> _oldYaml = new(StringComparer.OrdinalIgnoreCase);
- private Dictionary<string, string> _newYaml = new(StringComparer.OrdinalIgnoreCase);
- private string _inputYaml = "";
- private ImportError? _error;
- private bool _isValid;
- private MergeMode _mode = MergeMode.Merge;
- async Task OnModeChanged(MergeMode mode)
- {
- _mode = mode;
- await ComputeDiff();
- }
- async Task ComputeDiff()
- {
- _error = null;
- _isValid = false;
- _added.Clear();
- _updated.Clear();
- _replaced.Clear();
- _oldYaml.Clear();
- _newYaml.Clear();
- if (string.IsNullOrWhiteSpace(_inputYaml))
- return;
- try
- {
- var result = await ImportUseCase.ExecuteAsync(new ImportYamlRequest
- {
- Yaml = _inputYaml,
- Mode = _mode,
- DryRun = true
- });
- _added = result.Added;
- _updated = result.Updated;
- _replaced = result.Replaced;
- _oldYaml = result.OldYaml;
- _newYaml = result.NewYaml;
- _isValid = true;
- }
- catch (Exception ex)
- {
- _error = BuildError(ex, headlinePrefix: "YAML invalid");
- }
- }
- async Task Apply()
- {
- if (!_isValid)
- return;
- try
- {
- await ImportUseCase.ExecuteAsync(new ImportYamlRequest
- {
- Yaml = _inputYaml,
- Mode = _mode,
- DryRun = false
- });
- _inputYaml = "";
- _isValid = false;
- _added.Clear();
- _updated.Clear();
- _replaced.Clear();
- }
- catch (Exception ex)
- {
- _error = BuildError(ex, headlinePrefix: "Apply failed");
- }
- }
- private ImportError BuildError(Exception ex, string headlinePrefix)
- {
- // YamlDotNet wraps the underlying parser error; walk InnerExceptions
- // to find the YamlException so we can extract Line/Column for the
- // user.
- YamlException? yamlEx = FindYamlException(ex);
- if (yamlEx is not null)
- {
- long? line = yamlEx.Start.Line > 0 ? yamlEx.Start.Line : null;
- long? col = yamlEx.Start.Column > 0 ? yamlEx.Start.Column : null;
- return new ImportError(
- $"{headlinePrefix}: {FirstLine(yamlEx.Message)}",
- line,
- col,
- line is long l ? ExtractSnippet(_inputYaml, (int)l) : null);
- }
- // Domain validation errors (duplicate names, missing version, etc.)
- // are already user-friendly — just surface them prominently.
- if (ex is ValidationException ve)
- return new ImportError(ve.Message, null, null, null);
- return new ImportError($"{headlinePrefix}: {FirstLine(ex.Message)}", null, null, null);
- }
- private static YamlException? FindYamlException(Exception? ex)
- {
- while (ex is not null)
- {
- if (ex is YamlException ye) return ye;
- ex = ex.InnerException;
- }
- return null;
- }
- private static string FirstLine(string message)
- {
- if (string.IsNullOrEmpty(message)) return string.Empty;
- var nl = message.IndexOf('\n');
- return nl < 0 ? message.Trim() : message[..nl].Trim();
- }
- private static string ExtractSnippet(string yaml, int lineNumber, int context = 2)
- {
- // Render `context` lines before and after the offending line, prefix
- // each with its 1-based line number, and mark the bad line with an
- // arrow so the eye lands on it instantly.
- var lines = yaml.Replace("\r\n", "\n").Split('\n');
- if (lineNumber < 1 || lineNumber > lines.Length) return string.Empty;
- var start = Math.Max(1, lineNumber - context);
- var end = Math.Min(lines.Length, lineNumber + context);
- var sb = new StringBuilder();
- for (int i = start; i <= end; i++)
- {
- sb.Append(i == lineNumber ? "→ " : " ")
- .Append(i.ToString().PadLeft(4))
- .Append(" ")
- .Append(lines[i - 1]);
- if (i < end) sb.Append('\n');
- }
- return sb.ToString();
- }
- private sealed record ImportError(string Headline, long? Line, long? Column, string? Snippet);
- }
|