@using System.Collections.Specialized
@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 (!string.IsNullOrEmpty(_validationError))
{
@_validationError
}
}
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 = "";
string? _validationError;
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;
_validationError = null;
_isEditing = true;
}
void Cancel()
{
_isEditing = false;
_validationError = null;
}
async Task Save()
{
if (!ValidateYamlRoundTrip(_editText, out var error))
{
_validationError = error;
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 string? error)
{
try
{
if (string.IsNullOrWhiteSpace(yaml))
{
error = "YAML is empty.";
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 = "No resources section found.";
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 = "Round-trip serialization failed.";
return false;
}
// ---------- DUPLICATE NAME CHECK ----------
var dup = root2.Resources
.GroupBy(r => r.Name, StringComparer.OrdinalIgnoreCase)
.FirstOrDefault(g => g.Count() > 1);
if (dup != null)
{
error = $"Duplicate resource name: '{dup.Key}'";
return false;
}
error = null;
return true;
}
catch (Exception ex)
{
error = $"YAML validation failed: {ex.Message}";
return false;
}
}
}