@using System.Collections.Specialized @using System.Text @using YamlDotNet.Core @using RackPeek.Domain.Persistence @using RackPeek.Domain.Persistence.Yaml @using RackPeek.Domain.Resources @using RackPeek.Domain.Resources.AccessPoints @using RackPeek.Domain.Resources.Desktops @using RackPeek.Domain.Resources.Firewalls @using RackPeek.Domain.Resources.Laptops @using RackPeek.Domain.Resources.Servers @using RackPeek.Domain.Resources.Switches @using RackPeek.Domain.Resources.SystemResources @using YamlDotNet.Serialization @using YamlDotNet.Serialization.NamingConventions @using Router = RackPeek.Domain.Resources.Routers.Router @inject ITextFileStore FileStore @inject IResourceCollection Resources
@Title
@if (!_isEditing) { } else { }
@if (!_exists) {
File does not exist.
} else if (_isEditing) { @if (_error is not null) { } } else {
@_currentText
}
> Are you sure you want to delete @Path? @code { [Parameter] [EditorRequired] public string Path { get; set; } = default!; [Parameter] public string Title { get; set; } = "Edit YAML"; [Parameter] public EventCallback OnDeleted { get; set; } bool _isEditing; bool _exists; bool _confirmDeleteOpen; string _currentText = ""; string _editText = ""; YamlEditError? _error; protected override async Task OnParametersSetAsync() { await Load(); } async Task Load() { _exists = await FileStore.ExistsAsync(Path); if (!_exists) return; _currentText = await FileStore.ReadAllTextAsync(Path); } void BeginEdit() { _editText = _currentText; _error = null; _isEditing = true; } void Cancel() { _isEditing = false; _error = null; } async Task Save() { if (!ValidateYamlRoundTrip(_editText, out var err)) { _error = err; return; } await FileStore.WriteAllTextAsync(Path, _editText); await Resources.LoadAsync(); _currentText = _editText; _isEditing = false; } void ConfirmDelete() { _confirmDeleteOpen = true; } async Task DeleteFile() { _confirmDeleteOpen = false; // if your store supports delete, call it here await FileStore.WriteAllTextAsync(Path, ""); if (OnDeleted.HasDelegate) await OnDeleted.InvokeAsync(Path); } private bool ValidateYamlRoundTrip(string yaml, out YamlEditError? error) { try { if (string.IsNullOrWhiteSpace(yaml)) { error = new YamlEditError("YAML is empty.", null, null, null); return false; } // ---------- DESERIALIZER (same as resource loader) ---------- var deserializer = new DeserializerBuilder() .WithNamingConvention(CamelCaseNamingConvention.Instance) .WithCaseInsensitivePropertyMatching() .WithTypeConverter(new StorageSizeYamlConverter()) .WithTypeDiscriminatingNodeDeserializer(options => { options.AddKeyValueTypeDiscriminator("kind", new Dictionary { { Server.KindLabel, typeof(Server) }, { Switch.KindLabel, typeof(Switch) }, { Firewall.KindLabel, typeof(Firewall) }, { Router.KindLabel, typeof(Router) }, { Desktop.KindLabel, typeof(Desktop) }, { Laptop.KindLabel, typeof(Laptop) }, { AccessPoint.KindLabel, typeof(AccessPoint) }, { RackPeek.Domain.Resources.UpsUnits.Ups.KindLabel, typeof(RackPeek.Domain.Resources.UpsUnits.Ups) }, { SystemResource.KindLabel, typeof(SystemResource) }, { Service.KindLabel, typeof(Service) } }); }) .Build(); var root = deserializer.Deserialize(yaml); if (root?.Resources == null) { error = new YamlEditError("No resources section found.", null, null, null); return false; } // ---------- SERIALIZE AGAIN ---------- var serializer = new SerializerBuilder() .WithNamingConvention(CamelCaseNamingConvention.Instance) .Build(); var payload = new OrderedDictionary { ["resources"] = root.Resources }; var roundTripYaml = serializer.Serialize(payload); // ---------- DESERIALIZE AGAIN ---------- var root2 = deserializer.Deserialize(roundTripYaml); if (root2?.Resources == null) { error = new YamlEditError("Round-trip serialization failed.", null, null, null); return false; } // ---------- DUPLICATE NAME CHECK ---------- var dup = root2.Resources .GroupBy(r => r.Name, StringComparer.OrdinalIgnoreCase) .FirstOrDefault(g => g.Count() > 1); if (dup != null) { error = new YamlEditError($"Duplicate resource name: '{dup.Key}'", null, null, null); return false; } error = null; return true; } catch (Exception ex) { error = BuildEditError(ex, yaml); return false; } } private static YamlEditError BuildEditError(Exception ex, string yaml) { YamlException? ye = FindYamlException(ex); if (ye is not null) { long? line = ye.Start.Line > 0 ? ye.Start.Line : null; long? col = ye.Start.Column > 0 ? ye.Start.Column : null; return new YamlEditError( $"YAML invalid: {FirstLine(ye.Message)}", line, col, line is long l ? ExtractSnippet(yaml, (int)l) : null); } return new YamlEditError($"YAML validation failed: {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) { 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 YamlEditError(string Headline, long? Line, long? Column, string? Snippet); }