Initial commit

This commit is contained in:
2026-07-13 09:25:40 +07:00
parent c08ff54676
commit bccfb156d7
1938 changed files with 641646 additions and 0 deletions

View File

@@ -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);
}
}
}

View File

@@ -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; }
}

View File

@@ -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();
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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
};
}
}

View File

@@ -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);
}
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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
}

View File

@@ -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/)

View File

@@ -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>

View File

@@ -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.");
}
}

View File

@@ -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."
};
}
}

View File

@@ -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();
}
}

View File

@@ -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

View File

@@ -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;
}
};