Initial commit
This commit is contained in:
@@ -0,0 +1,457 @@
|
||||
@inject IDialogService DialogService
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<MudStack Spacing="4">
|
||||
<!-- Header -->
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
|
||||
<MudStack Spacing="1">
|
||||
<MudText Typo="Typo.h6">@(State.SelectedConfig?.ConfigType ?? "Unknown")</MudText>
|
||||
@if (!string.IsNullOrEmpty(State.SelectedConfig?.Description))
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">
|
||||
@State.SelectedConfig.Description
|
||||
</MudText>
|
||||
}
|
||||
</MudStack>
|
||||
|
||||
<MudStack Row="true" Spacing="2">
|
||||
@if (_hasUnsavedChanges)
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Warning" Variant="Variant.Text">
|
||||
Unsaved changes
|
||||
</MudChip>
|
||||
}
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Save"
|
||||
Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
OnClick="HandleSave"
|
||||
Disabled="@(State.IsSaving || !_hasUnsavedChanges)">
|
||||
Save
|
||||
</MudButton>
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Edit"
|
||||
Variant="Variant.Outlined"
|
||||
Color="Color.Primary"
|
||||
OnClick="OpenEditConfigDialog"
|
||||
Disabled="@(State.IsSaving || Disabled)">
|
||||
Edit Config
|
||||
</MudButton>
|
||||
<MudButton StartIcon="@Icons.Material.Filled.FileDownload"
|
||||
Variant="Variant.Outlined"
|
||||
Color="Color.Info"
|
||||
OnClick="@(() => OnExport.InvokeAsync(State.SelectedConfig!.Id))">
|
||||
Export
|
||||
</MudButton>
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Delete"
|
||||
Variant="Variant.Outlined"
|
||||
Color="Color.Error"
|
||||
OnClick="@(() => OnDelete.InvokeAsync(State.SelectedConfig!.Id))"
|
||||
Disabled="@(Disabled)">
|
||||
Delete
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
|
||||
<!-- Config Info -->
|
||||
<MudPaper Class="pa-3" Elevation="1">
|
||||
<MudGrid>
|
||||
<MudItem xs="12" md="6">
|
||||
<MudStack Spacing="1">
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">CONFIG INFO</MudText>
|
||||
<MudText Typo="Typo.body2">
|
||||
<strong>Config Type:</strong> @(State.SelectedConfig?.ConfigType ?? "Unknown")
|
||||
</MudText>
|
||||
<MudText Typo="Typo.body2">
|
||||
<strong>Created:</strong> @(State.SelectedConfig?.CreatedAt.ToString("yyyy-MM-dd HH:mm") ?? "N/A")
|
||||
</MudText>
|
||||
<MudText Typo="Typo.body2">
|
||||
<strong>Updated:</strong> @(State.SelectedConfig?.UpdatedAt.ToString("yyyy-MM-dd HH:mm") ?? "N/A")
|
||||
</MudText>
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="6">
|
||||
<MudStack Spacing="1">
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">VARIABLES</MudText>
|
||||
<MudText Typo="Typo.body2">
|
||||
<strong>Total Variables:</strong> @(State.SelectedConfig?.Variables.Count ?? 0)
|
||||
</MudText>
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudPaper>
|
||||
|
||||
<!-- Variables Editor -->
|
||||
<MudPaper Class="pa-4" Elevation="1">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="mb-3">
|
||||
<MudText Typo="Typo.h6">Variables</MudText>
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Add"
|
||||
Variant="Variant.Outlined"
|
||||
Color="Color.Success"
|
||||
Size="Size.Small"
|
||||
OnClick="OpenAddVariableDialog"
|
||||
Disabled="@(Disabled)">
|
||||
Add Variable
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
|
||||
@if (State.SelectedConfig?.Variables.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary" Align="Align.Center" Class="mt-4">
|
||||
No variables defined
|
||||
</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTable Items="@(State.SelectedConfig?.Variables ?? new List<ConfigVariableModel>())"
|
||||
Hover="true"
|
||||
Dense="true"
|
||||
Elevation="0"
|
||||
FixedHeader="true"
|
||||
Height="calc(100vh - 575px)">
|
||||
<HeaderContent>
|
||||
<MudTh>Name</MudTh>
|
||||
<MudTh>Type</MudTh>
|
||||
<MudTh>Value</MudTh>
|
||||
<MudTh>Min</MudTh>
|
||||
<MudTh>Max</MudTh>
|
||||
<MudTh>Actions</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
@{
|
||||
bool canEditThisVariable = _variableEditPermissions.TryGetValue(context.Name, out var canEdit) && canEdit;
|
||||
}
|
||||
<MudTd DataLabel="Name">
|
||||
<MudText Typo="Typo.body2" Style="font-weight: bold;">
|
||||
@context.Name
|
||||
</MudText>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Type">
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Primary">
|
||||
@context.Type
|
||||
</MudChip>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Value">
|
||||
@{
|
||||
var variableName = context.Name;
|
||||
}
|
||||
<VariableEditor Variable="@context"
|
||||
OnValueChanged="@((value) => OnVariableValueChanged(variableName, value))"
|
||||
Disabled="@(!canEditThisVariable)" />
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Min">
|
||||
@if (context.Min.HasValue)
|
||||
{
|
||||
<MudText Typo="Typo.body2">@context.Min</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">-</MudText>
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Max">
|
||||
@if (context.Max.HasValue)
|
||||
{
|
||||
<MudText Typo="Typo.body2">@context.Max</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">-</MudText>
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Actions">
|
||||
<MudStack Row="true" Spacing="1">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
||||
Color="Color.Primary"
|
||||
Size="Size.Small"
|
||||
OnClick="@(() => OpenEditVariableDialog(context))"
|
||||
Disabled="@(!canEditThisVariable)">
|
||||
<MudTooltip>Edit Variable</MudTooltip>
|
||||
</MudIconButton>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||
Color="Color.Error"
|
||||
Size="Size.Small"
|
||||
OnClick="@(() => OnRemoveVariable(context.Name))"
|
||||
Disabled="@(Disabled)">
|
||||
<MudTooltip>Delete Variable</MudTooltip>
|
||||
</MudIconButton>
|
||||
</MudStack>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
}
|
||||
</MudPaper>
|
||||
</MudStack>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public ConfigManagerState State { get; set; } = null!;
|
||||
|
||||
[Parameter]
|
||||
public EventCallback OnSave { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<Guid> OnDelete { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<Guid> OnExport { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool Disabled { get; set; }
|
||||
|
||||
private Dictionary<string, bool> _variableEditPermissions = new();
|
||||
private Dictionary<string, object?> _originalValues = new(); // Store original values for reset
|
||||
private bool _hasUnsavedChanges = false;
|
||||
private Guid? _lastConfigId = null;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
// Reset when config changes
|
||||
if (State.SelectedConfig?.Id != _lastConfigId)
|
||||
{
|
||||
ResetLocalState();
|
||||
_lastConfigId = State.SelectedConfig?.Id;
|
||||
await LoadVariablePermissionsAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private void ResetLocalState()
|
||||
{
|
||||
_originalValues.Clear();
|
||||
_variableEditPermissions.Clear();
|
||||
_hasUnsavedChanges = false;
|
||||
|
||||
// Store original values
|
||||
if (State.SelectedConfig != null)
|
||||
{
|
||||
foreach (var variable in State.SelectedConfig.Variables)
|
||||
{
|
||||
_originalValues[variable.Name] = variable.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadVariablePermissionsAsync()
|
||||
{
|
||||
_variableEditPermissions.Clear();
|
||||
if (State.SelectedConfig != null)
|
||||
{
|
||||
foreach (var variable in State.SelectedConfig.Variables)
|
||||
{
|
||||
_variableEditPermissions[variable.Name] = await State.CanEditVariableAsync(variable);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnVariableValueChanged(string variableName, object? value)
|
||||
{
|
||||
// Check permission before updating
|
||||
if (State.SelectedConfig == null)
|
||||
return;
|
||||
|
||||
var variable = State.SelectedConfig.Variables.FirstOrDefault(v => v.Name == variableName);
|
||||
if (variable == null)
|
||||
return;
|
||||
|
||||
var canEdit = await State.CanEditVariableAsync(variable);
|
||||
if (!canEdit)
|
||||
{
|
||||
Snackbar.Add("You do not have permission to edit this variable", Severity.Warning);
|
||||
StateHasChanged(); // Refresh to show original value
|
||||
return;
|
||||
}
|
||||
|
||||
// Update local state only (don't save to backend yet)
|
||||
variable.Value = value;
|
||||
_hasUnsavedChanges = true;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task HandleSave()
|
||||
{
|
||||
if (State.SelectedConfig == null || !_hasUnsavedChanges)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
// Save all variables to backend
|
||||
await State.UpdateConfigAsync(State.SelectedConfig.Variables, State.SelectedConfig.Description);
|
||||
|
||||
// Reset local state after successful save
|
||||
ResetLocalState();
|
||||
|
||||
Snackbar.Add("Config saved successfully", Severity.Success);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add(HttpErrorHelper.GetErrorMessage(ex), Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnRemoveVariable(string variableName)
|
||||
{
|
||||
var result = await DialogService.ShowMessageBoxAsync(
|
||||
"Remove Variable",
|
||||
$"Are you sure you want to remove variable '{variableName}'?",
|
||||
yesText: "Remove",
|
||||
cancelText: "Cancel");
|
||||
|
||||
if (result == true)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (State.SelectedConfig == null)
|
||||
return;
|
||||
|
||||
// Remove variable from local state only (don't save to backend yet)
|
||||
var variable = State.SelectedConfig.Variables.FirstOrDefault(v => v.Name == variableName);
|
||||
if (variable != null)
|
||||
{
|
||||
State.SelectedConfig.Variables.Remove(variable);
|
||||
// Remove from original values tracking
|
||||
_originalValues.Remove(variableName);
|
||||
_hasUnsavedChanges = true;
|
||||
StateHasChanged();
|
||||
Snackbar.Add($"Variable '{variableName}' removed (click Save to persist)", Severity.Info);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add(HttpErrorHelper.GetErrorMessage(ex), Severity.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OpenAddVariableDialog()
|
||||
{
|
||||
if (State.SelectedConfig == null)
|
||||
return;
|
||||
|
||||
var parameters = new DialogParameters<AddVariableDialog>
|
||||
{
|
||||
{ x => x.OnAdd, EventCallback.Factory.Create<ConfigVariableModel>(this, HandleAddVariable) }
|
||||
};
|
||||
|
||||
var options = new DialogOptions
|
||||
{
|
||||
CloseOnEscapeKey = true,
|
||||
MaxWidth = MaxWidth.Medium,
|
||||
FullWidth = true
|
||||
};
|
||||
|
||||
await DialogService.ShowAsync<AddVariableDialog>("Add Variable", parameters, options);
|
||||
}
|
||||
|
||||
private async Task HandleAddVariable(ConfigVariableModel variable)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (State.SelectedConfig == null)
|
||||
return;
|
||||
|
||||
// Add variable to local state only (don't save to backend yet)
|
||||
State.SelectedConfig.Variables.Add(variable);
|
||||
// Store original value for new variable (null means it's new)
|
||||
_originalValues[variable.Name] = null;
|
||||
_variableEditPermissions[variable.Name] = await State.CanEditVariableAsync(variable);
|
||||
_hasUnsavedChanges = true;
|
||||
StateHasChanged();
|
||||
Snackbar.Add($"Variable '{variable.Name}' added (click Save to persist)", Severity.Info);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add(HttpErrorHelper.GetErrorMessage(ex), Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OpenEditConfigDialog()
|
||||
{
|
||||
if (State.SelectedConfig == null)
|
||||
return;
|
||||
|
||||
var parameters = new DialogParameters<EditConfigDialog>
|
||||
{
|
||||
{ x => x.Config, State.SelectedConfig },
|
||||
{ x => x.OnSave, EventCallback.Factory.Create<ConfigFileModel>(this, HandleEditConfig) }
|
||||
};
|
||||
|
||||
var options = new DialogOptions
|
||||
{
|
||||
CloseOnEscapeKey = true,
|
||||
MaxWidth = MaxWidth.Medium,
|
||||
FullWidth = true
|
||||
};
|
||||
|
||||
await DialogService.ShowAsync<EditConfigDialog>("Edit Config", parameters, options);
|
||||
}
|
||||
|
||||
private async Task HandleEditConfig(ConfigFileModel config)
|
||||
{
|
||||
try
|
||||
{
|
||||
await State.UpdateConfigAsync(description: config.Description);
|
||||
Snackbar.Add("Config updated successfully", Severity.Success);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add(HttpErrorHelper.GetErrorMessage(ex), Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OpenEditVariableDialog(ConfigVariableModel variable)
|
||||
{
|
||||
if (State.SelectedConfig == null)
|
||||
return;
|
||||
|
||||
var parameters = new DialogParameters<EditVariableDialog>
|
||||
{
|
||||
{ x => x.Variable, variable },
|
||||
{ x => x.OnSave, EventCallback.Factory.Create<ConfigVariableModel>(this, HandleEditVariable) }
|
||||
};
|
||||
|
||||
var options = new DialogOptions
|
||||
{
|
||||
CloseOnEscapeKey = true,
|
||||
MaxWidth = MaxWidth.Medium,
|
||||
FullWidth = true
|
||||
};
|
||||
|
||||
await DialogService.ShowAsync<EditVariableDialog>("Edit Variable", parameters, options);
|
||||
}
|
||||
|
||||
private async Task HandleEditVariable(ConfigVariableModel variable)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (State.SelectedConfig == null)
|
||||
return;
|
||||
|
||||
// Find and update the variable in the config
|
||||
var existingVariable = State.SelectedConfig.Variables.FirstOrDefault(v => v.Name == variable.Name);
|
||||
if (existingVariable == null)
|
||||
{
|
||||
Snackbar.Add($"Variable '{variable.Name}' not found", Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update variable properties (local state only)
|
||||
existingVariable.Type = variable.Type;
|
||||
existingVariable.Value = variable.Value;
|
||||
existingVariable.Min = variable.Min;
|
||||
existingVariable.Max = variable.Max;
|
||||
existingVariable.Roles = variable.Roles;
|
||||
existingVariable.EnumValues = variable.EnumValues;
|
||||
|
||||
// Refresh permission in case Roles changed
|
||||
_variableEditPermissions[variable.Name] = await State.CanEditVariableAsync(existingVariable);
|
||||
|
||||
// Mark as having unsaved changes
|
||||
_hasUnsavedChanges = true;
|
||||
StateHasChanged();
|
||||
|
||||
Snackbar.Add($"Variable '{variable.Name}' updated (click Save to persist)", Severity.Info);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add(HttpErrorHelper.GetErrorMessage(ex), Severity.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<MudStack Spacing="2">
|
||||
<MudText Typo="Typo.h6">Configurations</MudText>
|
||||
|
||||
@if (State.Configs.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary" Align="Align.Center" Class="mt-4">
|
||||
No configs found
|
||||
</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var config in State.Configs)
|
||||
{
|
||||
<MudCard Elevation="@(State.SelectedConfig?.Id == config.Id ? 4 : 1)"
|
||||
Class="mb-2 cursor-pointer"
|
||||
Style="@($"background-color: {(State.SelectedConfig?.Id == config.Id ? "var(--mud-palette-primary-lighten)" : "transparent")};")"
|
||||
@onclick="@(() => OnConfigSelected.InvokeAsync(config))">
|
||||
<MudCardContent>
|
||||
<MudStack Spacing="1">
|
||||
<MudText Typo="Typo.subtitle1" Style="font-weight: bold;">
|
||||
@config.ConfigType
|
||||
</MudText>
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
}
|
||||
}
|
||||
</MudStack>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public ConfigManagerState State { get; set; } = null!;
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<ConfigFileMetadataModel> OnConfigSelected { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
@inject ConfigManagerState State
|
||||
@inject IDialogService DialogService
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject HttpClient HttpClient
|
||||
@inject NavigationManager NavigationManager
|
||||
@implements IDisposable
|
||||
|
||||
<!-- Toolbar -->
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraExtraLarge" Class="mt-4">
|
||||
<MudPaper Class="pa-4 mb-4" MinHeight="80px">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Spacing="3">
|
||||
<MudText Typo="Typo.h5">Config Manager</MudText>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<!-- Search Box -->
|
||||
<MudTextField Value="@searchText"
|
||||
Placeholder="Search configs..."
|
||||
Variant="Variant.Outlined"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Search"
|
||||
Margin="Margin.Dense"
|
||||
Style="min-width: 250px;"
|
||||
Immediate="false"
|
||||
T="string"
|
||||
ValueChanged="OnSearchChanged"
|
||||
Clearable="true" />
|
||||
|
||||
<!-- Import Button -->
|
||||
<MudButton StartIcon="@Icons.Material.Filled.FileUpload"
|
||||
Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
OnClick="OpenImportDialog"
|
||||
Disabled="@(State.IsLoading || !_canEditConfig)">
|
||||
Import
|
||||
</MudButton>
|
||||
|
||||
<!-- Create Button -->
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Add"
|
||||
Variant="Variant.Filled"
|
||||
Color="Color.Success"
|
||||
OnClick="OpenCreateDialog"
|
||||
Disabled="@(State.IsLoading || !_canEditConfig)">
|
||||
Create
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
|
||||
<!-- Error Message -->
|
||||
@if (!string.IsNullOrEmpty(State.ErrorMessage))
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Class="mb-4" ShowCloseIcon="true" CloseIconClicked="ClearError">
|
||||
@State.ErrorMessage
|
||||
</MudAlert>
|
||||
}
|
||||
|
||||
<!-- Loading State -->
|
||||
@if (State.IsLoading)
|
||||
{
|
||||
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="mb-4" />
|
||||
}
|
||||
|
||||
<!-- Main Content -->
|
||||
<MudGrid Spacing="3">
|
||||
<!-- Left Panel: Config List -->
|
||||
<MudItem xs="12" md="4">
|
||||
<MudPaper Elevation="2" Class="pa-4" Style="height: calc(100vh - 250px); overflow-y: auto;">
|
||||
<ConfigListPanel State="@State" OnConfigSelected="OnConfigSelected" />
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
|
||||
<!-- Right Panel: Config Editor -->
|
||||
<MudItem xs="12" md="8">
|
||||
<MudPaper Elevation="2" Class="pa-4" Style="height: calc(100vh - 250px); overflow: hidden;">
|
||||
@if (State.SelectedConfig != null)
|
||||
{
|
||||
<ConfigEditorPanel State="@State"
|
||||
OnSave="@(EventCallback.Factory.Create(this, OnSaveConfig))"
|
||||
OnDelete="@(EventCallback.Factory.Create<Guid>(this, OnDeleteConfig))"
|
||||
OnExport="@(EventCallback.Factory.Create<Guid>(this, OnExportConfig))"
|
||||
Disabled="!_canEditConfig" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudStack AlignItems="AlignItems.Center" Justify="Justify.Center" Style="height: 100%;">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Settings" Size="Size.Large" Color="Color.Secondary" Style="font-size: 100px;" />
|
||||
<MudText Typo="Typo.h6" Color="Color.Secondary" Align="Align.Center" Class="mt-4">
|
||||
Select a config to view and edit
|
||||
</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary" Align="Align.Center">
|
||||
Choose a config from the list on the left
|
||||
</MudText>
|
||||
</MudStack>
|
||||
}
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
private const string ApiPath = "api/configs";
|
||||
|
||||
private string? searchText;
|
||||
private Timer? _searchTimer;
|
||||
private bool _canEditConfig = true;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
State.OnStateChanged += StateChanged;
|
||||
await State.LoadConfigsAsync();
|
||||
await LoadPermissionsAsync();
|
||||
}
|
||||
|
||||
private async Task LoadPermissionsAsync()
|
||||
{
|
||||
_canEditConfig = await State.CanEditConfigAsync();
|
||||
}
|
||||
|
||||
private void StateChanged()
|
||||
{
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private async Task OnSearchChanged(string? value)
|
||||
{
|
||||
searchText = value;
|
||||
|
||||
// Debounce search
|
||||
_searchTimer?.Dispose();
|
||||
_searchTimer = new Timer(async _ =>
|
||||
{
|
||||
await InvokeAsync(async () =>
|
||||
{
|
||||
await State.LoadConfigsAsync(searchText);
|
||||
});
|
||||
}, null, 500, Timeout.Infinite);
|
||||
}
|
||||
|
||||
private async Task OnConfigSelected(ConfigFileMetadataModel config)
|
||||
{
|
||||
|
||||
if (config.Id == State.SelectedConfig?.Id) return;
|
||||
await State.SelectConfigAsync(config);
|
||||
}
|
||||
|
||||
private async Task OpenImportDialog()
|
||||
{
|
||||
var parameters = new DialogParameters<ImportConfigDialog>
|
||||
{
|
||||
{ x => x.OnImport, EventCallback.Factory.Create<ConfigFileModel>(this, HandleImport) }
|
||||
};
|
||||
|
||||
var options = new DialogOptions
|
||||
{
|
||||
CloseOnEscapeKey = true,
|
||||
MaxWidth = MaxWidth.Medium,
|
||||
FullWidth = true
|
||||
};
|
||||
|
||||
await DialogService.ShowAsync<ImportConfigDialog>("Import Config", parameters, options);
|
||||
}
|
||||
|
||||
private async Task OpenCreateDialog()
|
||||
{
|
||||
var parameters = new DialogParameters<CreateConfigDialog>
|
||||
{
|
||||
{ x => x.OnCreate, EventCallback.Factory.Create<ConfigFileModel>(this, HandleCreate) }
|
||||
};
|
||||
|
||||
var options = new DialogOptions
|
||||
{
|
||||
CloseOnEscapeKey = true,
|
||||
MaxWidth = MaxWidth.Medium,
|
||||
FullWidth = true
|
||||
};
|
||||
|
||||
await DialogService.ShowAsync<CreateConfigDialog>("Create Config", parameters, options);
|
||||
}
|
||||
|
||||
private async Task HandleImport(ConfigFileModel config)
|
||||
{
|
||||
Snackbar.Add($"Config '{config.ConfigType}' imported successfully", Severity.Success);
|
||||
await State.LoadConfigsAsync(State.SearchQuery);
|
||||
}
|
||||
|
||||
private async Task HandleCreate(ConfigFileModel config)
|
||||
{
|
||||
Snackbar.Add($"Config '{config.ConfigType}' created successfully", Severity.Success);
|
||||
await State.LoadConfigsAsync(State.SearchQuery);
|
||||
}
|
||||
|
||||
private async Task OnSaveConfig()
|
||||
{
|
||||
try
|
||||
{
|
||||
await State.UpdateConfigAsync();
|
||||
Snackbar.Add("Config saved successfully", Severity.Success);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add(HttpErrorHelper.GetErrorMessage(ex), Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnDeleteConfig(Guid id)
|
||||
{
|
||||
var result = await DialogService.ShowMessageBoxAsync(
|
||||
"Delete Config",
|
||||
"Are you sure you want to delete this config?",
|
||||
yesText: "Delete",
|
||||
cancelText: "Cancel");
|
||||
|
||||
if (result == true)
|
||||
{
|
||||
try
|
||||
{
|
||||
await State.DeleteConfigAsync(id);
|
||||
Snackbar.Add("Config deleted successfully", Severity.Success);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add(HttpErrorHelper.GetErrorMessage(ex), Severity.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnExportConfig(Guid id)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Get config type for filename
|
||||
var configType = State.SelectedConfig?.ConfigType ?? State.Configs.FirstOrDefault(c => c.Id == id)?.ConfigType;
|
||||
var fileName = !string.IsNullOrEmpty(configType) ? $"{configType}.config.json" : "config.config.json";
|
||||
|
||||
// Build export URL
|
||||
var baseUrl = NavigationManager.BaseUri.TrimEnd('/');
|
||||
var exportUrl = $"{baseUrl}/{ApiPath}/{id}/export";
|
||||
|
||||
// Try to download directly from URL first (simpler and more reliable)
|
||||
try
|
||||
{
|
||||
await DownloadFileFromUrl(exportUrl, fileName);
|
||||
Snackbar.Add("Config exported successfully", Severity.Success);
|
||||
return;
|
||||
}
|
||||
catch (Microsoft.JSInterop.JSException)
|
||||
{
|
||||
// Fallback to stream method if URL download fails
|
||||
}
|
||||
|
||||
// Fallback: Get stream from API
|
||||
var stream = await State.ExportConfigAsync(id);
|
||||
await DownloadFile(stream, fileName);
|
||||
Snackbar.Add("Config exported successfully", Severity.Success);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add(HttpErrorHelper.GetErrorMessage(ex), Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DownloadFileFromUrl(string url, string fileName)
|
||||
{
|
||||
// Use JavaScript to download file directly from URL
|
||||
await JSRuntime.InvokeVoidAsync("downloadFileFromUrl", url, fileName);
|
||||
}
|
||||
|
||||
private async Task DownloadFile(Stream stream, string fileName)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Ensure stream position is at the beginning
|
||||
if (stream.CanSeek && stream.Position != 0)
|
||||
{
|
||||
stream.Position = 0;
|
||||
}
|
||||
|
||||
// Verify stream is readable
|
||||
if (!stream.CanRead)
|
||||
{
|
||||
throw new InvalidOperationException("Stream is not readable");
|
||||
}
|
||||
|
||||
// Verify stream has data
|
||||
if (stream.Length == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Stream is empty");
|
||||
}
|
||||
|
||||
// Create stream reference - this will keep the stream alive until JS reads it
|
||||
var streamRef = new DotNetStreamReference(stream);
|
||||
await JSRuntime.InvokeVoidAsync("downloadFileFromStream", fileName, streamRef);
|
||||
// Note: DotNetStreamReference will dispose the stream after JS reads it
|
||||
}
|
||||
catch (Microsoft.JSInterop.JSException jsEx)
|
||||
{
|
||||
var errorMsg = jsEx.Message.Contains("downloadFileFromStream") || jsEx.Message.Contains("is not defined")
|
||||
? "JavaScript function not found. Please ensure downloadFile.js is loaded in your HTML."
|
||||
: $"JavaScript error: {jsEx.Message}";
|
||||
Snackbar.Add(errorMsg, Severity.Error);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var errorMsg = HttpErrorHelper.GetErrorMessage(ex);
|
||||
Snackbar.Add(errorMsg, Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearError()
|
||||
{
|
||||
State.ClearError();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
State.OnStateChanged -= StateChanged;
|
||||
_searchTimer?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
@inject ISnackbar Snackbar
|
||||
@inject ConfigManagerState State
|
||||
|
||||
<MudDialog>
|
||||
<DialogContent>
|
||||
<MudStack Spacing="3">
|
||||
<MudTextField @bind-Value="variableName"
|
||||
Label="Variable Name"
|
||||
Required="true"
|
||||
HelperText="Unique name for the variable"
|
||||
Variant="Variant.Outlined" />
|
||||
|
||||
<MudSelect T="string"
|
||||
@bind-Value="variableType"
|
||||
Label="Type"
|
||||
Required="true"
|
||||
Variant="Variant.Outlined">
|
||||
<MudSelectItem Value="@("string")">String</MudSelectItem>
|
||||
<MudSelectItem Value="@("int")">Int</MudSelectItem>
|
||||
<MudSelectItem Value="@("double")">Double</MudSelectItem>
|
||||
<MudSelectItem Value="@("bool")">Bool</MudSelectItem>
|
||||
<MudSelectItem Value="@("enum")">Enum</MudSelectItem>
|
||||
<MudSelectItem Value="@("object")">Object</MudSelectItem>
|
||||
<MudSelectItem Value="@("array")">Array</MudSelectItem>
|
||||
</MudSelect>
|
||||
|
||||
@if (variableType == "int" || variableType == "double")
|
||||
{
|
||||
<MudGrid>
|
||||
<MudItem xs="6">
|
||||
<MudNumericField T="double?"
|
||||
@bind-Value="minValue"
|
||||
Label="Min Value"
|
||||
Variant="Variant.Outlined"
|
||||
HelperText="Optional minimum value" />
|
||||
</MudItem>
|
||||
<MudItem xs="6">
|
||||
<MudNumericField T="double?"
|
||||
@bind-Value="maxValue"
|
||||
Label="Max Value"
|
||||
Variant="Variant.Outlined"
|
||||
HelperText="Optional maximum value" />
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
}
|
||||
|
||||
@if (variableType == "enum")
|
||||
{
|
||||
<MudStack Spacing="2">
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">
|
||||
Enum Values (one per line)
|
||||
</MudText>
|
||||
<MudTextField @bind-Value="enumValuesText"
|
||||
Label="Enum Values"
|
||||
Required="true"
|
||||
Lines="3"
|
||||
Variant="Variant.Outlined"
|
||||
HelperText="Enter one value per line" />
|
||||
</MudStack>
|
||||
}
|
||||
|
||||
@if (variableType == "string")
|
||||
{
|
||||
<MudTextField @bind-Value="stringValue"
|
||||
Label="Default Value"
|
||||
Variant="Variant.Outlined"
|
||||
HelperText="Optional default value" />
|
||||
}
|
||||
else if (variableType == "int")
|
||||
{
|
||||
<MudNumericField T="int?"
|
||||
@bind-Value="intValue"
|
||||
Label="Default Value"
|
||||
Variant="Variant.Outlined"
|
||||
Min="@((int?)minValue)"
|
||||
Max="@((int?)maxValue)"
|
||||
HelperText="Optional default value" />
|
||||
}
|
||||
else if (variableType == "double")
|
||||
{
|
||||
<MudNumericField T="double?"
|
||||
@bind-Value="doubleValue"
|
||||
Label="Default Value"
|
||||
Variant="Variant.Outlined"
|
||||
Min="@minValue"
|
||||
Max="@maxValue"
|
||||
HelperText="Optional default value" />
|
||||
}
|
||||
else if (variableType == "bool")
|
||||
{
|
||||
<MudSwitch T="bool"
|
||||
@bind-Value="boolValue"
|
||||
Label="Default Value"
|
||||
Color="Color.Primary" />
|
||||
}
|
||||
else if (variableType == "enum")
|
||||
{
|
||||
<MudSelect T="string"
|
||||
@bind-Value="enumValue"
|
||||
Label="Default Value"
|
||||
Variant="Variant.Outlined"
|
||||
HelperText="Select default enum value">
|
||||
@if (!string.IsNullOrEmpty(enumValuesText))
|
||||
{
|
||||
@foreach (var val in GetEnumValues())
|
||||
{
|
||||
<MudSelectItem Value="@val">@val</MudSelectItem>
|
||||
}
|
||||
}
|
||||
</MudSelect>
|
||||
}
|
||||
else if (variableType == "object" || variableType == "array")
|
||||
{
|
||||
<MudTextField @bind-Value="jsonValue"
|
||||
Label="Default Value (JSON)"
|
||||
Lines="3"
|
||||
Variant="Variant.Outlined"
|
||||
HelperText="Enter valid JSON" />
|
||||
}
|
||||
|
||||
<MudTextField @bind-Value="roles"
|
||||
Label="Roles"
|
||||
Variant="Variant.Outlined"
|
||||
HelperText="Optional roles (comma-separated)" />
|
||||
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
<MudAlert Severity="Severity.Error">@errorMessage</MudAlert>
|
||||
}
|
||||
</MudStack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="Cancel">Cancel</MudButton>
|
||||
<MudButton Color="Color.Primary"
|
||||
Variant="Variant.Filled"
|
||||
OnClick="Submit"
|
||||
Disabled="@(!IsValid() || isAdding)">
|
||||
@if (isAdding)
|
||||
{
|
||||
<MudProgressCircular Class="ms-n1" Size="Size.Small" Indeterminate="true" />
|
||||
<MudText Class="ms-2">Adding...</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Add</span>
|
||||
}
|
||||
</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code
|
||||
{
|
||||
[CascadingParameter]
|
||||
private IMudDialogInstance MudDialog { get; set; } = null!;
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<ConfigVariableModel> OnAdd { get; set; } = default;
|
||||
|
||||
private string variableName = string.Empty;
|
||||
private string variableType = "string";
|
||||
private string? roles;
|
||||
private string? stringValue;
|
||||
private int? intValue;
|
||||
private double? doubleValue;
|
||||
private bool boolValue;
|
||||
private string? enumValue;
|
||||
private string? enumValuesText;
|
||||
private string? jsonValue;
|
||||
private double? minValue;
|
||||
private double? maxValue;
|
||||
private string? errorMessage;
|
||||
private bool isAdding = false;
|
||||
|
||||
private bool IsValid() => VariableDialogHelper.IsValid(variableName, variableType, enumValuesText, jsonValue);
|
||||
|
||||
private List<string> GetEnumValues() => VariableDialogHelper.GetEnumValues(enumValuesText);
|
||||
|
||||
private object? GetDefaultValue() => VariableDialogHelper.GetDefaultValue(variableType, stringValue, intValue, doubleValue, boolValue, enumValue, jsonValue);
|
||||
|
||||
private void Cancel() => MudDialog.Cancel();
|
||||
|
||||
private async Task Submit()
|
||||
{
|
||||
if (!IsValid())
|
||||
{
|
||||
errorMessage = "Please fill in all required fields correctly";
|
||||
return;
|
||||
}
|
||||
|
||||
isAdding = true;
|
||||
errorMessage = null;
|
||||
|
||||
try
|
||||
{
|
||||
// Check if variable name already exists
|
||||
if (State.SelectedConfig?.Variables.Any(v =>
|
||||
v.Name.Equals(variableName, StringComparison.OrdinalIgnoreCase)) == true)
|
||||
{
|
||||
errorMessage = $"Variable '{variableName}' already exists in this config";
|
||||
isAdding = false;
|
||||
return;
|
||||
}
|
||||
|
||||
var variable = new ConfigVariableModel
|
||||
{
|
||||
Name = variableName,
|
||||
Type = variableType,
|
||||
Value = GetDefaultValue(),
|
||||
Min = (variableType == "int" || variableType == "double") ? minValue : null,
|
||||
Max = (variableType == "int" || variableType == "double") ? maxValue : null,
|
||||
Roles = roles ?? string.Empty,
|
||||
EnumValues = variableType == "enum" ? GetEnumValues() : null
|
||||
};
|
||||
|
||||
await OnAdd.InvokeAsync(variable);
|
||||
MudDialog.Close(DialogResult.Ok(true));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = HttpErrorHelper.GetErrorMessage(ex);
|
||||
Snackbar.Add(errorMessage, Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
isAdding = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
@inject ISnackbar Snackbar
|
||||
@inject ConfigManagerState State
|
||||
|
||||
<MudDialog>
|
||||
<DialogContent>
|
||||
<MudStack Spacing="3">
|
||||
<MudTextField @bind-Value="configType"
|
||||
Label="Config Type"
|
||||
Required="true"
|
||||
HelperText="Unique identifier for the config (e.g., MQTTBrokerConfig)"
|
||||
Variant="Variant.Outlined" />
|
||||
|
||||
<MudTextField @bind-Value="description"
|
||||
Label="Description"
|
||||
Lines="3"
|
||||
HelperText="Optional description"
|
||||
Variant="Variant.Outlined" />
|
||||
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">
|
||||
Note: Variables can be added after creating the config.
|
||||
</MudText>
|
||||
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
<MudAlert Severity="Severity.Error">@errorMessage</MudAlert>
|
||||
}
|
||||
</MudStack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="Cancel">Cancel</MudButton>
|
||||
<MudButton Color="Color.Success"
|
||||
Variant="Variant.Filled"
|
||||
OnClick="Submit"
|
||||
Disabled="@(!IsValid() || isCreating)">
|
||||
@if (isCreating)
|
||||
{
|
||||
<MudProgressCircular Class="ms-n1" Size="Size.Small" Indeterminate="true" />
|
||||
<MudText Class="ms-2">Creating...</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Create</span>
|
||||
}
|
||||
</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
private IMudDialogInstance MudDialog { get; set; } = null!;
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<ConfigFileModel> OnCreate { get; set; } = default;
|
||||
|
||||
private string configType = string.Empty;
|
||||
private string? description;
|
||||
private string? errorMessage;
|
||||
private bool isCreating = false;
|
||||
|
||||
private bool IsValid()
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(configType);
|
||||
}
|
||||
|
||||
private void Cancel() => MudDialog.Cancel();
|
||||
|
||||
private async Task Submit()
|
||||
{
|
||||
if (!IsValid())
|
||||
return;
|
||||
|
||||
isCreating = true;
|
||||
errorMessage = null;
|
||||
|
||||
try
|
||||
{
|
||||
// Check if config type already exists
|
||||
var exists = await State.ConfigTypeExistsAsync(configType);
|
||||
if (exists)
|
||||
{
|
||||
errorMessage = $"Config with type '{configType}' already exists";
|
||||
isCreating = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Create config with empty variables
|
||||
var config = await State.CreateConfigAsync(configType, new List<ConfigVariableModel>(), description);
|
||||
|
||||
await OnCreate.InvokeAsync(config);
|
||||
MudDialog.Close(DialogResult.Ok(true));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = HttpErrorHelper.GetErrorMessage(ex);
|
||||
Snackbar.Add(errorMessage, Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
isCreating = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<MudDialog>
|
||||
<DialogContent>
|
||||
<MudStack Spacing="3">
|
||||
<MudTextField Value="@Config.ConfigType"
|
||||
Label="Config Type"
|
||||
Disabled="true"
|
||||
Variant="Variant.Outlined"
|
||||
HelperText="Config Type cannot be changed" />
|
||||
|
||||
<MudTextField @bind-Value="description"
|
||||
Label="Description"
|
||||
Lines="3"
|
||||
HelperText="Optional description"
|
||||
Variant="Variant.Outlined" />
|
||||
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
<MudAlert Severity="Severity.Error">@errorMessage</MudAlert>
|
||||
}
|
||||
</MudStack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="Cancel">Cancel</MudButton>
|
||||
<MudButton Color="Color.Primary"
|
||||
Variant="Variant.Filled"
|
||||
OnClick="Submit"
|
||||
Disabled="@isSaving">
|
||||
@if (isSaving)
|
||||
{
|
||||
<MudProgressCircular Class="ms-n1" Size="Size.Small" Indeterminate="true" />
|
||||
<MudText Class="ms-2">Saving...</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Save</span>
|
||||
}
|
||||
</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
private IMudDialogInstance MudDialog { get; set; } = null!;
|
||||
|
||||
[Parameter]
|
||||
public ConfigFileModel Config { get; set; } = null!;
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<ConfigFileModel> OnSave { get; set; }
|
||||
|
||||
private string? description;
|
||||
private string? errorMessage;
|
||||
private bool isSaving = false;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
description = Config.Description;
|
||||
}
|
||||
|
||||
private void Cancel() => MudDialog.Cancel();
|
||||
|
||||
private async Task Submit()
|
||||
{
|
||||
isSaving = true;
|
||||
errorMessage = null;
|
||||
|
||||
try
|
||||
{
|
||||
Config.Description = description;
|
||||
await OnSave.InvokeAsync(Config);
|
||||
MudDialog.Close(DialogResult.Ok(true));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = HttpErrorHelper.GetErrorMessage(ex);
|
||||
Snackbar.Add(errorMessage, Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
isSaving = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
@inject ISnackbar Snackbar
|
||||
@inject ConfigManagerState State
|
||||
|
||||
<MudDialog>
|
||||
<DialogContent>
|
||||
<MudStack Spacing="3">
|
||||
|
||||
<MudTextField Value="@variableName"
|
||||
Label="Variable Name"
|
||||
Disabled="true"
|
||||
Variant="Variant.Outlined"
|
||||
HelperText="Variable name cannot be changed" />
|
||||
|
||||
<MudSelect T="string"
|
||||
Value="@variableType"
|
||||
Label="Type"
|
||||
Disabled="true"
|
||||
Variant="Variant.Outlined"
|
||||
HelperText="Type cannot be changed">
|
||||
<MudSelectItem Value="@("string")">String</MudSelectItem>
|
||||
<MudSelectItem Value="@("int")">Int</MudSelectItem>
|
||||
<MudSelectItem Value="@("double")">Double</MudSelectItem>
|
||||
<MudSelectItem Value="@("bool")">Bool</MudSelectItem>
|
||||
<MudSelectItem Value="@("enum")">Enum</MudSelectItem>
|
||||
<MudSelectItem Value="@("object")">Object</MudSelectItem>
|
||||
<MudSelectItem Value="@("array")">Array</MudSelectItem>
|
||||
</MudSelect>
|
||||
|
||||
@if (variableType == "int" || variableType == "double")
|
||||
{
|
||||
<MudGrid>
|
||||
<MudItem xs="6">
|
||||
<MudNumericField T="double?"
|
||||
@bind-Value="minValue"
|
||||
Label="Min Value"
|
||||
Variant="Variant.Outlined"
|
||||
HelperText="Optional minimum value" />
|
||||
</MudItem>
|
||||
<MudItem xs="6">
|
||||
<MudNumericField T="double?"
|
||||
@bind-Value="maxValue"
|
||||
Label="Max Value"
|
||||
Variant="Variant.Outlined"
|
||||
HelperText="Optional maximum value" />
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
}
|
||||
|
||||
@if (variableType == "enum")
|
||||
{
|
||||
<MudStack Spacing="2">
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">
|
||||
Enum Values (one per line)
|
||||
</MudText>
|
||||
<MudTextField @bind-Value="enumValuesText"
|
||||
Label="Enum Values"
|
||||
Required="true"
|
||||
Lines="3"
|
||||
Variant="Variant.Outlined"
|
||||
HelperText="Enter one value per line" />
|
||||
</MudStack>
|
||||
}
|
||||
|
||||
@if (variableType == "string")
|
||||
{
|
||||
<MudTextField @bind-Value="stringValue"
|
||||
Label="Default Value"
|
||||
Variant="Variant.Outlined"
|
||||
HelperText="Optional default value" />
|
||||
}
|
||||
else if (variableType == "int")
|
||||
{
|
||||
<MudNumericField T="int?"
|
||||
@bind-Value="intValue"
|
||||
Label="Default Value"
|
||||
Variant="Variant.Outlined"
|
||||
Min="@((int?)minValue)"
|
||||
Max="@((int?)maxValue)"
|
||||
HelperText="Optional default value" />
|
||||
}
|
||||
else if (variableType == "double")
|
||||
{
|
||||
<MudNumericField T="double?"
|
||||
@bind-Value="doubleValue"
|
||||
Label="Default Value"
|
||||
Variant="Variant.Outlined"
|
||||
Min="@minValue"
|
||||
Max="@maxValue"
|
||||
HelperText="Optional default value" />
|
||||
}
|
||||
else if (variableType == "bool")
|
||||
{
|
||||
<MudSwitch T="bool"
|
||||
@bind-Value="boolValue"
|
||||
Label="Default Value"
|
||||
Color="Color.Primary" />
|
||||
}
|
||||
else if (variableType == "enum")
|
||||
{
|
||||
<MudSelect T="string"
|
||||
@bind-Value="enumValue"
|
||||
Label="Default Value"
|
||||
Variant="Variant.Outlined"
|
||||
HelperText="Select default enum value">
|
||||
@if (!string.IsNullOrEmpty(enumValuesText))
|
||||
{
|
||||
@foreach (var val in GetEnumValues())
|
||||
{
|
||||
<MudSelectItem Value="@val">@val</MudSelectItem>
|
||||
}
|
||||
}
|
||||
</MudSelect>
|
||||
}
|
||||
else if (variableType == "object" || variableType == "array")
|
||||
{
|
||||
<MudTextField @bind-Value="jsonValue"
|
||||
Label="Default Value (JSON)"
|
||||
Lines="3"
|
||||
Variant="Variant.Outlined"
|
||||
HelperText="Enter valid JSON" />
|
||||
}
|
||||
|
||||
<MudTextField @bind-Value="roles"
|
||||
Label="Roles"
|
||||
Variant="Variant.Outlined"
|
||||
HelperText="Optional roles (comma-separated)" />
|
||||
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
<MudAlert Severity="Severity.Error">@errorMessage</MudAlert>
|
||||
}
|
||||
</MudStack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="Cancel">Cancel</MudButton>
|
||||
<MudButton Color="Color.Primary"
|
||||
Variant="Variant.Filled"
|
||||
OnClick="Submit"
|
||||
Disabled="@(!IsValid() || isSaving)">
|
||||
@if (isSaving)
|
||||
{
|
||||
<MudProgressCircular Class="ms-n1" Size="Size.Small" Indeterminate="true" />
|
||||
<MudText Class="ms-2">Saving...</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Save</span>
|
||||
}
|
||||
</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code
|
||||
{
|
||||
[CascadingParameter]
|
||||
private IMudDialogInstance MudDialog { get; set; } = null!;
|
||||
|
||||
[Parameter]
|
||||
public ConfigVariableModel Variable { get; set; } = null!;
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<ConfigVariableModel> OnSave { get; set; } = default;
|
||||
|
||||
private string variableName = string.Empty;
|
||||
private string variableType = "string";
|
||||
private string? roles;
|
||||
private string? stringValue;
|
||||
private int? intValue;
|
||||
private double? doubleValue;
|
||||
private bool boolValue;
|
||||
private string? enumValue;
|
||||
private string? enumValuesText;
|
||||
private string? jsonValue;
|
||||
private double? minValue;
|
||||
private double? maxValue;
|
||||
private string? errorMessage;
|
||||
private bool isSaving = false;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
// Initialize from existing variable
|
||||
variableName = Variable.Name;
|
||||
variableType = Variable.Type;
|
||||
roles = Variable.Roles;
|
||||
minValue = Variable.Min;
|
||||
maxValue = Variable.Max;
|
||||
enumValuesText = Variable.EnumValues != null ? string.Join("\n", Variable.EnumValues) : null;
|
||||
|
||||
// Initialize default value based on type
|
||||
switch (Variable.Type.ToLower())
|
||||
{
|
||||
case "string":
|
||||
stringValue = Variable.Value?.ToString() ?? string.Empty;
|
||||
break;
|
||||
case "int":
|
||||
if (Variable.Value is int i)
|
||||
intValue = i;
|
||||
else if (int.TryParse(Variable.Value?.ToString(), out var parsedInt))
|
||||
intValue = parsedInt;
|
||||
break;
|
||||
case "double":
|
||||
if (Variable.Value is double d)
|
||||
doubleValue = d;
|
||||
else if (double.TryParse(Variable.Value?.ToString(), out var parsedDouble))
|
||||
doubleValue = parsedDouble;
|
||||
break;
|
||||
case "bool":
|
||||
if (Variable.Value is bool b)
|
||||
boolValue = b;
|
||||
else if (bool.TryParse(Variable.Value?.ToString(), out var parsedBool))
|
||||
boolValue = parsedBool;
|
||||
break;
|
||||
case "enum":
|
||||
enumValue = Variable.Value?.ToString();
|
||||
break;
|
||||
case "object":
|
||||
case "array":
|
||||
jsonValue = Variable.Value?.ToString() ?? string.Empty;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsValid() => VariableDialogHelper.IsValid(variableName, variableType, enumValuesText, jsonValue);
|
||||
|
||||
private List<string> GetEnumValues() => VariableDialogHelper.GetEnumValues(enumValuesText);
|
||||
|
||||
private object? GetDefaultValue() => VariableDialogHelper.GetDefaultValue(variableType, stringValue, intValue, doubleValue, boolValue, enumValue, jsonValue);
|
||||
|
||||
private void Cancel() => MudDialog.Cancel();
|
||||
|
||||
private async Task Submit()
|
||||
{
|
||||
if (!IsValid())
|
||||
{
|
||||
errorMessage = "Please fill in all required fields correctly";
|
||||
return;
|
||||
}
|
||||
|
||||
isSaving = true;
|
||||
errorMessage = null;
|
||||
|
||||
try
|
||||
{
|
||||
var variable = new ConfigVariableModel
|
||||
{
|
||||
Name = variableName,
|
||||
Type = variableType,
|
||||
Value = GetDefaultValue(),
|
||||
Min = (variableType == "int" || variableType == "double") ? minValue : null,
|
||||
Max = (variableType == "int" || variableType == "double") ? maxValue : null,
|
||||
Roles = roles ?? string.Empty,
|
||||
EnumValues = variableType == "enum" ? GetEnumValues() : null
|
||||
};
|
||||
|
||||
await OnSave.InvokeAsync(variable);
|
||||
MudDialog.Close(DialogResult.Ok(true));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = HttpErrorHelper.GetErrorMessage(ex);
|
||||
Snackbar.Add(errorMessage, Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
isSaving = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
@inject ISnackbar Snackbar
|
||||
@inject ConfigManagerState State
|
||||
@using System.Text.Json
|
||||
|
||||
<MudDialog>
|
||||
<DialogContent>
|
||||
<MudStack Spacing="3">
|
||||
<MudTextField @bind-Value="configType"
|
||||
Label="Config Type"
|
||||
Required="true"
|
||||
HelperText="Unique identifier for the config (e.g., MQTTBrokerConfig)"
|
||||
Variant="Variant.Outlined" />
|
||||
|
||||
<MudTextField @bind-Value="description"
|
||||
Label="Description"
|
||||
Lines="3"
|
||||
HelperText="Optional description"
|
||||
Variant="Variant.Outlined" />
|
||||
|
||||
<MudFileUpload T="IBrowserFile"
|
||||
Accept=".json"
|
||||
FilesChanged="OnFileSelected"
|
||||
MaximumFileCount="1">
|
||||
<CustomContent>
|
||||
<MudButton Variant="Variant.Outlined"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Upload"
|
||||
FullWidth="true"
|
||||
OnClick="@context.OpenFilePickerAsync">
|
||||
@if (selectedFile != null)
|
||||
{
|
||||
<span>@selectedFile.Name</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Select JSON File</span>
|
||||
}
|
||||
</MudButton>
|
||||
</CustomContent>
|
||||
</MudFileUpload>
|
||||
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
<MudAlert Severity="Severity.Error">@errorMessage</MudAlert>
|
||||
}
|
||||
</MudStack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="Cancel">Cancel</MudButton>
|
||||
<MudButton Color="Color.Primary"
|
||||
Variant="Variant.Filled"
|
||||
OnClick="Submit"
|
||||
Disabled="@(!IsValid() || isImporting)">
|
||||
@if (isImporting)
|
||||
{
|
||||
<MudProgressCircular Class="ms-n1" Size="Size.Small" Indeterminate="true" />
|
||||
<MudText Class="ms-2">Importing...</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Import</span>
|
||||
}
|
||||
</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
private IMudDialogInstance MudDialog { get; set; } = null!;
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<ConfigFileModel> OnImport { get; set; } = default;
|
||||
|
||||
private string configType = string.Empty;
|
||||
private string? description;
|
||||
private IBrowserFile? selectedFile;
|
||||
private string? errorMessage;
|
||||
private bool isImporting = false;
|
||||
|
||||
private bool IsValid()
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(configType) && selectedFile != null;
|
||||
}
|
||||
|
||||
private async Task OnFileSelected(IBrowserFile? file)
|
||||
{
|
||||
selectedFile = file;
|
||||
errorMessage = null;
|
||||
|
||||
// If file is selected, try to parse it and extract ConfigType and Description
|
||||
if (file != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ParseFileAndFillForm(file);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Don't show error if parsing fails - user can still manually enter the fields
|
||||
// Just log or ignore
|
||||
System.Diagnostics.Debug.WriteLine($"Failed to parse file: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task ParseFileAndFillForm(IBrowserFile file)
|
||||
{
|
||||
using var stream = file.OpenReadStream();
|
||||
using var reader = new StreamReader(stream);
|
||||
var json = await reader.ReadToEndAsync();
|
||||
|
||||
// Parse JSON to check if it has ConfigType and Description
|
||||
using var jsonDoc = JsonDocument.Parse(json);
|
||||
var root = jsonDoc.RootElement;
|
||||
|
||||
// Check if it's new format (object with metadata)
|
||||
if (root.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
// Try to get ConfigType
|
||||
if (root.TryGetProperty("configType", out var configTypeProp) &&
|
||||
configTypeProp.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var fileConfigType = configTypeProp.GetString();
|
||||
// Only fill if field is empty
|
||||
if (string.IsNullOrWhiteSpace(configType) && !string.IsNullOrWhiteSpace(fileConfigType))
|
||||
{
|
||||
configType = fileConfigType;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to get Description
|
||||
if (root.TryGetProperty("description", out var descProp))
|
||||
{
|
||||
string? fileDescription = null;
|
||||
if (descProp.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
fileDescription = descProp.GetString();
|
||||
}
|
||||
else if (descProp.ValueKind == JsonValueKind.Null)
|
||||
{
|
||||
fileDescription = null;
|
||||
}
|
||||
|
||||
// Only fill if field is empty
|
||||
if (string.IsNullOrWhiteSpace(description) && !string.IsNullOrWhiteSpace(fileDescription))
|
||||
{
|
||||
description = fileDescription;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void Cancel() => MudDialog.Cancel();
|
||||
|
||||
private async Task Submit()
|
||||
{
|
||||
if (!IsValid() || selectedFile == null)
|
||||
return;
|
||||
|
||||
isImporting = true;
|
||||
errorMessage = null;
|
||||
|
||||
try
|
||||
{
|
||||
// Check if config type already exists
|
||||
var exists = await State.ConfigTypeExistsAsync(configType);
|
||||
if (exists)
|
||||
{
|
||||
errorMessage = $"Config with type '{configType}' already exists";
|
||||
isImporting = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Read file stream
|
||||
using var stream = selectedFile.OpenReadStream();
|
||||
var config = await State.ImportConfigAsync(stream, selectedFile.Name, configType, description);
|
||||
|
||||
await OnImport.InvokeAsync(config);
|
||||
MudDialog.Close(DialogResult.Ok(true));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = HttpErrorHelper.GetErrorMessage(ex);
|
||||
Snackbar.Add(errorMessage, Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
isImporting = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
namespace RobotNet10.CustomConfigurationEditor.Components.ConfigManager.Dialogs;
|
||||
|
||||
/// <summary>
|
||||
/// Shared helper methods cho AddVariableDialog và EditVariableDialog
|
||||
/// </summary>
|
||||
internal static class VariableDialogHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Validate variable input fields
|
||||
/// </summary>
|
||||
public static bool IsValid(string? variableName, string? variableType, string? enumValuesText, string? jsonValue)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(variableName))
|
||||
return false;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(variableType))
|
||||
return false;
|
||||
|
||||
// Validate enum
|
||||
if (variableType == "enum")
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(enumValuesText))
|
||||
return false;
|
||||
|
||||
var values = GetEnumValues(enumValuesText);
|
||||
if (values.Count == 0)
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate JSON for object/array
|
||||
if (variableType == "object" || variableType == "array")
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(jsonValue))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(jsonValue);
|
||||
}
|
||||
catch (System.Text.Json.JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse enum values từ text (one per line)
|
||||
/// </summary>
|
||||
public static List<string> GetEnumValues(string? enumValuesText)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(enumValuesText))
|
||||
return [];
|
||||
|
||||
return enumValuesText.Split('\n', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(v => v.Trim())
|
||||
.Where(v => !string.IsNullOrWhiteSpace(v))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lấy default value theo variable type
|
||||
/// </summary>
|
||||
public static object? GetDefaultValue(string? variableType, string? stringValue, int? intValue, double? doubleValue, bool boolValue, string? enumValue, string? jsonValue)
|
||||
{
|
||||
return variableType switch
|
||||
{
|
||||
"string" => stringValue ?? string.Empty,
|
||||
"int" => intValue,
|
||||
"double" => doubleValue,
|
||||
"bool" => boolValue,
|
||||
"enum" => enumValue,
|
||||
"object" => jsonValue,
|
||||
"array" => jsonValue,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
@switch (Variable.Type.ToLower())
|
||||
{
|
||||
case "string":
|
||||
<MudTextField Value="@stringValue"
|
||||
Variant="Variant.Outlined"
|
||||
Margin="Margin.Dense"
|
||||
T="string"
|
||||
Disabled="@Disabled"
|
||||
ValueChanged="HandleStringValueChanged" />
|
||||
break;
|
||||
|
||||
case "int":
|
||||
<MudNumericField T="int?"
|
||||
Value="@intValue"
|
||||
Variant="Variant.Outlined"
|
||||
Margin="Margin.Dense"
|
||||
Disabled="@Disabled"
|
||||
Min="@(Variable.Min.HasValue ? (int?)Variable.Min.Value : null)"
|
||||
Max="@(Variable.Max.HasValue ? (int?)Variable.Max.Value : null)"
|
||||
ValueChanged="HandleIntValueChanged" />
|
||||
break;
|
||||
|
||||
case "double":
|
||||
<MudNumericField T="double?"
|
||||
Value="@doubleValue"
|
||||
Variant="Variant.Outlined"
|
||||
Margin="Margin.Dense"
|
||||
Disabled="@Disabled"
|
||||
Min="@Variable.Min"
|
||||
Max="@Variable.Max"
|
||||
ValueChanged="HandleDoubleValueChanged" />
|
||||
break;
|
||||
|
||||
case "bool":
|
||||
<MudSwitch T="bool"
|
||||
Value="@boolValue"
|
||||
Color="Color.Primary"
|
||||
Disabled="@Disabled"
|
||||
ValueChanged="HandleBoolValueChanged" />
|
||||
break;
|
||||
|
||||
case "enum":
|
||||
<MudSelect T="string"
|
||||
Value="@enumValue"
|
||||
Variant="Variant.Outlined"
|
||||
Margin="Margin.Dense"
|
||||
Dense="true"
|
||||
Disabled="@Disabled"
|
||||
ValueChanged="HandleEnumValueChanged">
|
||||
@if (Variable.EnumValues != null)
|
||||
{
|
||||
@foreach (var enumVal in Variable.EnumValues)
|
||||
{
|
||||
<MudSelectItem Value="@enumVal">@enumVal</MudSelectItem>
|
||||
}
|
||||
}
|
||||
</MudSelect>
|
||||
break;
|
||||
|
||||
case "object":
|
||||
case "array":
|
||||
<MudTextField Value="@jsonValue"
|
||||
Variant="Variant.Outlined"
|
||||
Margin="Margin.Dense"
|
||||
Lines="3"
|
||||
T="string"
|
||||
Disabled="@Disabled"
|
||||
ValueChanged="HandleJsonValueChanged"
|
||||
Placeholder="Enter JSON..." />
|
||||
break;
|
||||
|
||||
default:
|
||||
<MudTextField Value="@stringValue"
|
||||
Variant="Variant.Outlined"
|
||||
Margin="Margin.Dense"
|
||||
T="string"
|
||||
Disabled="@Disabled"
|
||||
ValueChanged="@HandleStringValueChanged" />
|
||||
break;
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public ConfigVariableModel Variable { get; set; } = null!;
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<object?> OnValueChanged { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool Disabled { get; set; } = false;
|
||||
|
||||
private string? stringValue;
|
||||
private int? intValue;
|
||||
private double? doubleValue;
|
||||
private bool boolValue;
|
||||
private string? enumValue;
|
||||
private string? jsonValue;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
// Initialize values based on type
|
||||
switch (Variable.Type.ToLower())
|
||||
{
|
||||
case "string":
|
||||
stringValue = Variable.Value?.ToString() ?? string.Empty;
|
||||
break;
|
||||
case "int":
|
||||
if (Variable.Value is int i)
|
||||
intValue = i;
|
||||
else if (int.TryParse(Variable.Value?.ToString(), out var parsedInt))
|
||||
intValue = parsedInt;
|
||||
break;
|
||||
case "double":
|
||||
if (Variable.Value is double d)
|
||||
doubleValue = d;
|
||||
else if (double.TryParse(Variable.Value?.ToString(), out var parsedDouble))
|
||||
doubleValue = parsedDouble;
|
||||
break;
|
||||
case "bool":
|
||||
if (Variable.Value is bool b)
|
||||
boolValue = b;
|
||||
else if (bool.TryParse(Variable.Value?.ToString(), out var parsedBool))
|
||||
boolValue = parsedBool;
|
||||
break;
|
||||
case "enum":
|
||||
enumValue = Variable.Value?.ToString();
|
||||
break;
|
||||
case "object":
|
||||
case "array":
|
||||
jsonValue = Variable.Value?.ToString() ?? string.Empty;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleStringValueChanged(string value)
|
||||
{
|
||||
stringValue = value;
|
||||
await OnValueChanged.InvokeAsync(value);
|
||||
}
|
||||
|
||||
private async Task HandleIntValueChanged(int? value)
|
||||
{
|
||||
intValue = value;
|
||||
await OnValueChanged.InvokeAsync(value);
|
||||
}
|
||||
|
||||
private async Task HandleDoubleValueChanged(double? value)
|
||||
{
|
||||
doubleValue = value;
|
||||
await OnValueChanged.InvokeAsync(value);
|
||||
}
|
||||
|
||||
private async Task HandleBoolValueChanged(bool value)
|
||||
{
|
||||
boolValue = value;
|
||||
await OnValueChanged.InvokeAsync(value);
|
||||
}
|
||||
|
||||
private async Task HandleEnumValueChanged(string value)
|
||||
{
|
||||
enumValue = value;
|
||||
await OnValueChanged.InvokeAsync(value);
|
||||
}
|
||||
|
||||
private async Task HandleJsonValueChanged(string value)
|
||||
{
|
||||
jsonValue = value;
|
||||
object? convertedValue = value;
|
||||
|
||||
// For JSON types, try to parse
|
||||
if (!string.IsNullOrEmpty(value))
|
||||
{
|
||||
try
|
||||
{
|
||||
// Validate JSON
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(value);
|
||||
convertedValue = value;
|
||||
}
|
||||
catch (System.Text.Json.JsonException)
|
||||
{
|
||||
// Invalid JSON, but still pass it through
|
||||
convertedValue = value;
|
||||
}
|
||||
}
|
||||
|
||||
await OnValueChanged.InvokeAsync(convertedValue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace RobotNet10.CustomConfigurationEditor.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Model cho ConfigFileMetadata trong frontend (list view)
|
||||
/// </summary>
|
||||
public class ConfigFileMetadataModel
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string ConfigType { get; set; } = string.Empty; // Tên định danh duy nhất
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace RobotNet10.CustomConfigurationEditor.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Model cho ConfigFile trong frontend
|
||||
/// </summary>
|
||||
public class ConfigFileModel
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string ConfigType { get; set; } = string.Empty; // Tên định danh duy nhất
|
||||
public List<ConfigVariableModel> Variables { get; set; } = new();
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace RobotNet10.CustomConfigurationEditor.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Model cho ConfigVariable trong frontend
|
||||
/// </summary>
|
||||
public class ConfigVariableModel
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Type { get; set; } = string.Empty; // "string", "int", "double", "bool", "object", "array", "enum"
|
||||
public object? Value { get; set; }
|
||||
|
||||
// Optional properties
|
||||
public double? Min { get; set; } // Cho int và double (0 nếu không dùng)
|
||||
public double? Max { get; set; } // Cho int và double (0 nếu không dùng)
|
||||
public string Roles { get; set; } = string.Empty; // Roles string (có thể empty)
|
||||
public List<string>? EnumValues { get; set; } // Cho type Enum - danh sách các giá trị cho phép
|
||||
}
|
||||
|
||||
@@ -0,0 +1,562 @@
|
||||
# RobotNet10.CustomConfigurationEditor
|
||||
|
||||
Component Blazor để quản lý cấu hình với giao diện người dùng, tích hợp với `RobotNet10.CustomConfiguration` backend.
|
||||
|
||||
## 📋 Mục lục
|
||||
|
||||
- [Tính năng](#tính-năng)
|
||||
- [Cài đặt](#cài-đặt)
|
||||
- [Sử dụng Component](#sử-dụng-component)
|
||||
- [Sử dụng Services](#sử-dụng-services)
|
||||
- [Components](#components)
|
||||
- [Models](#models)
|
||||
- [Ví dụ](#ví-dụ)
|
||||
|
||||
## ✨ Tính năng
|
||||
|
||||
- 🎨 **Giao diện đẹp**: Sử dụng MudBlazor components
|
||||
- 📋 **Danh sách Configs**: Hiển thị và tìm kiếm configs
|
||||
- ✏️ **Editor**: Chỉnh sửa config và variables
|
||||
- 📤 **Import/Export**: Import và export config files
|
||||
- 🔍 **Tìm kiếm**: Tìm kiếm configs theo tên hoặc ConfigType
|
||||
- ✅ **Validation**: Validation real-time khi chỉnh sửa
|
||||
- 🎯 **Type-aware Editor**: Editor tự động thay đổi theo type của variable
|
||||
|
||||
## 🚀 Cài đặt
|
||||
|
||||
### 1. Thêm Project Reference
|
||||
|
||||
```xml
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Components\RobotNet10.CustomConfigurationEditor\RobotNet10.CustomConfigurationEditor.csproj" />
|
||||
</ItemGroup>
|
||||
```
|
||||
|
||||
### 2. Đăng ký Services
|
||||
|
||||
Trong `Program.cs` hoặc `Client/Program.cs`:
|
||||
|
||||
```csharp
|
||||
using RobotNet10.CustomConfigurationEditor.Services.API;
|
||||
using RobotNet10.CustomConfigurationEditor.Services.State;
|
||||
|
||||
// HttpClient (nếu chưa có)
|
||||
builder.Services.AddScoped(sp => new HttpClient
|
||||
{
|
||||
BaseAddress = new Uri(builder.HostEnvironment.BaseAddress)
|
||||
});
|
||||
|
||||
// MudBlazor (nếu chưa có)
|
||||
builder.Services.AddMudServices();
|
||||
|
||||
// CustomConfiguration Services
|
||||
builder.Services.AddScoped<ConfigApiService>();
|
||||
builder.Services.AddScoped<ConfigManagerState>();
|
||||
```
|
||||
|
||||
### 3. Copy JavaScript File
|
||||
|
||||
Copy `wwwroot/js/downloadFile.js` vào `wwwroot/js/` của project frontend và thêm vào `index.html`:
|
||||
|
||||
```html
|
||||
<script src="js/downloadFile.js"></script>
|
||||
```
|
||||
|
||||
### 4. Thêm Using
|
||||
|
||||
Trong `_Imports.razor`:
|
||||
|
||||
```razor
|
||||
@using RobotNet10.CustomConfigurationEditor.Components.ConfigManager
|
||||
@using RobotNet10.CustomConfigurationEditor.Services.API
|
||||
@using RobotNet10.CustomConfigurationEditor.Services.State
|
||||
```
|
||||
|
||||
## 📖 Sử dụng Component
|
||||
|
||||
### Cách 1: Sử dụng Component trực tiếp
|
||||
|
||||
Tạo page mới:
|
||||
|
||||
```razor
|
||||
@page "/config-manager"
|
||||
@using RobotNet10.CustomConfigurationEditor.Components.ConfigManager
|
||||
|
||||
<PageTitle>Configuration Manager</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="mt-4">
|
||||
<ConfigManagerComponent />
|
||||
</MudContainer>
|
||||
```
|
||||
|
||||
### Cách 2: Sử dụng trong Layout
|
||||
|
||||
Thêm vào navigation menu:
|
||||
|
||||
```razor
|
||||
<MudNavLink Href="/config-manager" Match="NavLinkMatch.All">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Settings" />
|
||||
Configuration Manager
|
||||
</MudNavLink>
|
||||
```
|
||||
|
||||
## 🔧 Sử dụng Services
|
||||
|
||||
### ConfigApiService
|
||||
|
||||
Service để gọi API backend:
|
||||
|
||||
```csharp
|
||||
@inject ConfigApiService ApiService
|
||||
|
||||
@code {
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
// Lấy tất cả configs
|
||||
var configs = await ApiService.GetAllConfigsAsync();
|
||||
|
||||
// Lấy config theo ID
|
||||
var config = await ApiService.GetConfigByIdAsync(id);
|
||||
|
||||
// Lấy config theo ConfigType
|
||||
var mqttConfig = await ApiService.GetConfigByTypeAsync("MQTTBrokerConfig");
|
||||
|
||||
// Tạo config mới
|
||||
var newConfig = await ApiService.CreateConfigAsync(
|
||||
configType: "MyConfig",
|
||||
variables: variables,
|
||||
description: "My config"
|
||||
);
|
||||
|
||||
// Cập nhật config
|
||||
await ApiService.UpdateConfigAsync(id, variables, description);
|
||||
|
||||
// Xóa config
|
||||
await ApiService.DeleteConfigAsync(id);
|
||||
|
||||
// Import config
|
||||
using var stream = file.OpenReadStream();
|
||||
var imported = await ApiService.ImportConfigAsync(stream, file.Name, "MyConfig");
|
||||
|
||||
// Export config
|
||||
var exportStream = await ApiService.ExportConfigAsync(id);
|
||||
|
||||
// Cập nhật variable
|
||||
await ApiService.UpdateVariableAsync(id, "port", 8080);
|
||||
|
||||
// Thêm variable
|
||||
await ApiService.AddVariableAsync(id, newVariable);
|
||||
|
||||
// Xóa variable
|
||||
await ApiService.RemoveVariableAsync(id, "variableName");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ConfigManagerState
|
||||
|
||||
State management service với events:
|
||||
|
||||
```csharp
|
||||
@inject ConfigManagerState State
|
||||
|
||||
<MudButton OnClick="LoadConfigs">Load Configs</MudButton>
|
||||
|
||||
@if (State.IsLoading)
|
||||
{
|
||||
<MudProgressLinear />
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(State.ErrorMessage))
|
||||
{
|
||||
<MudAlert Severity="Severity.Error">@State.ErrorMessage</MudAlert>
|
||||
}
|
||||
|
||||
@code {
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
// Subscribe to state changes
|
||||
State.OnStateChanged += StateChanged;
|
||||
|
||||
// Load configs
|
||||
await State.LoadConfigsAsync();
|
||||
}
|
||||
|
||||
private async Task LoadConfigs()
|
||||
{
|
||||
await State.LoadConfigsAsync();
|
||||
}
|
||||
|
||||
private void StateChanged()
|
||||
{
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
State.OnStateChanged -= StateChanged;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🧩 Components
|
||||
|
||||
### ConfigManagerComponent
|
||||
|
||||
Component chính để quản lý configs.
|
||||
|
||||
**Sử dụng:**
|
||||
|
||||
```razor
|
||||
<ConfigManagerComponent />
|
||||
```
|
||||
|
||||
**Tính năng:**
|
||||
- Toolbar với search, import, create buttons
|
||||
- Split view: List panel và Editor panel
|
||||
- Error handling và notifications
|
||||
|
||||
### ConfigListPanel
|
||||
|
||||
Panel hiển thị danh sách configs.
|
||||
|
||||
**Parameters:**
|
||||
- `State`: ConfigManagerState
|
||||
- `OnConfigSelected`: EventCallback khi chọn config
|
||||
|
||||
**Sử dụng:**
|
||||
|
||||
```razor
|
||||
<ConfigListPanel
|
||||
State="@State"
|
||||
OnConfigSelected="@(EventCallback.Factory.Create<ConfigFileMetadataModel>(this, OnConfigSelected))" />
|
||||
```
|
||||
|
||||
### ConfigEditorPanel
|
||||
|
||||
Panel để chỉnh sửa config.
|
||||
|
||||
**Parameters:**
|
||||
- `State`: ConfigManagerState
|
||||
- `OnSave`: EventCallback khi save
|
||||
- `OnDelete`: EventCallback khi delete
|
||||
- `OnExport`: EventCallback khi export
|
||||
|
||||
**Sử dụng:**
|
||||
|
||||
```razor
|
||||
<ConfigEditorPanel
|
||||
State="@State"
|
||||
OnSave="@(EventCallback.Factory.Create(this, OnSave))"
|
||||
OnDelete="@(EventCallback.Factory.Create<Guid>(this, OnDelete))"
|
||||
OnExport="@(EventCallback.Factory.Create<Guid>(this, OnExport))" />
|
||||
```
|
||||
|
||||
### VariableEditor
|
||||
|
||||
Component để chỉnh sửa một variable, tự động thay đổi input type theo variable type.
|
||||
|
||||
**Parameters:**
|
||||
- `Variable`: ConfigVariableModel
|
||||
- `OnValueChanged`: EventCallback khi value thay đổi
|
||||
|
||||
**Sử dụng:**
|
||||
|
||||
```razor
|
||||
<VariableEditor
|
||||
Variable="@variable"
|
||||
OnValueChanged="@(EventCallback.Factory.Create<object?>(this, OnValueChanged))" />
|
||||
```
|
||||
|
||||
**Hỗ trợ types:**
|
||||
- `string`: MudTextField
|
||||
- `int`: MudNumericField với Min/Max
|
||||
- `double`: MudNumericField với Min/Max
|
||||
- `bool`: MudSwitch
|
||||
- `enum`: MudSelect với EnumValues
|
||||
- `object`: MudTextField multiline (JSON)
|
||||
- `array`: MudTextField multiline (JSON)
|
||||
|
||||
### Dialogs
|
||||
|
||||
#### ImportConfigDialog
|
||||
|
||||
Dialog để import config từ file.
|
||||
|
||||
```razor
|
||||
var dialog = await DialogService.ShowAsync<ImportConfigDialog>("Import Config");
|
||||
var result = await dialog.Result;
|
||||
```
|
||||
|
||||
#### ExportConfigDialog
|
||||
|
||||
Dialog để export config.
|
||||
|
||||
```razor
|
||||
var dialog = await DialogService.ShowAsync<ExportConfigDialog>("Export Config");
|
||||
```
|
||||
|
||||
#### CreateConfigDialog
|
||||
|
||||
Dialog để tạo config mới.
|
||||
|
||||
```razor
|
||||
var dialog = await DialogService.ShowAsync<CreateConfigDialog>("Create Config");
|
||||
```
|
||||
|
||||
#### EditConfigDialog
|
||||
|
||||
Dialog để chỉnh sửa metadata của config.
|
||||
|
||||
```razor
|
||||
var dialog = await DialogService.ShowAsync<EditConfigDialog>("Edit Config");
|
||||
```
|
||||
|
||||
## 📦 Models
|
||||
|
||||
### ConfigFileModel
|
||||
|
||||
```csharp
|
||||
public class ConfigFileModel
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string ConfigType { get; set; } = string.Empty;
|
||||
public List<ConfigVariableModel> Variables { get; set; } = new();
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
### ConfigFileMetadataModel
|
||||
|
||||
```csharp
|
||||
public class ConfigFileMetadataModel
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string ConfigType { get; set; } = string.Empty;
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
### ConfigVariableModel
|
||||
|
||||
```csharp
|
||||
public class ConfigVariableModel
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Type { get; set; } = string.Empty; // "string", "int", "double", "bool", "enum", "object", "array"
|
||||
public object? Value { get; set; }
|
||||
public double? Min { get; set; }
|
||||
public double? Max { get; set; }
|
||||
public string? Roles { get; set; }
|
||||
public List<string>? EnumValues { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
## 💡 Ví dụ
|
||||
|
||||
### Ví dụ 1: Custom Page với State
|
||||
|
||||
```razor
|
||||
@page "/my-configs"
|
||||
@inject ConfigManagerState State
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<PageTitle>My Configs</PageTitle>
|
||||
|
||||
<MudContainer>
|
||||
<MudText Typo="Typo.h4" Class="mb-4">My Configurations</MudText>
|
||||
|
||||
@if (State.IsLoading)
|
||||
{
|
||||
<MudProgressLinear />
|
||||
}
|
||||
|
||||
<MudGrid>
|
||||
@foreach (var config in State.Configs)
|
||||
{
|
||||
<MudItem xs="12" md="6" lg="4">
|
||||
<MudCard>
|
||||
<MudCardContent>
|
||||
<MudText Typo="Typo.h6">@config.ConfigType</MudText>
|
||||
<MudText Typo="Typo.body2">@config.Description</MudText>
|
||||
</MudCardContent>
|
||||
<MudCardActions>
|
||||
<MudButton OnClick="@(() => SelectConfig(config))">Select</MudButton>
|
||||
</MudCardActions>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
State.OnStateChanged += StateChanged;
|
||||
await State.LoadConfigsAsync();
|
||||
}
|
||||
|
||||
private async Task SelectConfig(ConfigFileMetadataModel config)
|
||||
{
|
||||
await State.SelectConfigAsync(config);
|
||||
Snackbar.Add($"Selected {config.ConfigType}", Severity.Success);
|
||||
}
|
||||
|
||||
private void StateChanged()
|
||||
{
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
State.OnStateChanged -= StateChanged;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Ví dụ 2: Custom Variable Editor
|
||||
|
||||
```razor
|
||||
@inject ConfigManagerState State
|
||||
|
||||
<MudTable Items="@State.SelectedConfig?.Variables" Hover="true">
|
||||
<HeaderContent>
|
||||
<MudTh>Name</MudTh>
|
||||
<MudTh>Type</MudTh>
|
||||
<MudTh>Value</MudTh>
|
||||
<MudTh>Actions</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd>@context.Name</MudTd>
|
||||
<MudTd>@context.Type</MudTd>
|
||||
<MudTd>
|
||||
<VariableEditor
|
||||
Variable="@context"
|
||||
OnValueChanged="@(EventCallback.Factory.Create<object?>(this, value => OnVariableChanged(context.Name, value)))" />
|
||||
</MudTd>
|
||||
<MudTd>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||
OnClick="@(() => DeleteVariable(context.Name))" />
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
|
||||
@code {
|
||||
private async Task OnVariableChanged(string name, object? value)
|
||||
{
|
||||
await State.UpdateVariableAsync(name, value);
|
||||
}
|
||||
|
||||
private async Task DeleteVariable(string name)
|
||||
{
|
||||
await State.RemoveVariableAsync(name);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Ví dụ 3: Import Config với Custom Logic
|
||||
|
||||
```razor
|
||||
@inject IDialogService DialogService
|
||||
@inject ConfigManagerState State
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<MudButton OnClick="ImportConfig">Import Config</MudButton>
|
||||
|
||||
@code {
|
||||
private async Task ImportConfig()
|
||||
{
|
||||
var parameters = new DialogParameters();
|
||||
var options = new DialogOptions
|
||||
{
|
||||
CloseOnEscapeKey = true,
|
||||
MaxWidth = MaxWidth.Medium,
|
||||
FullWidth = true
|
||||
};
|
||||
|
||||
var dialog = await DialogService.ShowAsync<ImportConfigDialog>("Import Config", parameters, options);
|
||||
var result = await dialog.Result;
|
||||
|
||||
if (!result.Canceled)
|
||||
{
|
||||
Snackbar.Add("Config imported successfully", Severity.Success);
|
||||
await State.LoadConfigsAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🎨 Customization
|
||||
|
||||
### Thay đổi Theme
|
||||
|
||||
Component sử dụng MudBlazor theme. Để custom theme:
|
||||
|
||||
```csharp
|
||||
builder.Services.AddMudServices(config =>
|
||||
{
|
||||
config.SnackbarConfiguration.PositionClass = Defaults.Classes.Position.BottomRight;
|
||||
config.SnackbarConfiguration.VisibleStateDuration = 3000;
|
||||
});
|
||||
```
|
||||
|
||||
### Custom Styling
|
||||
|
||||
Thêm CSS vào `wwwroot/css/app.css`:
|
||||
|
||||
```css
|
||||
.config-manager {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.config-list-item {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.config-list-item:hover {
|
||||
background-color: var(--mud-palette-action-hover);
|
||||
}
|
||||
```
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
|
||||
### Component không hiển thị
|
||||
|
||||
**Kiểm tra:**
|
||||
1. Đã đăng ký services trong `Program.cs`
|
||||
2. Đã thêm using trong `_Imports.razor`
|
||||
3. Đã có MudBlazor services
|
||||
|
||||
### API calls fail
|
||||
|
||||
**Kiểm tra:**
|
||||
1. HttpClient có `BaseAddress` đúng
|
||||
2. Backend API đang chạy
|
||||
3. CORS đã được cấu hình (nếu frontend và backend khác domain)
|
||||
|
||||
### Export không hoạt động
|
||||
|
||||
**Kiểm tra:**
|
||||
1. Đã copy `downloadFile.js` vào `wwwroot/js/`
|
||||
2. Đã thêm script tag vào `index.html`
|
||||
|
||||
### State không update
|
||||
|
||||
**Kiểm tra:**
|
||||
1. Đã subscribe `OnStateChanged` event
|
||||
2. Đã gọi `StateHasChanged()` trong event handler
|
||||
3. Component implement `IDisposable` và unsubscribe khi dispose
|
||||
|
||||
## 📚 Tài liệu tham khảo
|
||||
|
||||
- [Backend README](../RobotNet10.CustomConfiguration/README.md)
|
||||
- [MudBlazor Documentation](https://mudblazor.com/)
|
||||
- [Blazor Documentation](https://learn.microsoft.com/aspnet/core/blazor/)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Razor">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
<SupportedPlatform Include="browser" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="10.0.3" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Components\" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\RobotNet10.Components\RobotNet10.Components.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,232 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using RobotNet10.CustomConfigurationEditor.Models;
|
||||
|
||||
namespace RobotNet10.CustomConfigurationEditor.Services.API;
|
||||
|
||||
/// <summary>
|
||||
/// Service cho giao tiếp với Config REST API
|
||||
/// </summary>
|
||||
public class ConfigApiService(HttpClient httpClient)
|
||||
{
|
||||
private readonly HttpClient _httpClient = httpClient;
|
||||
private readonly string? _baseUrl = httpClient.BaseAddress?.AbsoluteUri;
|
||||
private const string ApiPath = "api/configs";
|
||||
|
||||
// ==========================================
|
||||
// CONFIG FILE MANAGEMENT
|
||||
// ==========================================
|
||||
|
||||
/// <summary>
|
||||
/// Lấy tất cả configs (metadata only)
|
||||
/// </summary>
|
||||
public async Task<List<ConfigFileMetadataModel>> GetAllConfigsAsync(string? search = null)
|
||||
{
|
||||
var url = $"{_baseUrl}{ApiPath}";
|
||||
if (!string.IsNullOrEmpty(search))
|
||||
url += $"?search={Uri.EscapeDataString(search)}";
|
||||
|
||||
return await _httpClient.GetFromJsonAsync<List<ConfigFileMetadataModel>>(url) ?? [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lấy config theo ID
|
||||
/// </summary>
|
||||
public async Task<ConfigFileModel?> GetConfigByIdAsync(Guid id)
|
||||
{
|
||||
return await _httpClient.GetFromJsonAsync<ConfigFileModel>($"{_baseUrl}{ApiPath}/{id}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lấy config theo ConfigType
|
||||
/// </summary>
|
||||
public async Task<ConfigFileModel?> GetConfigByTypeAsync(string configType)
|
||||
{
|
||||
return await _httpClient.GetFromJsonAsync<ConfigFileModel>($"{_baseUrl}{ApiPath}/by-type/{Uri.EscapeDataString(configType)}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Kiểm tra ConfigType có tồn tại không
|
||||
/// </summary>
|
||||
public async Task<bool> ConfigTypeExistsAsync(string configType)
|
||||
{
|
||||
return await _httpClient.GetFromJsonAsync<bool>($"{_baseUrl}{ApiPath}/exists/{Uri.EscapeDataString(configType)}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tạo config mới
|
||||
/// </summary>
|
||||
public async Task<ConfigFileModel> CreateConfigAsync(string configType, List<ConfigVariableModel> variables, string? description = null)
|
||||
{
|
||||
var request = new
|
||||
{
|
||||
ConfigType = configType,
|
||||
Variables = variables,
|
||||
Description = description
|
||||
};
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync($"{_baseUrl}{ApiPath}", request);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorMessage = await HttpErrorHelper.GetErrorMessageAsync(response);
|
||||
throw new HttpRequestException(errorMessage);
|
||||
}
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<ConfigFileModel>()
|
||||
?? throw new Exception("Failed to create config. Invalid response from server.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cập nhật config
|
||||
/// </summary>
|
||||
public async Task<ConfigFileModel> UpdateConfigAsync(Guid id, List<ConfigVariableModel>? variables = null, string? description = null)
|
||||
{
|
||||
var request = new
|
||||
{
|
||||
Variables = variables,
|
||||
Description = description
|
||||
};
|
||||
|
||||
var response = await _httpClient.PutAsJsonAsync($"{_baseUrl}{ApiPath}/{id}", request);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorMessage = await HttpErrorHelper.GetErrorMessageAsync(response);
|
||||
throw new HttpRequestException(errorMessage);
|
||||
}
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<ConfigFileModel>()
|
||||
?? throw new Exception("Failed to update config. Invalid response from server.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Xóa config
|
||||
/// </summary>
|
||||
public async Task DeleteConfigAsync(Guid id)
|
||||
{
|
||||
var response = await _httpClient.DeleteAsync($"{_baseUrl}{ApiPath}/{id}");
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorMessage = await HttpErrorHelper.GetErrorMessageAsync(response);
|
||||
throw new HttpRequestException(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// IMPORT/EXPORT
|
||||
// ==========================================
|
||||
|
||||
/// <summary>
|
||||
/// Import config từ JSON file
|
||||
/// </summary>
|
||||
public async Task<ConfigFileModel> ImportConfigAsync(Stream fileStream, string fileName, string configType, string? description = null)
|
||||
{
|
||||
using var content = new MultipartFormDataContent();
|
||||
var streamContent = new StreamContent(fileStream);
|
||||
streamContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
|
||||
content.Add(streamContent, "file", fileName);
|
||||
content.Add(new StringContent(configType), "configType");
|
||||
if (!string.IsNullOrWhiteSpace(description))
|
||||
{
|
||||
content.Add(new StringContent(description), "description");
|
||||
}
|
||||
|
||||
var response = await _httpClient.PostAsync($"{_baseUrl}{ApiPath}/import", content);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorMessage = await HttpErrorHelper.GetErrorMessageAsync(response);
|
||||
throw new HttpRequestException(errorMessage);
|
||||
}
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<ConfigFileModel>()
|
||||
?? throw new Exception("Failed to import config. Invalid response from server.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Export config ra JSON file
|
||||
/// </summary>
|
||||
public async Task<Stream> ExportConfigAsync(Guid id)
|
||||
{
|
||||
var response = await _httpClient.GetAsync($"{_baseUrl}{ApiPath}/{id}/export");
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorMessage = await HttpErrorHelper.GetErrorMessageAsync(response);
|
||||
throw new HttpRequestException(errorMessage);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Read content as byte array first, then create memory stream
|
||||
var bytes = await response.Content.ReadAsByteArrayAsync();
|
||||
var memoryStream = new MemoryStream(bytes);
|
||||
memoryStream.Position = 0; // Reset position to beginning
|
||||
return memoryStream;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new HttpRequestException($"Failed to read export data: {HttpErrorHelper.GetErrorMessage(ex)}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// VARIABLE MANAGEMENT
|
||||
// ==========================================
|
||||
|
||||
/// <summary>
|
||||
/// Cập nhật giá trị của một variable
|
||||
/// </summary>
|
||||
public async Task<ConfigFileModel> UpdateVariableAsync(Guid configId, string variableName, object? value)
|
||||
{
|
||||
var request = new { Value = value };
|
||||
|
||||
var response = await _httpClient.PutAsJsonAsync($"{_baseUrl}{ApiPath}/{configId}/variables/{Uri.EscapeDataString(variableName)}", request);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorMessage = await HttpErrorHelper.GetErrorMessageAsync(response);
|
||||
throw new HttpRequestException(errorMessage);
|
||||
}
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<ConfigFileModel>()
|
||||
?? throw new Exception("Failed to update variable. Invalid response from server.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Thêm variable mới vào config
|
||||
/// </summary>
|
||||
public async Task<ConfigFileModel> AddVariableAsync(Guid configId, ConfigVariableModel variable)
|
||||
{
|
||||
var response = await _httpClient.PostAsJsonAsync($"{_baseUrl}{ApiPath}/{configId}/variables", variable);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorMessage = await HttpErrorHelper.GetErrorMessageAsync(response);
|
||||
throw new HttpRequestException(errorMessage);
|
||||
}
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<ConfigFileModel>()
|
||||
?? throw new Exception("Failed to add variable. Invalid response from server.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Xóa variable khỏi config
|
||||
/// </summary>
|
||||
public async Task<ConfigFileModel> RemoveVariableAsync(Guid configId, string variableName)
|
||||
{
|
||||
var response = await _httpClient.DeleteAsync($"{_baseUrl}{ApiPath}/{configId}/variables/{Uri.EscapeDataString(variableName)}");
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorMessage = await HttpErrorHelper.GetErrorMessageAsync(response);
|
||||
throw new HttpRequestException(errorMessage);
|
||||
}
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<ConfigFileModel>()
|
||||
?? throw new Exception("Failed to remove variable. Invalid response from server.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace RobotNet10.CustomConfigurationEditor.Services.API;
|
||||
|
||||
/// <summary>
|
||||
/// Helper class để parse error messages từ HTTP responses
|
||||
/// </summary>
|
||||
public static class HttpErrorHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Extract user-friendly error message từ HttpResponseMessage
|
||||
/// </summary>
|
||||
public static async Task<string> GetErrorMessageAsync(HttpResponseMessage response)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Try to read error message from response body
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
// Try to parse as JSON error object
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(content);
|
||||
var root = doc.RootElement;
|
||||
|
||||
// Check for common error property names
|
||||
if (root.TryGetProperty("error", out var errorProp))
|
||||
{
|
||||
var errorMsg = errorProp.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(errorMsg))
|
||||
return errorMsg;
|
||||
}
|
||||
|
||||
if (root.TryGetProperty("message", out var messageProp))
|
||||
{
|
||||
var message = messageProp.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(message))
|
||||
return message;
|
||||
}
|
||||
|
||||
if (root.TryGetProperty("errors", out var errorsProp) && errorsProp.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var errors = errorsProp.EnumerateArray()
|
||||
.Select(e => e.GetString())
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s))
|
||||
.ToList();
|
||||
|
||||
if (errors.Count > 0)
|
||||
return string.Join("; ", errors);
|
||||
}
|
||||
|
||||
// If it's a simple string, return it
|
||||
if (root.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return root.GetString() ?? GetDefaultMessage(response.StatusCode);
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// If JSON parsing fails, check if content is a simple error message
|
||||
if (content.Length < 500) // Reasonable length for error message
|
||||
{
|
||||
return content;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or ObjectDisposedException)
|
||||
{
|
||||
// Fall through to default message
|
||||
}
|
||||
|
||||
return GetDefaultMessage(response.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extract user-friendly error message từ Exception
|
||||
/// </summary>
|
||||
public static string GetErrorMessage(Exception ex)
|
||||
{
|
||||
// Check for HttpRequestException
|
||||
if (ex is HttpRequestException httpEx)
|
||||
{
|
||||
// Try to extract meaningful message
|
||||
var message = httpEx.Message;
|
||||
|
||||
// Remove technical details
|
||||
if (message.Contains("net_http_message_not_success_statuscode"))
|
||||
{
|
||||
return "Unable to connect to server. Please check your network connection.";
|
||||
}
|
||||
|
||||
if (message.Contains("timeout"))
|
||||
{
|
||||
return "Request timeout. Please try again.";
|
||||
}
|
||||
|
||||
if (message.Contains("connection"))
|
||||
{
|
||||
return "Unable to connect to server. Please check your network connection.";
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
// Check for TaskCanceledException (often timeout)
|
||||
if (ex is TaskCanceledException)
|
||||
{
|
||||
return "Request timeout. Please try again.";
|
||||
}
|
||||
|
||||
// Return original message if it's user-friendly
|
||||
var exMessage = ex.Message;
|
||||
if (!string.IsNullOrWhiteSpace(exMessage) &&
|
||||
!exMessage.Contains("net_http") &&
|
||||
!exMessage.Contains("StatusCode") &&
|
||||
!exMessage.Contains("Bad Request") &&
|
||||
exMessage.Length < 200)
|
||||
{
|
||||
return exMessage;
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
return "An error occurred. Please try again or contact the administrator.";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get default error message based on HTTP status code
|
||||
/// </summary>
|
||||
private static string GetDefaultMessage(HttpStatusCode statusCode)
|
||||
{
|
||||
return statusCode switch
|
||||
{
|
||||
HttpStatusCode.BadRequest => "Invalid data. Please check your input.",
|
||||
HttpStatusCode.Unauthorized => "You do not have permission to perform this action.",
|
||||
HttpStatusCode.Forbidden => "You do not have access to this resource.",
|
||||
HttpStatusCode.NotFound => "The requested data was not found.",
|
||||
HttpStatusCode.Conflict => "Data already exists or conflicts with existing data.",
|
||||
HttpStatusCode.InternalServerError => "Server error. Please try again later.",
|
||||
HttpStatusCode.ServiceUnavailable => "Service is temporarily unavailable. Please try again later.",
|
||||
HttpStatusCode.GatewayTimeout => "Request timeout. Please try again.",
|
||||
_ => $"Error: {statusCode}. Please try again."
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,582 @@
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using RobotNet10.CustomConfigurationEditor.Models;
|
||||
using RobotNet10.CustomConfigurationEditor.Services.API;
|
||||
using System.Net.Http;
|
||||
using System.Security.Claims;
|
||||
using System.Threading;
|
||||
|
||||
namespace RobotNet10.CustomConfigurationEditor.Services.State;
|
||||
|
||||
/// <summary>
|
||||
/// State management cho Config Manager
|
||||
/// </summary>
|
||||
public class ConfigManagerState(ConfigApiService apiService, AuthenticationStateProvider? authStateProvider = null, string editorRole = "")
|
||||
{
|
||||
private readonly ConfigApiService _apiService = apiService;
|
||||
private readonly AuthenticationStateProvider? _authStateProvider = authStateProvider;
|
||||
private readonly SemaphoreSlim _stateLock = new(1, 1);
|
||||
|
||||
// ===== DATA =====
|
||||
public List<ConfigFileMetadataModel> Configs { get; private set; } = [];
|
||||
public ConfigFileModel? SelectedConfig { get; private set; }
|
||||
|
||||
// ===== FILTERS & SEARCH =====
|
||||
public string? SearchQuery { get; set; }
|
||||
|
||||
// ===== UI STATE =====
|
||||
public bool IsLoading { get; private set; }
|
||||
public bool IsSaving { get; private set; }
|
||||
public string? ErrorMessage { get; private set; }
|
||||
|
||||
// ===== EVENTS =====
|
||||
public event Action? OnStateChanged;
|
||||
|
||||
// ==========================================
|
||||
// PUBLIC METHODS
|
||||
// ==========================================
|
||||
|
||||
// Role Editor
|
||||
public string EditorRole { get; } = editorRole;
|
||||
|
||||
/// <summary>
|
||||
/// Load tất cả configs
|
||||
/// </summary>
|
||||
public async Task LoadConfigsAsync(string? searchQuery = null)
|
||||
{
|
||||
await _stateLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
IsLoading = true;
|
||||
SearchQuery = searchQuery;
|
||||
ErrorMessage = null;
|
||||
NotifyStateChanged();
|
||||
|
||||
try
|
||||
{
|
||||
Configs = await _apiService.GetAllConfigsAsync(searchQuery);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorMessage = HttpErrorHelper.GetErrorMessage(ex);
|
||||
Configs = [];
|
||||
}
|
||||
|
||||
IsLoading = false;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_stateLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load config theo ID
|
||||
/// </summary>
|
||||
public async Task LoadConfigByIdAsync(Guid id)
|
||||
{
|
||||
await _stateLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
IsLoading = true;
|
||||
ErrorMessage = null;
|
||||
NotifyStateChanged();
|
||||
|
||||
try
|
||||
{
|
||||
SelectedConfig = await _apiService.GetConfigByIdAsync(id);
|
||||
if (SelectedConfig == null)
|
||||
{
|
||||
ErrorMessage = "Config not found";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorMessage = HttpErrorHelper.GetErrorMessage(ex);
|
||||
SelectedConfig = null;
|
||||
}
|
||||
|
||||
IsLoading = false;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_stateLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load config theo ConfigType
|
||||
/// </summary>
|
||||
public async Task LoadConfigByTypeAsync(string configType)
|
||||
{
|
||||
await _stateLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
IsLoading = true;
|
||||
ErrorMessage = null;
|
||||
NotifyStateChanged();
|
||||
|
||||
try
|
||||
{
|
||||
SelectedConfig = await _apiService.GetConfigByTypeAsync(configType);
|
||||
if (SelectedConfig == null)
|
||||
{
|
||||
ErrorMessage = "Config not found";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorMessage = HttpErrorHelper.GetErrorMessage(ex);
|
||||
SelectedConfig = null;
|
||||
}
|
||||
|
||||
IsLoading = false;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_stateLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Select config
|
||||
/// </summary>
|
||||
public async Task SelectConfigAsync(ConfigFileMetadataModel configMetadata)
|
||||
{
|
||||
await LoadConfigByIdAsync(configMetadata.Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear error message
|
||||
/// </summary>
|
||||
public void ClearError()
|
||||
{
|
||||
ErrorMessage = null;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear selection
|
||||
/// </summary>
|
||||
public void ClearSelection()
|
||||
{
|
||||
SelectedConfig = null;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tạo config mới
|
||||
/// </summary>
|
||||
public async Task<ConfigFileModel> CreateConfigAsync(string configType, List<ConfigVariableModel> variables, string? description = null)
|
||||
{
|
||||
await _stateLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
IsSaving = true;
|
||||
ErrorMessage = null;
|
||||
NotifyStateChanged();
|
||||
|
||||
try
|
||||
{
|
||||
var config = await _apiService.CreateConfigAsync(configType, variables, description);
|
||||
await ReloadConfigsInternalAsync();
|
||||
SelectedConfig = config;
|
||||
NotifyStateChanged();
|
||||
return config;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorMessage = HttpErrorHelper.GetErrorMessage(ex);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsSaving = false;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_stateLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cập nhật config
|
||||
/// </summary>
|
||||
public async Task UpdateConfigAsync(List<ConfigVariableModel>? variables = null, string? description = null)
|
||||
{
|
||||
if (SelectedConfig == null)
|
||||
{
|
||||
throw new InvalidOperationException("No config selected");
|
||||
}
|
||||
|
||||
await _stateLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
IsSaving = true;
|
||||
ErrorMessage = null;
|
||||
NotifyStateChanged();
|
||||
|
||||
try
|
||||
{
|
||||
SelectedConfig = await _apiService.UpdateConfigAsync(SelectedConfig.Id, variables, description);
|
||||
await ReloadConfigsInternalAsync();
|
||||
NotifyStateChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorMessage = HttpErrorHelper.GetErrorMessage(ex);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsSaving = false;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_stateLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Xóa config
|
||||
/// </summary>
|
||||
public async Task DeleteConfigAsync(Guid id)
|
||||
{
|
||||
await _stateLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
IsSaving = true;
|
||||
ErrorMessage = null;
|
||||
NotifyStateChanged();
|
||||
|
||||
try
|
||||
{
|
||||
await _apiService.DeleteConfigAsync(id);
|
||||
await ReloadConfigsInternalAsync();
|
||||
|
||||
// Clear selection if deleted
|
||||
if (SelectedConfig?.Id == id)
|
||||
{
|
||||
SelectedConfig = null;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorMessage = HttpErrorHelper.GetErrorMessage(ex);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsSaving = false;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_stateLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Import config từ file
|
||||
/// </summary>
|
||||
public async Task<ConfigFileModel> ImportConfigAsync(Stream fileStream, string fileName, string configType, string? description = null)
|
||||
{
|
||||
await _stateLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
IsSaving = true;
|
||||
ErrorMessage = null;
|
||||
NotifyStateChanged();
|
||||
|
||||
try
|
||||
{
|
||||
var config = await _apiService.ImportConfigAsync(fileStream, fileName, configType, description);
|
||||
await ReloadConfigsInternalAsync();
|
||||
SelectedConfig = config;
|
||||
NotifyStateChanged();
|
||||
return config;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorMessage = HttpErrorHelper.GetErrorMessage(ex);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsSaving = false;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_stateLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Export config ra file
|
||||
/// </summary>
|
||||
public async Task<Stream> ExportConfigAsync(Guid id)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _apiService.ExportConfigAsync(id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorMessage = HttpErrorHelper.GetErrorMessage(ex);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cập nhật variable value
|
||||
/// </summary>
|
||||
public async Task UpdateVariableAsync(string variableName, object? value)
|
||||
{
|
||||
if (SelectedConfig == null)
|
||||
{
|
||||
throw new InvalidOperationException("No config selected");
|
||||
}
|
||||
|
||||
await _stateLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
IsSaving = true;
|
||||
ErrorMessage = null;
|
||||
NotifyStateChanged();
|
||||
|
||||
try
|
||||
{
|
||||
SelectedConfig = await _apiService.UpdateVariableAsync(SelectedConfig.Id, variableName, value);
|
||||
NotifyStateChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorMessage = HttpErrorHelper.GetErrorMessage(ex);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsSaving = false;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_stateLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Thêm variable
|
||||
/// </summary>
|
||||
public async Task AddVariableAsync(ConfigVariableModel variable)
|
||||
{
|
||||
if (SelectedConfig == null)
|
||||
{
|
||||
throw new InvalidOperationException("No config selected");
|
||||
}
|
||||
|
||||
await _stateLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
IsSaving = true;
|
||||
ErrorMessage = null;
|
||||
NotifyStateChanged();
|
||||
|
||||
try
|
||||
{
|
||||
SelectedConfig = await _apiService.AddVariableAsync(SelectedConfig.Id, variable);
|
||||
NotifyStateChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorMessage = HttpErrorHelper.GetErrorMessage(ex);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsSaving = false;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_stateLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Xóa variable
|
||||
/// </summary>
|
||||
public async Task RemoveVariableAsync(string variableName)
|
||||
{
|
||||
if (SelectedConfig == null)
|
||||
{
|
||||
throw new InvalidOperationException("No config selected");
|
||||
}
|
||||
|
||||
await _stateLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
IsSaving = true;
|
||||
ErrorMessage = null;
|
||||
NotifyStateChanged();
|
||||
|
||||
try
|
||||
{
|
||||
SelectedConfig = await _apiService.RemoveVariableAsync(SelectedConfig.Id, variableName);
|
||||
NotifyStateChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorMessage = HttpErrorHelper.GetErrorMessage(ex);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsSaving = false;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_stateLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Kiểm tra ConfigType có tồn tại không
|
||||
/// </summary>
|
||||
public async Task<bool> ConfigTypeExistsAsync(string configType)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _apiService.ConfigTypeExistsAsync(configType);
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// ROLE-BASED PERMISSION CHECKS
|
||||
// ==========================================
|
||||
|
||||
/// <summary>
|
||||
/// Kiểm tra user hiện tại có quyền chỉnh sửa config không
|
||||
/// </summary>
|
||||
public async Task<bool> CanEditConfigAsync()
|
||||
{
|
||||
// Nếu EditorRole rỗng, mọi thứ hoạt động bình thường
|
||||
if (string.IsNullOrWhiteSpace(EditorRole))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Kiểm tra role của user hiện tại
|
||||
var userRoles = await GetCurrentUserRolesAsync();
|
||||
return userRoles.Contains(EditorRole, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Kiểm tra user hiện tại có quyền chỉnh sửa variable không
|
||||
/// </summary>
|
||||
public async Task<bool> CanEditVariableAsync(ConfigVariableModel variable)
|
||||
{
|
||||
// Nếu EditorRole rỗng, mọi thứ hoạt động bình thường
|
||||
if (string.IsNullOrWhiteSpace(EditorRole))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var userRoles = await GetCurrentUserRolesAsync();
|
||||
|
||||
// Kiểm tra nếu user có role = EditorRole
|
||||
if (userRoles.Contains(EditorRole, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Kiểm tra nếu role của user nằm trong Roles của variable
|
||||
if (!string.IsNullOrWhiteSpace(variable.Roles))
|
||||
{
|
||||
var variableRoles = variable.Roles.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(r => r.Trim())
|
||||
.Where(r => !string.IsNullOrWhiteSpace(r));
|
||||
|
||||
return variableRoles.Any(role => userRoles.Contains(role, StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lấy danh sách roles của user hiện tại
|
||||
/// </summary>
|
||||
private async Task<List<string>> GetCurrentUserRolesAsync()
|
||||
{
|
||||
if (_authStateProvider == null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var authState = await _authStateProvider.GetAuthenticationStateAsync();
|
||||
var user = authState?.User;
|
||||
|
||||
if (user == null || user.Identity?.IsAuthenticated != true)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
// Lấy roles từ claims
|
||||
var roles = user.Claims
|
||||
.Where(c => c.Type == ClaimTypes.Role)
|
||||
.Select(c => c.Value)
|
||||
.ToList();
|
||||
|
||||
return roles;
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidOperationException or NullReferenceException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// PRIVATE METHODS
|
||||
// ==========================================
|
||||
|
||||
/// <summary>
|
||||
/// Reload configs without acquiring the lock (for use inside locked methods)
|
||||
/// </summary>
|
||||
private async Task ReloadConfigsInternalAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
Configs = await _apiService.GetAllConfigsAsync(SearchQuery);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorMessage = HttpErrorHelper.GetErrorMessage(ex);
|
||||
Configs = [];
|
||||
}
|
||||
}
|
||||
|
||||
private void NotifyStateChanged()
|
||||
{
|
||||
OnStateChanged?.Invoke();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.Extensions.Configuration
|
||||
@using Microsoft.Extensions.DependencyInjection
|
||||
@using Microsoft.JSInterop
|
||||
@using MudBlazor
|
||||
@using RobotNet10.CustomConfigurationEditor.Models
|
||||
@using RobotNet10.CustomConfigurationEditor.Services.API
|
||||
@using RobotNet10.CustomConfigurationEditor.Services.State
|
||||
@using RobotNet10.CustomConfigurationEditor.Components.ConfigManager
|
||||
@using RobotNet10.CustomConfigurationEditor.Components.ConfigManager.Dialogs
|
||||
@@ -0,0 +1,38 @@
|
||||
// Function to download file from stream
|
||||
window.downloadFileFromStream = async (fileName, contentStreamReference) => {
|
||||
const arrayBuffer = await contentStreamReference.arrayBuffer();
|
||||
const blob = new Blob([arrayBuffer]);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchorElement = document.createElement('a');
|
||||
anchorElement.href = url;
|
||||
anchorElement.download = fileName ?? '';
|
||||
anchorElement.click();
|
||||
anchorElement.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// Function to download file directly from URL
|
||||
window.downloadFileFromUrl = async (url, fileName) => {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`HTTP error! status: ${response.status}, message: ${errorText}`);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const anchorElement = document.createElement('a');
|
||||
anchorElement.href = blobUrl;
|
||||
anchorElement.download = fileName ?? '';
|
||||
document.body.appendChild(anchorElement);
|
||||
anchorElement.click();
|
||||
document.body.removeChild(anchorElement);
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
} catch (error) {
|
||||
console.error('Error downloading file:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user