diff --git a/RackPeek.Domain/Helpers/ResourceTemplateSerializer.cs b/RackPeek.Domain/Helpers/ResourceTemplateSerializer.cs new file mode 100644 index 00000000..edc6e538 --- /dev/null +++ b/RackPeek.Domain/Helpers/ResourceTemplateSerializer.cs @@ -0,0 +1,79 @@ +using System.Collections.Specialized; +using System.Text.Json; +using RackPeek.Domain.Persistence.Yaml; +using RackPeek.Domain.Resources; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace RackPeek.Domain.Helpers; + +/// +/// Serializes a to template-format YAML suitable for +/// contributing as a bundled hardware template. Instance-specific fields +/// (tags, labels, notes, runsOn) are stripped from the output. +/// +public static class ResourceTemplateSerializer +{ + private static readonly HashSet ExcludedKeys = new(StringComparer.OrdinalIgnoreCase) + { + "tags", "labels", "notes", "runsOn", "kind" + }; + + /// + /// Produces a template-format YAML string for the given resource. + /// The output has kind first, then name, followed by + /// type-specific hardware properties. + /// + /// The resource to serialize. + /// + /// Optional official hardware name to use in the template instead of + /// the resource's current . + /// + public static string Serialize(Resource resource, string? templateName = null) + { + var concreteType = resource.GetType(); + var json = JsonSerializer.Serialize(resource, concreteType); + var clone = (Resource)JsonSerializer.Deserialize(json, concreteType)!; + clone.Tags = []; + clone.Labels = new Dictionary(); + clone.Notes = null; + clone.RunsOn = []; + + var kind = Resource.GetKind(clone); + + var serializer = new SerializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .WithTypeConverter(new NotesStringYamlConverter()) + .ConfigureDefaultValuesHandling( + DefaultValuesHandling.OmitNull | + DefaultValuesHandling.OmitEmptyCollections + ) + .Build(); + + var yaml = serializer.Serialize(clone); + + var props = new DeserializerBuilder() + .Build() + .Deserialize>(yaml); + + var map = new OrderedDictionary + { + ["kind"] = kind, + ["name"] = templateName ?? clone.Name + }; + + if (props is not null) + { + foreach (var (key, value) in props) + { + if (ExcludedKeys.Contains(key) || + string.Equals(key, "name", StringComparison.OrdinalIgnoreCase)) + continue; + + map[key] = value; + } + } + + return serializer.Serialize(map); + } +} diff --git a/RackPeek.Domain/Resources/Resource.cs b/RackPeek.Domain/Resources/Resource.cs index 9f9f3cc6..8d62f56b 100644 --- a/RackPeek.Domain/Resources/Resource.cs +++ b/RackPeek.Domain/Resources/Resource.cs @@ -95,6 +95,19 @@ public static string GetKind() where T : Resource $"No kind mapping defined for type {typeof(T).Name}"); } + /// + /// Resolves the kind label for a resource instance at runtime. + /// + /// Thrown when the resource type has no kind mapping. + public static string GetKind(Resource resource) + { + if (TypeToKindMap.TryGetValue(resource.GetType(), out var kind)) + return kind; + + throw new InvalidOperationException( + $"No kind mapping defined for type {resource.GetType().Name}"); + } + public static bool CanRunOn(Resource parent) where T : Resource { var childKind = GetKind().ToLowerInvariant(); diff --git a/RackPeek.Domain/ServiceCollectionExtensions.cs b/RackPeek.Domain/ServiceCollectionExtensions.cs index 8bbdb2c6..cd759432 100644 --- a/RackPeek.Domain/ServiceCollectionExtensions.cs +++ b/RackPeek.Domain/ServiceCollectionExtensions.cs @@ -5,6 +5,7 @@ using RackPeek.Domain.Resources.Hardware; using RackPeek.Domain.Resources.Services; using RackPeek.Domain.Resources.SystemResources; +using RackPeek.Domain.Templates; using RackPeek.Domain.UseCases; using RackPeek.Domain.UseCases.Cpus; using RackPeek.Domain.UseCases.Drives; @@ -49,6 +50,7 @@ public static IServiceCollection AddUseCases( this IServiceCollection services) { services.AddScoped(typeof(IAddResourceUseCase<>), typeof(AddResourceUseCase<>)); + services.AddScoped(typeof(IAddResourceFromTemplateUseCase<>), typeof(AddResourceFromTemplateUseCase<>)); services.AddScoped(typeof(IAddLabelUseCase<>), typeof(AddLabelUseCase<>)); services.AddScoped(typeof(IAddTagUseCase<>), typeof(AddTagUseCase<>)); services.AddScoped(typeof(ICloneResourceUseCase<>), typeof(CloneResourceUseCase<>)); diff --git a/RackPeek.Domain/Templates/BundledHardwareTemplateStore.cs b/RackPeek.Domain/Templates/BundledHardwareTemplateStore.cs new file mode 100644 index 00000000..d7796a41 --- /dev/null +++ b/RackPeek.Domain/Templates/BundledHardwareTemplateStore.cs @@ -0,0 +1,158 @@ +using RackPeek.Domain.Persistence.Yaml; +using RackPeek.Domain.Resources; +using RackPeek.Domain.Resources.AccessPoints; +using RackPeek.Domain.Resources.Firewalls; +using RackPeek.Domain.Resources.Routers; +using RackPeek.Domain.Resources.Servers; +using RackPeek.Domain.Resources.Switches; +using RackPeek.Domain.Resources.UpsUnits; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace RackPeek.Domain.Templates; + +/// +/// Reads hardware templates from YAML files stored in a local directory tree. +/// Expected layout: {basePath}/{kind}/*.yaml where kind is the plural +/// lowercase form (e.g. switches/, routers/). +/// Templates are cached in memory after the first load. +/// +public sealed class BundledHardwareTemplateStore : IHardwareTemplateStore +{ + private readonly string _basePath; + private readonly IDeserializer _deserializer; + private List? _cache; + private readonly SemaphoreSlim _loadLock = new(1, 1); + + private static readonly Dictionary PluralToKind = new(StringComparer.OrdinalIgnoreCase) + { + ["servers"] = "Server", + ["switches"] = "Switch", + ["firewalls"] = "Firewall", + ["routers"] = "Router", + ["accesspoints"] = "AccessPoint", + ["ups"] = "Ups", + }; + + public BundledHardwareTemplateStore(string basePath) + { + _basePath = basePath; + _deserializer = new DeserializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .WithCaseInsensitivePropertyMatching() + .WithTypeConverter(new StorageSizeYamlConverter()) + .WithTypeConverter(new NotesStringYamlConverter()) + .WithTypeDiscriminatingNodeDeserializer(options => + { + options.AddKeyValueTypeDiscriminator("kind", new Dictionary + { + { Server.KindLabel, typeof(Server) }, + { Switch.KindLabel, typeof(Switch) }, + { Firewall.KindLabel, typeof(Firewall) }, + { Router.KindLabel, typeof(Router) }, + { AccessPoint.KindLabel, typeof(AccessPoint) }, + { Ups.KindLabel, typeof(Ups) }, + }); + }) + .Build(); + } + + /// + public async Task> GetAllByKindAsync(string kind) + { + var all = await LoadAsync(); + return all + .Where(t => t.Kind.Equals(kind, StringComparison.OrdinalIgnoreCase)) + .OrderBy(t => t.Model, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + /// + public async Task GetByIdAsync(string templateId) + { + var all = await LoadAsync(); + return all.FirstOrDefault(t => t.Id.Equals(templateId, StringComparison.OrdinalIgnoreCase)); + } + + /// + public async Task> GetAllAsync() + { + var all = await LoadAsync(); + return all + .OrderBy(t => t.Kind, StringComparer.OrdinalIgnoreCase) + .ThenBy(t => t.Model, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + private async Task> LoadAsync() + { + if (_cache is not null) + return _cache; + + await _loadLock.WaitAsync(); + try + { + if (_cache is not null) + return _cache; + + _cache = await ScanTemplatesAsync(); + return _cache; + } + finally + { + _loadLock.Release(); + } + } + + private Task> ScanTemplatesAsync() + { + var templates = new List(); + + if (!Directory.Exists(_basePath)) + return Task.FromResult(templates); + + foreach (var kindDir in Directory.GetDirectories(_basePath)) + { + var dirName = Path.GetFileName(kindDir); + if (!PluralToKind.TryGetValue(dirName, out var kind)) + continue; + + foreach (var file in Directory.GetFiles(kindDir, "*.yaml")) + { + try + { + var yaml = File.ReadAllText(file); + var resource = _deserializer.Deserialize(yaml); + if (resource is null) + continue; + + resource.Kind = kind; + + var model = GetModel(resource) ?? Path.GetFileNameWithoutExtension(file); + var id = $"{kind}/{model}"; + + if (string.IsNullOrWhiteSpace(resource.Name)) + resource.Name = model; + + templates.Add(new HardwareTemplate(id, kind, model, resource)); + } + catch + { + // Skip malformed template files gracefully + } + } + } + + return Task.FromResult(templates); + } + + private static string? GetModel(Resource resource) => resource switch + { + Switch s => s.Model, + Router r => r.Model, + Firewall f => f.Model, + AccessPoint ap => ap.Model, + Ups u => u.Model, + _ => null, + }; +} diff --git a/RackPeek.Domain/Templates/HardwareTemplate.cs b/RackPeek.Domain/Templates/HardwareTemplate.cs new file mode 100644 index 00000000..58da1ab4 --- /dev/null +++ b/RackPeek.Domain/Templates/HardwareTemplate.cs @@ -0,0 +1,18 @@ +using RackPeek.Domain.Resources; + +namespace RackPeek.Domain.Templates; + +/// +/// Represents a pre-filled hardware specification that can be used as a starting point +/// when adding a new resource. The contains all hardware details +/// (ports, model, features) but uses a placeholder name that gets replaced at creation time. +/// +/// Unique identifier in the form {Kind}/{Model}. +/// Resource kind (e.g. Switch, Router, Firewall). +/// Human-readable model name used for display. +/// Fully populated resource with placeholder name — deep-cloned at use time. +public sealed record HardwareTemplate( + string Id, + string Kind, + string Model, + Resource Spec); diff --git a/RackPeek.Domain/Templates/IHardwareTemplateStore.cs b/RackPeek.Domain/Templates/IHardwareTemplateStore.cs new file mode 100644 index 00000000..04a4a75c --- /dev/null +++ b/RackPeek.Domain/Templates/IHardwareTemplateStore.cs @@ -0,0 +1,25 @@ +namespace RackPeek.Domain.Templates; + +/// +/// Read-only store of known hardware templates that can be used to pre-fill +/// resource specifications when adding new resources. +/// +public interface IHardwareTemplateStore +{ + /// + /// Returns all templates matching the specified resource kind (case-insensitive). + /// + /// Resource kind such as "Switch", "Router", or "Firewall". + Task> GetAllByKindAsync(string kind); + + /// + /// Returns a single template by its identifier, or null if not found. + /// + /// Template identifier in the form {Kind}/{Model}. + Task GetByIdAsync(string templateId); + + /// + /// Returns all available templates across all resource kinds. + /// + Task> GetAllAsync(); +} diff --git a/RackPeek.Domain/Templates/TemplateValidator.cs b/RackPeek.Domain/Templates/TemplateValidator.cs new file mode 100644 index 00000000..4f4c82ea --- /dev/null +++ b/RackPeek.Domain/Templates/TemplateValidator.cs @@ -0,0 +1,256 @@ +using RackPeek.Domain.Resources; +using RackPeek.Domain.Resources.AccessPoints; +using RackPeek.Domain.Resources.Firewalls; +using RackPeek.Domain.Resources.Routers; +using RackPeek.Domain.Resources.Servers; +using RackPeek.Domain.Resources.SubResources; +using RackPeek.Domain.Resources.Switches; +using RackPeek.Domain.Resources.UpsUnits; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; +using RackPeek.Domain.Persistence.Yaml; + +namespace RackPeek.Domain.Templates; + +/// +/// Validates a hardware template YAML file against the resource schema for its kind. +/// Returns a list of human-readable validation errors (empty list means valid). +/// +public sealed class TemplateValidator +{ + private static readonly HashSet ValidKinds = new(StringComparer.OrdinalIgnoreCase) + { + "Server", "Switch", "Router", "Firewall", "AccessPoint", "Ups" + }; + + private static readonly HashSet ValidNicTypes = + new(Nic.ValidNicTypes, StringComparer.OrdinalIgnoreCase); + + private static readonly HashSet ValidDriveTypes = + new(Drive.ValidDriveTypes, StringComparer.OrdinalIgnoreCase); + + private static readonly HashSet ValidPortTypes = + new(Nic.ValidNicTypes, StringComparer.OrdinalIgnoreCase); + + private readonly IDeserializer _deserializer; + private readonly IDeserializer _plainDeserializer; + + public TemplateValidator() + { + _deserializer = new DeserializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .WithCaseInsensitivePropertyMatching() + .WithTypeConverter(new StorageSizeYamlConverter()) + .WithTypeConverter(new NotesStringYamlConverter()) + .WithTypeDiscriminatingNodeDeserializer(options => + { + options.AddKeyValueTypeDiscriminator("kind", new Dictionary + { + { Server.KindLabel, typeof(Server) }, + { Switch.KindLabel, typeof(Switch) }, + { Firewall.KindLabel, typeof(Firewall) }, + { Router.KindLabel, typeof(Router) }, + { AccessPoint.KindLabel, typeof(AccessPoint) }, + { Ups.KindLabel, typeof(Ups) }, + }); + }) + .Build(); + + _plainDeserializer = new DeserializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .IgnoreUnmatchedProperties() + .Build(); + } + + /// + /// Validates raw YAML content as a hardware template. + /// + /// The YAML content to validate. + /// Filename used for error messages and model fallback. + /// A list of validation errors. Empty means the template is valid. + public IReadOnlyList Validate(string yaml, string fileName) + { + var errors = new List(); + + if (string.IsNullOrWhiteSpace(yaml)) + { + errors.Add("File is empty."); + return errors; + } + + // Pre-parse to extract kind before the type-discriminating deserializer runs, + // because the discriminator throws on unknown/missing kind values. + string? kind; + try + { + var raw = _plainDeserializer.Deserialize>(yaml); + if (raw is null) + { + errors.Add("YAML deserialized to null."); + return errors; + } + + kind = raw.TryGetValue("kind", out var k) ? k?.ToString() : null; + } + catch (Exception ex) + { + errors.Add($"YAML parsing failed: {ex.Message}"); + return errors; + } + + if (string.IsNullOrWhiteSpace(kind)) + { + errors.Add("Missing required field: 'kind'."); + return errors; + } + + if (!ValidKinds.Contains(kind)) + { + errors.Add($"Invalid kind '{kind}'. Must be one of: {string.Join(", ", ValidKinds.Order())}."); + return errors; + } + + Resource resource; + try + { + resource = _deserializer.Deserialize(yaml); + } + catch (Exception ex) + { + errors.Add($"YAML parsing failed: {ex.Message}"); + return errors; + } + + if (string.IsNullOrWhiteSpace(resource.Name)) + errors.Add("Missing required field: 'name'."); + + switch (resource) + { + case Server server: + ValidateServer(server, errors); + break; + case Switch sw: + ValidatePortDevice(sw.Model, sw.Ports, "Switch", errors); + break; + case Router rt: + ValidatePortDevice(rt.Model, rt.Ports, "Router", errors); + break; + case Firewall fw: + ValidatePortDevice(fw.Model, fw.Ports, "Firewall", errors); + break; + case AccessPoint ap: + ValidateAccessPoint(ap, errors); + break; + case Ups ups: + ValidateUps(ups, errors); + break; + } + + return errors; + } + + private static void ValidateServer(Server server, List errors) + { + if (server.Cpus is { Count: > 0 }) + { + for (var i = 0; i < server.Cpus.Count; i++) + { + var cpu = server.Cpus[i]; + if (string.IsNullOrWhiteSpace(cpu.Model)) + errors.Add($"cpus[{i}]: 'model' is required."); + if (cpu.Cores is < 0) + errors.Add($"cpus[{i}]: 'cores' must be non-negative."); + if (cpu.Threads is < 0) + errors.Add($"cpus[{i}]: 'threads' must be non-negative."); + } + } + + if (server.Ram is not null) + { + if (server.Ram.Size is < 0) + errors.Add("ram: 'size' must be non-negative."); + if (server.Ram.Mts is < 0) + errors.Add("ram: 'mts' must be non-negative."); + } + + if (server.Drives is { Count: > 0 }) + { + for (var i = 0; i < server.Drives.Count; i++) + { + var drive = server.Drives[i]; + if (string.IsNullOrWhiteSpace(drive.Type)) + errors.Add($"drives[{i}]: 'type' is required."); + else if (!ValidDriveTypes.Contains(drive.Type)) + errors.Add($"drives[{i}]: invalid type '{drive.Type}'. Valid: {string.Join(", ", Drive.ValidDriveTypes)}."); + if (drive.Size is < 0) + errors.Add($"drives[{i}]: 'size' must be non-negative."); + } + } + + if (server.Nics is { Count: > 0 }) + ValidateNics(server.Nics, errors); + + if (server.Gpus is { Count: > 0 }) + { + for (var i = 0; i < server.Gpus.Count; i++) + { + if (string.IsNullOrWhiteSpace(server.Gpus[i].Model)) + errors.Add($"gpus[{i}]: 'model' is required."); + } + } + } + + private static void ValidateNics(List nics, List errors) + { + for (var i = 0; i < nics.Count; i++) + { + var nic = nics[i]; + if (string.IsNullOrWhiteSpace(nic.Type)) + errors.Add($"nics[{i}]: 'type' is required."); + else if (!ValidNicTypes.Contains(nic.Type)) + errors.Add($"nics[{i}]: invalid type '{nic.Type}'. Valid: {string.Join(", ", Nic.ValidNicTypes)}."); + if (nic.Speed is < 0) + errors.Add($"nics[{i}]: 'speed' must be non-negative."); + if (nic.Ports is < 0) + errors.Add($"nics[{i}]: 'ports' must be non-negative."); + } + } + + private static void ValidatePortDevice(string? model, List? ports, string kindLabel, List errors) + { + if (string.IsNullOrWhiteSpace(model)) + errors.Add($"'{kindLabel}' templates require the 'model' field."); + + if (ports is { Count: > 0 }) + { + for (var i = 0; i < ports.Count; i++) + { + var port = ports[i]; + if (string.IsNullOrWhiteSpace(port.Type)) + errors.Add($"ports[{i}]: 'type' is required."); + else if (!ValidPortTypes.Contains(port.Type)) + errors.Add($"ports[{i}]: invalid type '{port.Type}'. Valid: {string.Join(", ", Nic.ValidNicTypes)}."); + if (port.Speed is < 0) + errors.Add($"ports[{i}]: 'speed' must be non-negative."); + if (port.Count is < 0) + errors.Add($"ports[{i}]: 'count' must be non-negative."); + } + } + } + + private static void ValidateAccessPoint(AccessPoint ap, List errors) + { + if (string.IsNullOrWhiteSpace(ap.Model)) + errors.Add("'AccessPoint' templates require the 'model' field."); + if (ap.Speed is < 0) + errors.Add("'speed' must be non-negative."); + } + + private static void ValidateUps(Ups ups, List errors) + { + if (string.IsNullOrWhiteSpace(ups.Model)) + errors.Add("'Ups' templates require the 'model' field."); + if (ups.Va is < 0) + errors.Add("'va' must be non-negative."); + } +} diff --git a/RackPeek.Domain/UseCases/AddResourceFromTemplateUseCase.cs b/RackPeek.Domain/UseCases/AddResourceFromTemplateUseCase.cs new file mode 100644 index 00000000..8a17aee9 --- /dev/null +++ b/RackPeek.Domain/UseCases/AddResourceFromTemplateUseCase.cs @@ -0,0 +1,80 @@ +using System.ComponentModel.DataAnnotations; +using RackPeek.Domain.Helpers; +using RackPeek.Domain.Persistence; +using RackPeek.Domain.Resources; +using RackPeek.Domain.Templates; + +namespace RackPeek.Domain.UseCases; + +/// +/// Marker interface for the template-based add use case, enabling open-generic DI registration. +/// +public interface IAddResourceFromTemplateUseCase : IResourceUseCase + where T : Resource +{ + /// + /// Creates a new resource pre-filled from a hardware template. + /// + /// Name for the new resource. + /// Template identifier in the form {Kind}/{Model}. + /// Optional parent resources this resource runs on. + /// A resource with already exists. + /// The specified was not found. + /// The template kind does not match . + Task ExecuteAsync(string name, string templateId, List? runsOn = null); +} + +/// +/// Creates a new resource by deep-cloning a hardware template and assigning the caller-supplied name. +/// +public class AddResourceFromTemplateUseCase( + IResourceCollection repo, + IHardwareTemplateStore templateStore +) : IAddResourceFromTemplateUseCase where T : Resource +{ + /// + public async Task ExecuteAsync(string name, string templateId, List? runsOn = null) + { + name = Normalize.HardwareName(name); + ThrowIfInvalid.ResourceName(name); + + var existing = await repo.GetByNameAsync(name); + if (existing is not null) + throw new ConflictException($"Resource '{name}' ({existing.Kind}) already exists."); + + var template = await templateStore.GetByIdAsync(templateId); + if (template is null) + throw new NotFoundException($"Template '{templateId}' not found."); + + var expectedKind = Resource.GetKind(); + if (!template.Kind.Equals(expectedKind, StringComparison.OrdinalIgnoreCase)) + throw new ValidationException( + $"Template '{templateId}' is for {template.Kind}, not {expectedKind}."); + + if (runsOn is not null) + { + foreach (var parent in runsOn) + { + var normalizedParent = Normalize.HardwareName(parent); + ThrowIfInvalid.ResourceName(normalizedParent); + var parentResource = await repo.GetByNameAsync(normalizedParent); + if (parentResource is null) + throw new NotFoundException($"Resource '{normalizedParent}' not found."); + + if (!Resource.CanRunOn(parentResource)) + throw new InvalidOperationException( + $"{expectedKind} cannot run on {parentResource.Kind} '{normalizedParent}'."); + } + } + + if (template.Spec is not T typedSpec) + throw new InvalidOperationException( + $"Template spec is {template.Spec.GetType().Name}, expected {typeof(T).Name}."); + + var clone = Clone.DeepClone(typedSpec); + clone.Name = name; + clone.RunsOn = runsOn ?? new List(); + + await repo.AddAsync(clone); + } +} diff --git a/RackPeek.Web/Program.cs b/RackPeek.Web/Program.cs index 580028d8..39867505 100644 --- a/RackPeek.Web/Program.cs +++ b/RackPeek.Web/Program.cs @@ -3,6 +3,7 @@ using RackPeek.Domain; using RackPeek.Domain.Persistence; using RackPeek.Domain.Persistence.Yaml; +using RackPeek.Domain.Templates; using RackPeek.Web.Components; using YamlDotNet.Serialization; using YamlDotNet.Serialization.NamingConventions; @@ -71,6 +72,11 @@ public static async Task BuildApp(WebApplicationBuilder builder) sp.GetRequiredService(), sp.GetRequiredService())); + // Templates + var templatesDir = builder.Configuration.GetValue("RPK_TEMPLATES_DIR") + ?? Path.Combine(AppContext.BaseDirectory, "templates"); + builder.Services.AddSingleton(new BundledHardwareTemplateStore(templatesDir)); + // Infrastructure builder.Services.AddYamlRepos(); diff --git a/RackPeek.Web/RackPeek.Web.csproj b/RackPeek.Web/RackPeek.Web.csproj index 47110620..a0de3f6a 100644 --- a/RackPeek.Web/RackPeek.Web.csproj +++ b/RackPeek.Web/RackPeek.Web.csproj @@ -102,4 +102,8 @@ + + + + diff --git a/RackPeek/RackPeek.csproj b/RackPeek/RackPeek.csproj index 44f19bd9..c2342769 100644 --- a/RackPeek/RackPeek.csproj +++ b/RackPeek/RackPeek.csproj @@ -1,4 +1,4 @@ - + Exe @@ -26,4 +26,8 @@ + + + + diff --git a/Shared.Rcl/AccessPoints/AccessPointCardComponent.razor b/Shared.Rcl/AccessPoints/AccessPointCardComponent.razor index 496a6df0..b2065fbd 100644 --- a/Shared.Rcl/AccessPoints/AccessPointCardComponent.razor +++ b/Shared.Rcl/AccessPoints/AccessPointCardComponent.razor @@ -39,6 +39,8 @@ Clone + + + + + +@code { + [Parameter] [EditorRequired] public Resource Resource { get; set; } = default!; + [Parameter] public string TestIdPrefix { get; set; } = "resource"; + + private bool _modalOpen; + private bool _copied; + + private void OpenModal() + { + _modalOpen = true; + } + + private async Task HandleSubmit(string templateName) + { + _modalOpen = false; + + var yaml = ResourceTemplateSerializer.Serialize(Resource, templateName); + await JS.InvokeVoidAsync("navigator.clipboard.writeText", yaml); + + _copied = true; + StateHasChanged(); + + await Task.Delay(2000); + _copied = false; + StateHasChanged(); + } +} diff --git a/Shared.Rcl/Components/HardwareTemplateSelectorComponent.razor b/Shared.Rcl/Components/HardwareTemplateSelectorComponent.razor new file mode 100644 index 00000000..ac400d0a --- /dev/null +++ b/Shared.Rcl/Components/HardwareTemplateSelectorComponent.razor @@ -0,0 +1,41 @@ +@using RackPeek.Domain.Resources +@using RackPeek.Domain.Templates +@typeparam TResource where TResource : RackPeek.Domain.Resources.Resource + +@inject IHardwareTemplateStore TemplateStore + +@if (_templates is { Count: > 0 }) +{ + +} + +@code { + private string ResourceKindLower => Resource.GetKind().ToLower(); + + [Parameter] public EventCallback OnTemplateSelected { get; set; } + + private IReadOnlyList? _templates; + private string _selectedId = string.Empty; + + protected override async Task OnInitializedAsync() + { + var kind = Resource.GetKind(); + _templates = await TemplateStore.GetAllByKindAsync(kind); + } + + private async Task OnSelectionChanged(ChangeEventArgs e) + { + _selectedId = e.Value?.ToString() ?? string.Empty; + var templateId = string.IsNullOrEmpty(_selectedId) ? null : _selectedId; + await OnTemplateSelected.InvokeAsync(templateId); + } +} diff --git a/Shared.Rcl/Desktops/DesktopCardComponent.razor b/Shared.Rcl/Desktops/DesktopCardComponent.razor index da986749..d135c393 100644 --- a/Shared.Rcl/Desktops/DesktopCardComponent.razor +++ b/Shared.Rcl/Desktops/DesktopCardComponent.razor @@ -58,6 +58,8 @@ Clone + + + + + + + + + + + +