Initial commit

This commit is contained in:
2026-07-03 16:31:37 +07:00
commit 899c7c637d
1939 changed files with 641750 additions and 0 deletions

View File

@@ -0,0 +1,69 @@
@using RobotNet.VDA5050.State
@using RobotNet.VDA5050.Type
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.h6" Class="mb-3">Action States</MudText>
@if (State?.ActionStates != null && State.ActionStates.Length > 0)
{
<MudTable Items="@State.ActionStates" T="ActionState" Hover="true" Dense="true" Height="400px" FixedHeader="true" Elevation="0">
<HeaderContent>
<MudTh>Action ID</MudTh>
<MudTh>Action Type</MudTh>
<MudTh>Status</MudTh>
<MudTh>Description</MudTh>
<MudTh>Result</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Action ID">
<MudText Typo="Typo.body2" Style="max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
@context.ActionId
</MudText>
</MudTd>
<MudTd DataLabel="Action Type">@(context.ActionType ?? "N/A")</MudTd>
<MudTd DataLabel="Status">
<MudChip T="string"
Size="Size.Small"
Color="@GetActionStatusColor(context.ActionStatus)">
@context.ActionStatus
</MudChip>
</MudTd>
<MudTd DataLabel="Description">
<MudText Typo="Typo.body2" Style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
@(context.ActionDescription ?? "N/A")
</MudText>
</MudTd>
<MudTd DataLabel="Result">
<MudText Typo="Typo.body2" Style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
@(context.ResultDescription ?? "N/A")
</MudText>
</MudTd>
</RowTemplate>
</MudTable>
}
else
{
<MudText Typo="Typo.body2" Align="Align.Center">No action states available</MudText>
}
</MudPaper>
@code {
public StateMsg? State { get; set; }
private Color GetActionStatusColor(ActionStatus status)
{
return status switch
{
ActionStatus.WAITING => Color.Default,
ActionStatus.RUNNING => Color.Info,
ActionStatus.FINISHED => Color.Success,
ActionStatus.FAILED => Color.Error,
_ => Color.Default
};
}
public void Update(StateMsg? state)
{
State = state;
StateHasChanged();
}
}

View File

@@ -0,0 +1,75 @@
@using RobotNet.VDA5050.State
<MudPaper Class="pa-4" Elevation="2">
@if (ShowNameCard)
{
<MudText Typo="Typo.h6" Class="mb-3">Battery State</MudText>
}
@if (BatteryState != null)
{
<MudSimpleTable Elevation="0">
<tbody>
<tr>
<td><strong>Charge:</strong></td>
<td>
<MudProgressLinear Value="@BatteryState.BatteryCharge"
Color="@GetBatteryColor(BatteryState.BatteryCharge)" />
@BatteryState.BatteryCharge.ToString("F1")%
</td>
</tr>
<tr>
<td><strong>Voltage:</strong></td>
<td>@BatteryState.BatteryVoltage?.ToString("F2") V</td>
</tr>
<tr>
<td><strong>Health:</strong></td>
<td>@BatteryState.BatteryHealth.ToString("F1")%</td>
</tr>
<tr>
<td><strong>Charging:</strong></td>
<td>
@if (BatteryState.Charging)
{
<MudChip T="string" Size="Size.Small" Color="Color.Info">Yes</MudChip>
}
else
{
<MudChip T="string" Size="Size.Small" Color="Color.Default">No</MudChip>
}
</td>
</tr>
@if (BatteryState.Reach > 0)
{
<tr>
<td><strong>Reach:</strong></td>
<td>@BatteryState.Reach?.ToString("F0") m</td>
</tr>
}
</tbody>
</MudSimpleTable>
}
else
{
<MudText Typo="Typo.body2" Align="Align.Center">No battery data available</MudText>
}
</MudPaper>
@code {
[Parameter]
public bool ShowNameCard { get; set; } = true;
public BatteryState? BatteryState { get; set; }
private Color GetBatteryColor(double charge)
{
if (charge > 50) return Color.Success;
if (charge > 20) return Color.Warning;
return Color.Error;
}
public void Update(BatteryState? batteryState)
{
BatteryState = batteryState;
StateHasChanged();
}
}

View File

@@ -0,0 +1,60 @@
@using RobotNet.VDA5050.State
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.h6" Class="mb-3">Edge States</MudText>
@if (State?.EdgeStates != null && State.EdgeStates.Length > 0)
{
<MudTable Items="@State.EdgeStates" T="EdgeState" Hover="true" Dense="true" Height="400px" FixedHeader="true" Elevation="0">
<HeaderContent>
<MudTh>Edge ID</MudTh>
<MudTh>Sequence ID</MudTh>
<MudTh>Released</MudTh>
<MudTh>Description</MudTh>
<MudTh>Trajectory</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Edge ID">@context.EdgeId</MudTd>
<MudTd DataLabel="Sequence ID">@context.SequenceId</MudTd>
<MudTd DataLabel="Released">
@if (context.Released)
{
<MudChip T="string" Size="Size.Small" Color="Color.Success">Yes</MudChip>
}
else
{
<MudChip T="string" Size="Size.Small" Color="Color.Warning">No</MudChip>
}
</MudTd>
<MudTd DataLabel="Description">
<MudText Typo="Typo.body2" Style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
@(context.EdgeDescription ?? "N/A")
</MudText>
</MudTd>
<MudTd DataLabel="Trajectory">
@if (context.Trajectory != null)
{
<MudChip T="string" Size="Size.Small" Color="Color.Info">Available</MudChip>
}
else
{
<MudText Typo="Typo.body2">N/A</MudText>
}
</MudTd>
</RowTemplate>
</MudTable>
}
else
{
<MudText Typo="Typo.body2" Align="Align.Center">No edge states available</MudText>
}
</MudPaper>
@code {
public StateMsg? State { get; set; }
public void Update(StateMsg? state)
{
State = state;
StateHasChanged();
}
}

View File

@@ -0,0 +1,75 @@
@using RobotNet.VDA5050.State
@using RobotNet.VDA5050.Type
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.h6" Class="mb-3">Errors</MudText>
@if (Errors != null && Errors.Length > 0)
{
<MudTable Items="@Errors" T="Error" Hover="true" Dense="true" Height="400px" FixedHeader="true" Elevation="0">
<HeaderContent>
<MudTh>Error Type</MudTh>
<MudTh>Level</MudTh>
<MudTh>Description</MudTh>
<MudTh>Hint</MudTh>
<MudTh>References</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Error Type">@context.ErrorType</MudTd>
<MudTd DataLabel="Level">
<MudChip T="string"
Size="Size.Small"
Color="@GetErrorLevelColor(context.ErrorLevel)">
@context.ErrorLevel
</MudChip>
</MudTd>
<MudTd DataLabel="Description">
<MudText Typo="Typo.body2" Style="max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
@(context.ErrorDescription ?? "N/A")
</MudText>
</MudTd>
<MudTd DataLabel="Hint">
<MudText Typo="Typo.body2" Style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
@(context.ErrorHint ?? "N/A")
</MudText>
</MudTd>
<MudTd DataLabel="References">
@if (context.ErrorReferences != null && context.ErrorReferences.Length > 0)
{
<MudChip T="string" Size="Size.Small" Color="Color.Info">
@context.ErrorReferences.Length reference(s)
</MudChip>
}
else
{
<MudText Typo="Typo.body2">N/A</MudText>
}
</MudTd>
</RowTemplate>
</MudTable>
}
else
{
<MudText Typo="Typo.body2" Align="Align.Center">No errors</MudText>
}
</MudPaper>
@code {
private Error[]? Errors { get; set; }
private Color GetErrorLevelColor(ErrorLevel level)
{
return level switch
{
ErrorLevel.NONE => Color.Success,
ErrorLevel.WARNING => Color.Warning,
ErrorLevel.FATAL => Color.Error,
_ => Color.Default
};
}
public void Update(Error[]? errors)
{
Errors = errors;
StateHasChanged();
}
}

View File

@@ -0,0 +1,46 @@
@using RobotNet.VDA5050.State
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.h6" Class="mb-3">Header Information</MudText>
@if (State != null)
{
<MudSimpleTable Elevation="0">
<tbody>
<tr>
<td><strong>Header ID:</strong></td>
<td>@State.HeaderId</td>
</tr>
<tr>
<td><strong>Timestamp:</strong></td>
<td>@State.Timestamp.ToString("g")</td>
</tr>
<tr>
<td><strong>Version:</strong></td>
<td>@State.Version</td>
</tr>
<tr>
<td><strong>Manufacturer:</strong></td>
<td>@State.Manufacturer</td>
</tr>
<tr>
<td><strong>Serial Number:</strong></td>
<td>@State.SerialNumber</td>
</tr>
</tbody>
</MudSimpleTable>
}
else
{
<MudText Typo="Typo.body2" Align="Align.Center">No state data available</MudText>
}
</MudPaper>
@code {
public StateMsg? State { get; set; }
public void Update(StateMsg? state)
{
State = state;
StateHasChanged();
}
}

View File

@@ -0,0 +1,68 @@
@using RobotNet.VDA5050.State
@using RobotNet.VDA5050.Type
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.h6" Class="mb-3">Information</MudText>
@if (Info != null && Info.Length > 0)
{
<MudTable Items="@Info" T="Information" Hover="true" Dense="true" Height="400px" FixedHeader="true" Elevation="0">
<HeaderContent>
<MudTh>Info Type</MudTh>
<MudTh>Level</MudTh>
<MudTh>Description</MudTh>
<MudTh>References</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Info Type">@context.InfoType</MudTd>
<MudTd DataLabel="Level">
<MudChip T="string"
Size="Size.Small"
Color="@GetInfoLevelColor(context.InfoLevel)">
@context.InfoLevel
</MudChip>
</MudTd>
<MudTd DataLabel="Description">
<MudText Typo="Typo.body2" Style="max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
@(context.InfoDescription ?? "N/A")
</MudText>
</MudTd>
<MudTd DataLabel="References">
@if (context.InfoReferences != null && context.InfoReferences.Length > 0)
{
<MudChip T="string" Size="Size.Small" Color="Color.Info">
@context.InfoReferences.Length reference(s)
</MudChip>
}
else
{
<MudText Typo="Typo.body2">N/A</MudText>
}
</MudTd>
</RowTemplate>
</MudTable>
}
else
{
<MudText Typo="Typo.body2" Align="Align.Center">No information available</MudText>
}
</MudPaper>
@code {
private Information[]? Info { get; set; }
private Color GetInfoLevelColor(InfoLevel level)
{
return level switch
{
InfoLevel.INFO => Color.Info,
InfoLevel.DEBUG => Color.Default,
_ => Color.Default
};
}
public void Update(Information[]? info)
{
Info = info;
StateHasChanged();
}
}

View File

@@ -0,0 +1,58 @@
@using RobotNet.VDA5050.State
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.h6" Class="mb-3">Loads</MudText>
@if (State?.Loads != null && State.Loads.Length > 0)
{
<MudTable Items="@State.Loads" T="Load" Hover="true" Dense="true" Height="400px" FixedHeader="true" Elevation="0">
<HeaderContent>
<MudTh>Load ID</MudTh>
<MudTh>Load Type</MudTh>
<MudTh>Position</MudTh>
<MudTh>Weight</MudTh>
<MudTh>Dimensions</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Load ID">
<MudText Typo="Typo.body2" Style="max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
@(context.LoadId ?? "N/A")
</MudText>
</MudTd>
<MudTd DataLabel="Load Type">@(context.LoadType ?? "N/A")</MudTd>
<MudTd DataLabel="Position">@(context.LoadPosition ?? "N/A")</MudTd>
<MudTd DataLabel="Weight">@(context.Weight > 0 ? context.Weight?.ToString("F2") + " kg" : "N/A")</MudTd>
<MudTd DataLabel="Dimensions">
@if (context.LoadDimensions != null)
{
<MudText Typo="Typo.body2">
@($"{context.LoadDimensions.Length:F2} × {context.LoadDimensions.Width:F2}")
@if (context.LoadDimensions.Height > 0)
{
<text> × @context.LoadDimensions.Height.ToString("F2")</text>
}
<text> m</text>
</MudText>
}
else
{
<MudText Typo="Typo.body2">N/A</MudText>
}
</MudTd>
</RowTemplate>
</MudTable>
}
else
{
<MudText Typo="Typo.body2" Align="Align.Center">No loads available</MudText>
}
</MudPaper>
@code {
public StateMsg? State { get; set; }
public void Update(StateMsg? state)
{
State = state;
StateHasChanged();
}
}

View File

@@ -0,0 +1,216 @@
@using RobotNet.VDA5050
@using RobotNet.VDA5050.Type
@using System.Net.Http.Json
@using RobotNet10.FleetManager.Shared.Models
@using RobotNet10.Shared
@inject HttpClient HttpClient
@inject ISnackbar Snackbar
<MudPaper Class="pa-4" Elevation="0">
<MudText Typo="Typo.h6" Class="mb-4">Manual Actions</MudText>
<MudStack Spacing="3">
<!-- Action Type Selector -->
<MudSelect @bind-Value="selectedActionType"
Label="Action Type"
Variant="Variant.Outlined"
T="ActionType">
@foreach (ActionType actionType in Enum.GetValues<ActionType>())
{
<MudSelectItem Value="@actionType">@actionType.ToString()</MudSelectItem>
}
</MudSelect>
<!-- Blocking Type Selector -->
<MudSelect @bind-Value="selectedBlockingType"
Label="Blocking Type"
Variant="Variant.Outlined"
T="BlockingType">
@foreach (BlockingType blockingType in Enum.GetValues<BlockingType>())
{
<MudSelectItem Value="@blockingType">@blockingType.ToString()</MudSelectItem>
}
</MudSelect>
<!-- Action Parameters -->
<MudText Typo="Typo.subtitle2">Action Parameters</MudText>
@foreach (var param in actionParameters)
{
<MudGrid>
<MudItem xs="5">
<MudTextField @bind-Value="param.Key"
Label="Key"
Variant="Variant.Outlined"
Margin="Margin.Dense" />
</MudItem>
<MudItem xs="5">
<MudTextField @bind-Value="param.Value"
Label="Value"
Variant="Variant.Outlined"
Margin="Margin.Dense" />
</MudItem>
<MudItem xs="2">
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Color="Color.Error"
Size="Size.Small"
OnClick="@(() => RemoveParameter(param))" />
</MudItem>
</MudGrid>
}
<MudButton StartIcon="@Icons.Material.Filled.Add"
Variant="Variant.Outlined"
Color="Color.Primary"
OnClick="AddParameter">
Add Parameter
</MudButton>
<!-- Send Action Button -->
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Send"
OnClick="SendAction"
Disabled="@isSending"
FullWidth="true">
@if (isSending)
{
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
<span>Sending...</span>
}
else
{
<span>Send Action</span>
}
</MudButton>
<!-- Action History -->
<MudDivider />
<MudText Typo="Typo.subtitle2">Recent Actions</MudText>
@if (actionHistory.Count > 0)
{
<MudStack Spacing="1" Style="height: 200px; overflow-y: auto">
@foreach (var action in actionHistory.Take(10))
{
<MudPaper Class="pa-2" Elevation="0">
<MudText Typo="Typo.body2">
<strong>@action.ActionType</strong> - @action.Timestamp.ToString("g")
</MudText>
</MudPaper>
}
</MudStack>
}
else
{
<MudText Typo="Typo.body2" Align="Align.Center">No actions sent yet</MudText>
}
</MudStack>
</MudPaper>
@code {
[Parameter]
public string RobotId { get; set; } = string.Empty;
private ActionType selectedActionType = ActionType.STATE_REQUEST;
private BlockingType selectedBlockingType = BlockingType.HARD;
private List<RobotNet.VDA5050.InstantAction.ActionParameter> actionParameters = new();
private List<SentAction> actionHistory = new();
private bool isSending = false;
private class SentAction
{
public ActionType ActionType { get; set; }
public DateTime Timestamp { get; set; }
}
private void AddParameter()
{
actionParameters.Add(new RobotNet.VDA5050.InstantAction.ActionParameter());
}
private void RemoveParameter(RobotNet.VDA5050.InstantAction.ActionParameter param)
{
actionParameters.Remove(param);
}
private async Task SendAction()
{
if (string.IsNullOrWhiteSpace(RobotId))
{
Snackbar.Add("Robot ID is required", Severity.Warning);
return;
}
isSending = true;
StateHasChanged();
try
{
// Build action request
var actionRequest = new RobotInstantActionModel
{
RobotId = RobotId,
Action = new RobotNet.VDA5050.InstantAction.Action
{
ActionId = Guid.NewGuid().ToString(),
BlockingType = selectedBlockingType,
ActionType = selectedActionType.ToJsonString(),
ActionParameters = [.. actionParameters.Where(p => !string.IsNullOrWhiteSpace(p.Key))],
}
};
// Call API endpoint (TODO: Create this endpoint in Phase 10)
var response = await HttpClient.PostAsJsonAsync($"/api/robotManager/InstantActions", actionRequest);
if (response.IsSuccessStatusCode)
{
var result = await response.Content.ReadFromJsonAsync<MessageResult>();
if(result is null)
{
Snackbar.Add($"Sending action is failed", Severity.Warning);
}
else if (!result.IsSuccess)
{
Snackbar.Add($"{result.Message}", Severity.Warning);
}
else
{
Snackbar.Add($"Action '{selectedActionType}' sent successfully", Severity.Success);
// Add to history
actionHistory.Insert(0, new SentAction
{
ActionType = selectedActionType,
Timestamp = DateTime.UtcNow
});
// Clear parameters
actionParameters.Clear();
}
}
else if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
Snackbar.Add($"Robot with ID '{RobotId}' not found", Severity.Error);
}
else if (response.StatusCode == System.Net.HttpStatusCode.BadRequest)
{
var error = await response.Content.ReadAsStringAsync();
Snackbar.Add($"Invalid action request: {error}", Severity.Warning);
}
else
{
var error = await response.Content.ReadAsStringAsync();
Snackbar.Add($"Error sending action: {error}", Severity.Error);
}
}
catch (Exception ex)
{
Snackbar.Add($"Error sending action: {ex.Message}", Severity.Error);
}
finally
{
isSending = false;
StateHasChanged();
}
}
}

View File

@@ -0,0 +1,401 @@
@using RobotNet10.FleetManager.Shared.DTOs.Robot
@using RobotNet10.FleetManager.Client.Services
@using RobotNet10.MapEditor.Services.API
@using RobotNet10.MapEditor.Shared.DTOs.LayoutData
@using RobotNet10.MapEditor.Shared.DTOs.Node
@using RobotNet10.FleetManager.Shared.Models
@using RobotNet10.Shared
@using System.Net.Http.Json
@inject HttpClient HttpClient
@inject ISnackbar Snackbar
@inject RobotApiService RobotApiService
@inject MapManagerApiService MapApiService
<MudPaper Class="pa-4" Elevation="0">
<MudText Typo="Typo.h6" Class="mb-4">Manual Order</MudText>
<MudStack Spacing="3">
<!-- Node Selector -->
<MudSelect @bind-Value="selectedNodeName"
Label="Select Node"
Variant="Variant.Outlined"
T="string"
Disabled="@isLoadingNodes"
ErrorText="@GetValidationError("NodeName")">
@if (isLoadingNodes)
{
<MudSelectItem Value="@(string.Empty)" Disabled="true">Loading nodes...</MudSelectItem>
}
else if (nodes != null && nodes.Any())
{
<MudSelectItem Value="@(string.Empty)">-- Select Node --</MudSelectItem>
@foreach (var node in nodes.Where(n => !string.IsNullOrEmpty(n.NodeName)).OrderBy(n => n.NodeName))
{
var displayName = !string.IsNullOrWhiteSpace(node.NodeName)
? $"{node.NodeName} ({node.NodeId})"
: node.NodeId;
<MudSelectItem Value="@node.NodeName">@displayName</MudSelectItem>
}
}
else
{
<MudSelectItem Value="@(string.Empty)" Disabled="true">No nodes available</MudSelectItem>
}
</MudSelect>
@if (!string.IsNullOrWhiteSpace(selectedNodeName))
{
var selectedNode = nodes?.FirstOrDefault(n =>
(!string.IsNullOrWhiteSpace(n.NodeName) && n.NodeName == selectedNodeName) ||
(string.IsNullOrWhiteSpace(n.NodeName) && n.NodeId == selectedNodeName));
@if (selectedNode != null)
{
<MudPaper Class="pa-2" Elevation="0" Style="background-color: var(--mud-palette-background-grey);">
<MudText Typo="Typo.body2">
<strong>Node ID:</strong> @selectedNode.NodeId<br />
@if (!string.IsNullOrWhiteSpace(selectedNode.NodeDescription))
{
<strong>Description:</strong> @selectedNode.NodeDescription<br />
}
<strong>Position:</strong> X: @selectedNode.X.ToString("F2")m, Y: @selectedNode.Y.ToString("F2")m
</MudText>
</MudPaper>
}
}
<!-- Action Buttons -->
<MudStack Row="true" Spacing="2">
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Send"
OnClick="SendOrder"
Disabled="@(isSending || string.IsNullOrWhiteSpace(selectedNodeName))"
FullWidth="true">
@if (isSending)
{
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
<span>Sending...</span>
}
else
{
<span>Send Order</span>
}
</MudButton>
<MudButton Variant="Variant.Filled"
Color="Color.Error"
StartIcon="@Icons.Material.Filled.Cancel"
OnClick="CancelOrder"
Disabled="@isCanceling"
FullWidth="true">
@if (isCanceling)
{
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
<span>Canceling...</span>
}
else
{
<span>Cancel Order</span>
}
</MudButton>
</MudStack>
<!-- Order History -->
<MudDivider />
<MudText Typo="Typo.subtitle2">Recent Orders</MudText>
@if (orderHistory.Count > 0)
{
<MudStack Spacing="1" Style="height: 200px; overflow: auto;">
@foreach (var order in orderHistory.Take(10))
{
<MudPaper Class="pa-2" Elevation="0">
<MudText Typo="Typo.body2">
<strong>@order.NodeName</strong> - @order.Timestamp.ToString("g")
@if (!order.IsSuccess)
{
<MudChip T="string" Size="Size.Small" Color="Color.Error" Class="ml-2">Failed</MudChip>
}
else
{
<MudChip T="string" Size="Size.Small" Color="Color.Success" Class="ml-2">Success</MudChip>
}
</MudText>
</MudPaper>
}
</MudStack>
}
else
{
<MudText Typo="Typo.body2" Align="Align.Center">No orders sent yet</MudText>
}
</MudStack>
</MudPaper>
@code {
[Parameter]
public string RobotId { get; set; } = string.Empty;
[Parameter]
public Guid? MapId { get; set; }
private string selectedNodeName = string.Empty;
private List<NodeDto> nodes = new();
private List<SentOrder> orderHistory = new();
private bool isSending = false;
private bool isCanceling = false;
private bool isLoadingNodes = false;
private Dictionary<string, string> validationErrors = new();
protected override async Task OnInitializedAsync()
{
await LoadNodesAsync();
}
private async Task LoadNodesAsync()
{
if (!MapId.HasValue || MapId.Value == Guid.Empty)
{
// Try to get MapId from robot
try
{
var robot = await RobotApiService.GetByRobotIdAsync(RobotId);
if (robot?.MapId.HasValue == true)
{
MapId = robot.MapId;
}
else
{
nodes = new List<NodeDto>();
return;
}
}
catch (Exception ex)
{
Snackbar.Add($"Error loading robot: {ex.Message}", Severity.Warning);
nodes = new List<NodeDto>();
return;
}
}
isLoadingNodes = true;
StateHasChanged();
try
{
var layoutData = await MapApiService.GetLayoutDataAsync(MapId.Value);
nodes = layoutData.Nodes?.ToList() ?? new List<NodeDto>();
}
catch (Exception ex)
{
Snackbar.Add($"Error loading nodes: {ex.Message}", Severity.Warning);
nodes = new List<NodeDto>();
}
finally
{
isLoadingNodes = false;
StateHasChanged();
}
}
private string? GetValidationError(string fieldName)
{
return validationErrors.TryGetValue(fieldName, out var error) ? error : null;
}
private async Task SendOrder()
{
if (string.IsNullOrWhiteSpace(RobotId))
{
Snackbar.Add("Robot ID is required", Severity.Warning);
return;
}
if (string.IsNullOrWhiteSpace(selectedNodeName))
{
validationErrors["NodeName"] = "Please select a node";
StateHasChanged();
return;
}
isSending = true;
validationErrors.Clear();
StateHasChanged();
try
{
// Find the selected node to get NodeName (preferred) or NodeId
var selectedNode = nodes?.FirstOrDefault(n =>
(!string.IsNullOrWhiteSpace(n.NodeName) && n.NodeName == selectedNodeName) ||
(string.IsNullOrWhiteSpace(n.NodeName) && n.NodeId == selectedNodeName));
if (selectedNode == null)
{
Snackbar.Add("Selected node not found", Severity.Warning);
return;
}
// Use NodeName if available, otherwise use NodeId
var nodeNameToSend = !string.IsNullOrWhiteSpace(selectedNode.NodeName)
? selectedNode.NodeName
: selectedNode.NodeId;
// Build order request
var orderRequest = new RobotMoveToNodeModel
{
RobotId = RobotId,
NodeName = nodeNameToSend,
LastAngle = null, // Optional, can be enhanced later
};
// Call API endpoint
var response = await HttpClient.PostAsJsonAsync($"/api/RobotManager/MoveToNode", orderRequest);
if (response.IsSuccessStatusCode)
{
var result = await response.Content.ReadFromJsonAsync<MessageResult>();
if (result is null)
{
Snackbar.Add($"Sending order failed", Severity.Warning);
orderHistory.Insert(0, new SentOrder
{
NodeName = selectedNodeName,
Timestamp = DateTime.UtcNow,
IsSuccess = false
});
}
else if (!result.IsSuccess)
{
Snackbar.Add($"{result.Message}", Severity.Warning);
orderHistory.Insert(0, new SentOrder
{
NodeName = selectedNodeName,
Timestamp = DateTime.UtcNow,
IsSuccess = false
});
}
else
{
Snackbar.Add($"Order to node '{selectedNodeName}' sent successfully", Severity.Success);
// Add to history
orderHistory.Insert(0, new SentOrder
{
NodeName = selectedNodeName,
Timestamp = DateTime.UtcNow,
IsSuccess = true
});
// Clear selection
selectedNodeName = string.Empty;
}
}
else if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
Snackbar.Add($"Robot with ID '{RobotId}' not found", Severity.Error);
orderHistory.Insert(0, new SentOrder
{
NodeName = selectedNodeName,
Timestamp = DateTime.UtcNow,
IsSuccess = false
});
}
else if (response.StatusCode == System.Net.HttpStatusCode.BadRequest)
{
var error = await response.Content.ReadAsStringAsync();
Snackbar.Add($"Invalid order request: {error}", Severity.Warning);
orderHistory.Insert(0, new SentOrder
{
NodeName = selectedNodeName,
Timestamp = DateTime.UtcNow,
IsSuccess = false
});
}
else
{
var error = await response.Content.ReadAsStringAsync();
Snackbar.Add($"Error sending order: {error}", Severity.Error);
orderHistory.Insert(0, new SentOrder
{
NodeName = selectedNodeName,
Timestamp = DateTime.UtcNow,
IsSuccess = false
});
}
}
catch (Exception ex)
{
Snackbar.Add($"Error sending order: {ex.Message}", Severity.Error);
orderHistory.Insert(0, new SentOrder
{
NodeName = selectedNodeName ?? "Unknown",
Timestamp = DateTime.UtcNow,
IsSuccess = false
});
}
finally
{
isSending = false;
StateHasChanged();
}
}
private async Task CancelOrder()
{
if (string.IsNullOrWhiteSpace(RobotId))
{
Snackbar.Add("Robot ID is required", Severity.Warning);
return;
}
isCanceling = true;
StateHasChanged();
try
{
// Call API endpoint
var response = await HttpClient.DeleteAsync($"/api/RobotManager/MoveToNode/{RobotId}");
if (response.IsSuccessStatusCode)
{
var result = await response.Content.ReadFromJsonAsync<MessageResult>();
if (result is null)
{
Snackbar.Add($"Canceling order failed", Severity.Warning);
}
else if (!result.IsSuccess)
{
Snackbar.Add($"{result.Message}", Severity.Warning);
}
else
{
Snackbar.Add($"Order canceled successfully", Severity.Success);
}
}
else if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
Snackbar.Add($"Robot with ID '{RobotId}' not found", Severity.Error);
}
else
{
var error = await response.Content.ReadAsStringAsync();
Snackbar.Add($"Error canceling order: {error}", Severity.Error);
}
}
catch (Exception ex)
{
Snackbar.Add($"Error canceling order: {ex.Message}", Severity.Error);
}
finally
{
isCanceling = false;
StateHasChanged();
}
}
// Helper class for order history
private class SentOrder
{
public string NodeName { get; set; } = string.Empty;
public DateTime Timestamp { get; set; }
public bool IsSuccess { get; set; }
}
}

View File

@@ -0,0 +1,32 @@
@using RobotNet.VDA5050.State
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.h6" Class="mb-3">Maps</MudText>
@if (State?.Maps != null && State.Maps.Length > 0)
{
<MudTable Items="@State.Maps" T="Map" Hover="true" Dense="true" Elevation="0">
<HeaderContent>
<MudTh>Map ID</MudTh>
<MudTh>Map Description</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Map ID">@context.MapId</MudTd>
<MudTd DataLabel="Map Description">@(context.MapDescription ?? "N/A")</MudTd>
</RowTemplate>
</MudTable>
}
else
{
<MudText Typo="Typo.body2" Align="Align.Center">No maps available</MudText>
}
</MudPaper>
@code {
public StateMsg? State { get; set; }
public void Update(StateMsg? state)
{
State = state;
StateHasChanged();
}
}

View File

@@ -0,0 +1,63 @@
@using RobotNet.VDA5050.State
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.h6" Class="mb-3">Node States</MudText>
@if (State?.NodeStates != null && State.NodeStates.Length > 0)
{
<MudTable Items="@State.NodeStates" T="NodeState" Hover="true" Dense="true" Height="400px" FixedHeader="true" Elevation="0">
<HeaderContent>
<MudTh>Node ID</MudTh>
<MudTh>Sequence ID</MudTh>
<MudTh>Released</MudTh>
<MudTh>Description</MudTh>
<MudTh>Position</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Node ID">@context.NodeId</MudTd>
<MudTd DataLabel="Sequence ID">@context.SequenceId</MudTd>
<MudTd DataLabel="Released">
@if (context.Released)
{
<MudChip T="string" Size="Size.Small" Color="Color.Success">Yes</MudChip>
}
else
{
<MudChip T="string" Size="Size.Small" Color="Color.Warning">No</MudChip>
}
</MudTd>
<MudTd DataLabel="Description">
<MudText Typo="Typo.body2" Style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
@(context.NodeDescription ?? "N/A")
</MudText>
</MudTd>
<MudTd DataLabel="Position">
@if (context.NodePosition != null)
{
<MudText Typo="Typo.body2">
@($"({context.NodePosition.X:F2}, {context.NodePosition.Y:F2})")
</MudText>
}
else
{
<MudText Typo="Typo.body2">N/A</MudText>
}
</MudTd>
</RowTemplate>
</MudTable>
}
else
{
<MudText Typo="Typo.body2" Align="Align.Center">No node states available</MudText>
}
</MudPaper>
@code {
public StateMsg? State { get; set; }
public void Update(StateMsg? state)
{
State = state;
Console.WriteLine($"Update node state: {State?.NodeStates.Length}");
StateHasChanged();
}
}

View File

@@ -0,0 +1,93 @@
@using RobotNet.VDA5050.State
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.h6" Class="mb-3">Order Information</MudText>
@if (State != null)
{
<MudSimpleTable Elevation="0">
<tbody>
<tr>
<td><strong>Order ID:</strong></td>
<td>@State.OrderId</td>
</tr>
<tr>
<td><strong>Order Update ID:</strong></td>
<td>@State.OrderUpdateId</td>
</tr>
<tr>
<td><strong>Zone Set ID:</strong></td>
<td>@(State.ZoneSetId ?? "N/A")</td>
</tr>
<tr>
<td><strong>Last Node ID:</strong></td>
<td>@State.LastNodeId</td>
</tr>
<tr>
<td><strong>Last Node Sequence ID:</strong></td>
<td>@State.LastNodeSequenceId</td>
</tr>
<tr>
<td><strong>Driving:</strong></td>
<td>
@if (State.Driving)
{
<MudChip T="string" Size="Size.Small" Color="Color.Success">Yes</MudChip>
}
else
{
<MudChip T="string" Size="Size.Small" Color="Color.Default">No</MudChip>
}
</td>
</tr>
<tr>
<td><strong>Paused:</strong></td>
<td>
@if (State.Paused)
{
<MudChip T="string" Size="Size.Small" Color="Color.Warning">Yes</MudChip>
}
else
{
<MudChip T="string" Size="Size.Small" Color="Color.Default">No</MudChip>
}
</td>
</tr>
<tr>
<td><strong>Operating Mode:</strong></td>
<td>@State.OperatingMode</td>
</tr>
<tr>
<td><strong>Distance Since Last Node:</strong></td>
<td>@State.DistanceSinceLastNode?.ToString("F3") m</td>
</tr>
<tr>
<td><strong>New Base Request:</strong></td>
<td>
@if (State.NewBaseRequest)
{
<MudChip T="string" Size="Size.Small" Color="Color.Warning">Yes</MudChip>
}
else
{
<MudChip T="string" Size="Size.Small" Color="Color.Default">No</MudChip>
}
</td>
</tr>
</tbody>
</MudSimpleTable>
}
else
{
<MudText Typo="Typo.body2" Align="Align.Center">No order data available</MudText>
}
</MudPaper>
@code {
public StateMsg? State { get; set; }
public void Update(StateMsg? state)
{
State = state;
StateHasChanged();
}
}

View File

@@ -0,0 +1,76 @@
@using RobotNet.VDA5050.State
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.h6" Class="mb-3">Position</MudText>
@if (State?.AgvPosition != null)
{
<MudSimpleTable Elevation="0">
<tbody>
<tr>
<td><strong>X:</strong></td>
<td>@State.AgvPosition.X.ToString("F3") m</td>
</tr>
<tr>
<td><strong>Y:</strong></td>
<td>@State.AgvPosition.Y.ToString("F3") m</td>
</tr>
<tr>
<td><strong>Theta:</strong></td>
<td>@State.AgvPosition.Theta.ToString("F3") rad</td>
</tr>
<tr>
<td><strong>Map ID:</strong></td>
<td>@(State.AgvPosition.MapId ?? "N/A")</td>
</tr>
<tr>
<td><strong>Position Initialized:</strong></td>
<td>
@if (State.AgvPosition.PositionInitialized)
{
<MudChip T="string" Size="Size.Small" Color="Color.Success">Yes</MudChip>
}
else
{
<MudChip T="string" Size="Size.Small" Color="Color.Warning">No</MudChip>
}
</td>
</tr>
@if (State.AgvPosition.LocalizationScore >= 0)
{
<tr>
<td><strong>Localization Score:</strong></td>
<td>@State.AgvPosition.LocalizationScore.ToString("F3")</td>
</tr>
}
@if (State.AgvPosition.DeviationRange >= 0)
{
<tr>
<td><strong>Deviation Range:</strong></td>
<td>@State.AgvPosition.DeviationRange.ToString("F3") m</td>
</tr>
}
@if (!string.IsNullOrWhiteSpace(State.AgvPosition.MapDescription))
{
<tr>
<td><strong>Map Description:</strong></td>
<td>@State.AgvPosition.MapDescription</td>
</tr>
}
</tbody>
</MudSimpleTable>
}
else
{
<MudText Typo="Typo.body2" Align="Align.Center">No position data available</MudText>
}
</MudPaper>
@code {
public StateMsg? State { get; set; }
public void Update(StateMsg? state)
{
State = state;
StateHasChanged();
}
}

View File

@@ -0,0 +1,62 @@
@using RobotNet.VDA5050.State
@using RobotNet.VDA5050.Type
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.h6" Class="mb-3">Safety State</MudText>
@if (State?.SafetyState != null)
{
<MudSimpleTable Elevation="0">
<tbody>
<tr>
<td><strong>E-Stop:</strong></td>
<td>
<MudChip T="string"
Size="Size.Small"
Color="@GetEStopColor(State.SafetyState.EStop)">
@State.SafetyState.EStop
</MudChip>
</td>
</tr>
<tr>
<td><strong>Field Violation:</strong></td>
<td>
@if (State.SafetyState.FieldViolation)
{
<MudChip T="string" Size="Size.Small" Color="Color.Error">Yes</MudChip>
}
else
{
<MudChip T="string" Size="Size.Small" Color="Color.Success">No</MudChip>
}
</td>
</tr>
</tbody>
</MudSimpleTable>
}
else
{
<MudText Typo="Typo.body2" Align="Align.Center">No safety state data available</MudText>
}
</MudPaper>
@code {
public StateMsg? State { get; set; }
private Color GetEStopColor(EStop eStop)
{
return eStop switch
{
EStop.NONE => Color.Success,
EStop.AUTOACK => Color.Warning,
EStop.MANUAL => Color.Error,
EStop.REMOTE => Color.Warning,
_ => Color.Default
};
}
public void Update(StateMsg? state)
{
State = state;
StateHasChanged();
}
}

View File

@@ -0,0 +1,38 @@
@using RobotNet.VDA5050.State
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.h6" Class="mb-3">Velocity</MudText>
@if (State?.Velocity != null)
{
<MudSimpleTable Elevation="0">
<tbody>
<tr>
<td><strong>Vx:</strong></td>
<td>@State.Velocity.Vx.ToString("F3") m/s</td>
</tr>
<tr>
<td><strong>Vy:</strong></td>
<td>@State.Velocity.Vy.ToString("F3") m/s</td>
</tr>
<tr>
<td><strong>Omega:</strong></td>
<td>@State.Velocity.Omega.ToString("F3") rad/s</td>
</tr>
</tbody>
</MudSimpleTable>
}
else
{
<MudText Typo="Typo.body2" Align="Align.Center">No velocity data available</MudText>
}
</MudPaper>
@code {
public StateMsg? State { get; set; }
public void Update(StateMsg? state)
{
State = state;
StateHasChanged();
}
}

View File

@@ -0,0 +1,248 @@
@using RobotNet10.FleetManager.Shared.DTOs.Robot
@using RobotNet10.FleetManager.Shared.DTOs.RobotModel
@using RobotNet10.FleetManager.Client.Services
@using RobotNet10.MapEditor.Services.API
@using RobotNet10.MapEditor.Shared.DTOs.Layout
@inject ISnackbar Snackbar
@inject MapManagerApiService MapApiService
<MudDialog>
<TitleContent>
<MudText Typo="Typo.h6">
<MudIcon Icon="@Icons.Material.Filled.Add" Color="Color.Primary" Class="mr-2" />
Create Robot
</MudText>
</TitleContent>
<DialogContent>
<MudStack Spacing="3">
<MudTextField @bind-Value="request.RobotId"
Label="Robot ID *"
Variant="Variant.Outlined"
ErrorText="@GetValidationError("RobotId")"
HelperText="Unique identifier for the robot" />
<MudTextField @bind-Value="request.Name"
Label="Name *"
Variant="Variant.Outlined"
ErrorText="@GetValidationError("Name")" />
<MudSelect @bind-Value="request.ModelId"
Label="Robot Model *"
Variant="Variant.Outlined"
T="Guid"
ErrorText="@GetValidationError("ModelId")">
@if (isLoadingModels)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" />
}
else
{
@foreach (var model in robotModels)
{
<MudSelectItem Value="@model.Id">@model.ModelName</MudSelectItem>
}
}
</MudSelect>
<MudSelect @bind-Value="request.MapId"
Label="Map (Optional)"
Variant="Variant.Outlined"
Clearable="true"
T="Guid?"
Disabled="@isLoadingMaps"
ErrorText="@GetValidationError("MapId")">
@if (isLoadingMaps)
{
<MudSelectItem Value="@((Guid?)null)" Disabled="true">Loading maps...</MudSelectItem>
}
else if (mapLevels != null && mapLevels.Any())
{
<MudSelectItem Value="@((Guid?)null)">No Map</MudSelectItem>
@foreach (var mapLevel in mapLevels)
{
<MudSelectItem Value="@((Guid?)mapLevel.LevelId)">@mapLevel.DisplayName</MudSelectItem>
}
}
else
{
<MudSelectItem Value="@((Guid?)null)" Disabled="true">No maps available</MudSelectItem>
}
</MudSelect>
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Primary"
Variant="Variant.Filled"
OnClick="Submit"
Disabled="@isCreating">
@if (isCreating)
{
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
<span>Creating...</span>
}
else
{
<span>Create</span>
}
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter] private IMudDialogInstance? MudDialog { get; set; }
[Parameter] public RobotApiService RobotApiService { get; set; } = null!;
[Parameter] public RobotModelApiService RobotModelApiService { get; set; } = null!;
private CreateRobotRequest request = new();
private List<RobotModelDto> robotModels = new();
private List<MapLevelInfo> mapLevels = new();
private Dictionary<string, string> validationErrors = new();
private bool isCreating = false;
private bool isLoadingModels = true;
private bool isLoadingMaps = false;
protected override async Task OnInitializedAsync()
{
await Task.WhenAll(
LoadRobotModelsAsync(),
LoadMapsAsync()
);
}
private async Task LoadRobotModelsAsync()
{
isLoadingModels = true;
try
{
robotModels = await RobotModelApiService.GetAllAsync();
}
catch (Exception ex)
{
Snackbar.Add($"Error loading robot models: {ex.Message}", Severity.Error);
}
finally
{
isLoadingModels = false;
StateHasChanged();
}
}
private async Task LoadMapsAsync()
{
isLoadingMaps = true;
StateHasChanged();
try
{
// Load all layouts (with nested versions and levels)
var layouts = await MapApiService.SearchLayoutsAsync();
if (layouts is null) return;
// Get all levels from all layouts (not just active layouts)
mapLevels = new List<MapLevelInfo>();
foreach (var layout in layouts.Where(l => l.Versions != null))
{
if (layout.Versions is null) continue;
foreach (var version in layout.Versions.Where(v => v.Levels != null))
{
if (version.Levels is null) continue;
foreach (var level in version.Levels.OrderBy(l => l.LevelOrder))
{
mapLevels.Add(new MapLevelInfo
{
LevelId = level.Id,
DisplayName = $"{layout.LayoutName} - {version.Version} - {level.LayoutLevelId}"
});
}
}
}
}
catch (Exception ex)
{
Snackbar.Add($"Error loading maps: {ex.Message}", Severity.Warning);
mapLevels = new List<MapLevelInfo>();
}
finally
{
isLoadingMaps = false;
StateHasChanged();
}
}
private bool Validate()
{
validationErrors.Clear();
if (string.IsNullOrWhiteSpace(request.RobotId))
{
validationErrors["RobotId"] = "Robot ID is required";
}
else if (request.RobotId.Length > 64)
{
validationErrors["RobotId"] = "Robot ID must be 64 characters or less";
}
if (string.IsNullOrWhiteSpace(request.Name))
{
validationErrors["Name"] = "Name is required";
}
else if (request.Name.Length > 256)
{
validationErrors["Name"] = "Name must be 256 characters or less";
}
if (request.ModelId == Guid.Empty)
{
validationErrors["ModelId"] = "Robot Model is required";
}
return validationErrors.Count == 0;
}
private string? GetValidationError(string fieldName)
{
return validationErrors.TryGetValue(fieldName, out var error) ? error : null;
}
private void Cancel()
{
MudDialog?.Cancel();
}
private async Task Submit()
{
if (!Validate())
{
Snackbar.Add("Please fix validation errors", Severity.Warning);
StateHasChanged();
return;
}
isCreating = true;
StateHasChanged();
try
{
var created = await RobotApiService.CreateAsync(request);
Snackbar.Add($"Robot '{request.Name}' created successfully", Severity.Success);
MudDialog?.Close(DialogResult.Ok(created));
}
catch (Exception ex)
{
Snackbar.Add($"Error creating robot: {ex.Message}", Severity.Error);
}
finally
{
isCreating = false;
StateHasChanged();
}
}
// Helper class for map level display
private class MapLevelInfo
{
public Guid LevelId { get; set; }
public string DisplayName { get; set; } = string.Empty;
}
}

View File

@@ -0,0 +1,75 @@
@using RobotNet10.FleetManager.Shared.DTOs.Robot
@using RobotNet10.FleetManager.Client.Services
@inject ISnackbar Snackbar
<MudDialog>
<TitleContent>
<MudText Typo="Typo.h6">
<MudIcon Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Class="mr-2" />
Delete Robot
</MudText>
</TitleContent>
<DialogContent>
<MudStack Spacing="3">
<MudText Typo="Typo.body1">
Are you sure you want to delete the robot <strong>@Robot.Name</strong> (ID: <strong>@Robot.RobotId</strong>)?
</MudText>
<MudAlert Severity="Severity.Warning">
<MudText>This action cannot be undone.</MudText>
</MudAlert>
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Error"
Variant="Variant.Filled"
OnClick="Submit"
Disabled="@isDeleting">
@if (isDeleting)
{
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
<span>Deleting...</span>
}
else
{
<span>Delete</span>
}
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter] private IMudDialogInstance? MudDialog { get; set; }
[Parameter] public RobotApiService RobotApiService { get; set; } = null!;
[Parameter] public RobotDto Robot { get; set; } = null!;
private bool isDeleting = false;
private void Cancel()
{
MudDialog?.Cancel();
}
private async Task Submit()
{
isDeleting = true;
StateHasChanged();
try
{
await RobotApiService.DeleteAsync(Robot.Id);
Snackbar.Add($"Robot '{Robot.Name}' deleted successfully", Severity.Success);
MudDialog?.Close(DialogResult.Ok(true));
}
catch (Exception ex)
{
Snackbar.Add($"Error deleting robot: {ex.Message}", Severity.Error);
}
finally
{
isDeleting = false;
StateHasChanged();
}
}
}

View File

@@ -0,0 +1,245 @@
@using RobotNet10.FleetManager.Shared.DTOs.Robot
@using RobotNet10.FleetManager.Shared.DTOs.RobotModel
@using RobotNet10.FleetManager.Client.Services
@using RobotNet10.MapEditor.Services.API
@using RobotNet10.MapEditor.Shared.DTOs.Layout
@inject ISnackbar Snackbar
@inject MapManagerApiService MapApiService
<MudDialog>
<TitleContent>
<MudText Typo="Typo.h6">
<MudIcon Icon="@Icons.Material.Filled.Edit" Color="Color.Primary" Class="mr-2" />
Edit Robot
</MudText>
</TitleContent>
<DialogContent>
<MudStack Spacing="3">
<MudTextField @bind-Value="request.RobotId"
Label="Robot ID"
Variant="Variant.Outlined"
ErrorText="@GetValidationError("RobotId")"
HelperText="Unique identifier for the robot" ReadOnly />
<MudTextField @bind-Value="request.Name"
Label="Name"
Variant="Variant.Outlined"
ErrorText="@GetValidationError("Name")" />
<MudSelect @bind-Value="request.ModelId"
Label="Robot Model"
Variant="Variant.Outlined"
T="Guid ?"
ErrorText="@GetValidationError("ModelId")">
@if (isLoadingModels)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" />
}
else
{
@foreach (var model in robotModels)
{
<MudSelectItem Value="@((Guid?)model.Id)">@model.ModelName</MudSelectItem>
}
}
</MudSelect>
<MudSelect @bind-Value="request.MapId"
Label="Map (Optional)"
Variant="Variant.Outlined"
Clearable="true"
T="Guid ?"
Disabled="@isLoadingMaps"
ErrorText="@GetValidationError("MapId")">
@if (isLoadingMaps)
{
<MudSelectItem Value="@((Guid?)null)" Disabled="true">Loading maps...</MudSelectItem>
}
else if (mapLevels != null && mapLevels.Any())
{
<MudSelectItem Value="@((Guid?)null)">No Map</MudSelectItem>
@foreach (var mapLevel in mapLevels)
{
<MudSelectItem Value="@((Guid?)mapLevel.LevelId)">@mapLevel.DisplayName</MudSelectItem>
}
}
else
{
<MudSelectItem Value="@((Guid?)null)" Disabled="true">No maps available</MudSelectItem>
}
</MudSelect>
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Primary"
Variant="Variant.Filled"
OnClick="Submit"
Disabled="@isUpdating">
@if (isUpdating)
{
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
<span>Updating...</span>
}
else
{
<span>Update</span>
}
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter] private IMudDialogInstance? MudDialog { get; set; }
[Parameter] public RobotApiService RobotApiService { get; set; } = null!;
[Parameter] public RobotModelApiService RobotModelApiService { get; set; } = null!;
[Parameter] public RobotDto Robot { get; set; } = null!;
private UpdateRobotRequest request = new();
private List<RobotModelDto> robotModels = new();
private List<MapLevelInfo> mapLevels = new();
private Dictionary<string, string> validationErrors = new();
private bool isUpdating = false;
private bool isLoadingModels = true;
private bool isLoadingMaps = false;
protected override void OnInitialized()
{
// Pre-fill with existing data
request.RobotId = Robot.RobotId;
request.Name = Robot.Name;
request.ModelId = Robot.ModelId;
request.MapId = Robot.MapId;
}
protected override async Task OnInitializedAsync()
{
await Task.WhenAll(
LoadRobotModelsAsync(),
LoadMapsAsync()
);
}
private async Task LoadRobotModelsAsync()
{
isLoadingModels = true;
try
{
robotModels = await RobotModelApiService.GetAllAsync();
}
catch (Exception ex)
{
Snackbar.Add($"Error loading robot models: {ex.Message}", Severity.Error);
}
finally
{
isLoadingModels = false;
StateHasChanged();
}
}
private async Task LoadMapsAsync()
{
isLoadingMaps = true;
StateHasChanged();
try
{
// Load all layouts (with nested versions and levels)
var layouts = await MapApiService.SearchLayoutsAsync();
if (layouts is null) return;
// Get all levels from all layouts (not just active layouts)
mapLevels = new List<MapLevelInfo>();
foreach (var layout in layouts.Where(l => l.Versions != null))
{
if (layout.Versions is null) continue;
foreach (var version in layout.Versions.Where(v => v.Levels != null))
{
if (version.Levels is null) continue;
foreach (var level in version.Levels.OrderBy(l => l.LevelOrder))
{
mapLevels.Add(new MapLevelInfo
{
LevelId = level.Id,
DisplayName = $"{layout.LayoutName} - {version.Version} - {level.LayoutLevelId}"
});
}
}
}
}
catch (Exception ex)
{
Snackbar.Add($"Error loading maps: {ex.Message}", Severity.Warning);
mapLevels = new List<MapLevelInfo>();
}
finally
{
isLoadingMaps = false;
StateHasChanged();
}
}
private bool Validate()
{
validationErrors.Clear();
if (!string.IsNullOrWhiteSpace(request.RobotId) && request.RobotId.Length > 64)
{
validationErrors["RobotId"] = "Robot ID must be 64 characters or less";
}
if (!string.IsNullOrWhiteSpace(request.Name) && request.Name.Length > 256)
{
validationErrors["Name"] = "Name must be 256 characters or less";
}
return validationErrors.Count == 0;
}
private string? GetValidationError(string fieldName)
{
return validationErrors.TryGetValue(fieldName, out var error) ? error : null;
}
private void Cancel()
{
MudDialog?.Cancel();
}
private async Task Submit()
{
if (!Validate())
{
Snackbar.Add("Please fix validation errors", Severity.Warning);
StateHasChanged();
return;
}
isUpdating = true;
StateHasChanged();
try
{
var updated = await RobotApiService.UpdateAsync(Robot.Id, request);
Snackbar.Add($"Robot '{updated.Name}' updated successfully", Severity.Success);
MudDialog?.Close(DialogResult.Ok(updated));
}
catch (Exception ex)
{
Snackbar.Add($"Error updating robot: {ex.Message}", Severity.Error);
}
finally
{
isUpdating = false;
StateHasChanged();
}
}
// Helper class for map level display
private class MapLevelInfo
{
public Guid LevelId { get; set; }
public string DisplayName { get; set; } = string.Empty;
}
}

View File

@@ -0,0 +1,392 @@
@using RobotNet10.FleetManager.Shared.DTOs.Robot
@using RobotNet10.FleetManager.Shared.DTOs.RobotModel
@using RobotNet10.FleetManager.Client.Services
@using RobotNet10.FleetManager.Client.Components.RobotManager.Dialogs
@using RobotNet10.MapEditor.Services.API
@using RobotNet10.MapEditor.Shared.DTOs.Layout
@using RobotNet10.Shared
@using System.Net.Http.Json
@inject IDialogService DialogService
@inject ISnackbar Snackbar
@inject NavigationManager NavigationManager
@inject MapManagerApiService MapApiService
@inject HttpClient HttpClient
<MudPaper Class="pa-4" Elevation="1">
<MudStack Spacing="3">
<!-- Search and Filters -->
<MudGrid>
<MudItem xs="12" md="4">
<MudTextField Value="@searchText"
Placeholder="Search by RobotId or Name..."
Variant="Variant.Outlined"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search"
Margin="Margin.Dense"
Immediate="false"
T="string"
ValueChanged="OnSearchTextChanged"
Clearable="true"
Class="mt-2" />
</MudItem>
<MudItem xs="12" md="4">
<MudSelect @bind-Value="selectedModelId"
@bind-Value:after="OnModelFilterChanged"
Label="Filter by Model"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Clearable="true"
T="Guid ?">
@foreach (var model in robotModels)
{
<MudSelectItem Value="@((Guid?)model.Id)">@model.ModelName</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" md="4">
<MudSelect @bind-Value="selectedMapId"
@bind-Value:after="OnMapFilterChanged"
Label="Filter by Map"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Clearable="true"
T="Guid ?">
<!-- TODO: Load maps from MapManager service if available -->
<MudSelectItem Value="@((Guid?)null)">All Maps</MudSelectItem>
</MudSelect>
</MudItem>
</MudGrid>
<!-- Robot Table -->
@if (isLoading)
{
<MudStack AlignItems="AlignItems.Center" Spacing="2" Class="my-4">
<MudProgressCircular Color="Color.Primary" Indeterminate="true" Size="Size.Medium" />
<MudText Typo="Typo.body2">Loading robots...</MudText>
</MudStack>
}
else if (robots.Count == 0)
{
<MudPaper Class="pa-4" Elevation="0">
<MudText Typo="Typo.body1" Align="Align.Center" Class="mt-4">
@if (!string.IsNullOrWhiteSpace(searchText) || selectedModelId.HasValue || selectedMapId.HasValue)
{
<span>No robots found matching the current filters.</span>
}
else
{
<span>No robots found. Click "Add Robot" to create one.</span>
}
</MudText>
</MudPaper>
}
else
{
<MudTable Items="@robots"
@ref="@table"
T="RobotDto"
Hover="true"
Dense="true"
FixedHeader="true"
Elevation="0"
Height="calc(100vh - 325px)">
<HeaderContent>
<MudTh>Robot ID</MudTh>
<MudTh>Name</MudTh>
<MudTh>Model</MudTh>
<MudTh>Map</MudTh>
<MudTh>Status</MudTh>
<MudTh>Created Date</MudTh>
<MudTh></MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Robot ID">
<MudText Typo="Typo.body2">@context.RobotId</MudText>
</MudTd>
<MudTd DataLabel="Name">
<MudText Typo="Typo.body2">@context.Name</MudText>
</MudTd>
<MudTd DataLabel="Model">
<MudChip T="string" Size="Size.Small" Color="Color.Primary">
@(context.ModelName ?? "N/A")
</MudChip>
</MudTd>
<MudTd DataLabel="Map">
<MudText Typo="Typo.body2">@GetMapDisplayName(context.MapId)</MudText>
</MudTd>
<MudTd DataLabel="Status">
@if (GetRobotOnlineStatus(context.RobotId))
{
<MudChip T="string" Size="Size.Small" Color="Color.Success">Online</MudChip>
}
else
{
<MudChip T="string" Size="Size.Small" Color="Color.Default">Offline</MudChip>
}
</MudTd>
<MudTd DataLabel="Created Date">
<MudText Typo="Typo.body2">@context.CreatedDate.ToString("g")</MudText>
</MudTd>
<MudTd DataLabel="Actions">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Color="Color.Primary"
Size="Size.Small"
OnClick="@(() => HandleEdit(context))" />
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Color="Color.Error"
Size="Size.Small"
OnClick="@(() => HandleDelete(context))" />
<MudIconButton Color="Color.Primary"
Size="Size.Small"
Icon="@Icons.Material.Filled.TrendingFlat"
OnClick="@(() => NavigateToDetail(context.RobotId))">
</MudIconButton>
</MudTd>
</RowTemplate>
<PagerContent>
<div class="d-flex w-100 flex-row-reverse">
<MudTablePager Style="width: 100%;" PageSizeOptions="new[] { 25, 50, 100 }" />
</div>
</PagerContent>
</MudTable>
}
</MudStack>
</MudPaper>
@code {
[Parameter]
public RobotApiService RobotApiService { get; set; } = null!;
[Parameter]
public RobotModelApiService RobotModelApiService { get; set; } = null!;
[Parameter]
public EventCallback OnRobotDeleted { get; set; }
[Parameter]
public EventCallback OnRobotUpdated { get; set; }
private List<RobotDto> robots = new();
private List<RobotModelDto> robotModels = new();
private MudTable<RobotDto>? table;
private bool isLoading = false;
private string searchText = string.Empty;
private Guid? selectedModelId;
private Guid? selectedMapId;
private Dictionary<Guid, string> mapDisplayNames = new();
private Dictionary<string, bool> robotOnlineStatus = new();
protected override async Task OnInitializedAsync()
{
await LoadRobotModelsAsync();
await LoadDataAsync();
}
protected override async Task OnParametersSetAsync()
{
await LoadDataAsync();
}
public async Task LoadDataAsync()
{
isLoading = true;
StateHasChanged();
try
{
if (!string.IsNullOrWhiteSpace(searchText))
{
robots = await RobotApiService.SearchAsync(searchText);
}
else
{
robots = await RobotApiService.GetAllAsync(selectedModelId, selectedMapId);
}
// Load map display names and online status
await LoadMapDisplayNamesAsync();
await LoadRobotOnlineStatusAsync();
table?.ReloadServerData();
}
catch (HttpRequestException ex)
{
Snackbar.Add($"Network error loading robots: {ex.Message}", Severity.Error);
robots = new List<RobotDto>();
}
catch (Exception ex)
{
Snackbar.Add($"Error loading robots: {ex.Message}", Severity.Error);
robots = new List<RobotDto>();
}
finally
{
isLoading = false;
StateHasChanged();
}
}
private async Task LoadMapDisplayNamesAsync()
{
mapDisplayNames.Clear();
var mapIds = robots.Where(r => r.MapId.HasValue).Select(r => r.MapId!.Value).Distinct().ToList();
if (mapIds.Count == 0) return;
try
{
// Load all layouts once
var layouts = await MapApiService.SearchLayoutsAsync();
// Create a dictionary to map level ID to layout/version info
var levelInfoMap = new Dictionary<Guid, (string LayoutName, string Version, string LevelId)>();
foreach (var layout in layouts)
{
if (layout.Versions != null)
{
foreach (var version in layout.Versions)
{
if (version.Levels != null)
{
foreach (var level in version.Levels)
{
levelInfoMap[level.Id] = (layout.LayoutName, version.Version, level.LayoutLevelId);
}
}
}
}
}
// Build display names for each map ID
foreach (var mapId in mapIds)
{
if (levelInfoMap.TryGetValue(mapId, out var info))
{
mapDisplayNames[mapId] = $"{info.LayoutName} - {info.Version} - {info.LevelId}";
}
else
{
mapDisplayNames[mapId] = "N/A";
}
}
}
catch (Exception ex)
{
// If loading fails, set all to N/A
foreach (var mapId in mapIds)
{
mapDisplayNames[mapId] = "N/A";
}
Snackbar.Add($"Error loading map information: {ex.Message}", Severity.Warning);
}
}
private async Task LoadRobotOnlineStatusAsync()
{
robotOnlineStatus.Clear();
var tasks = robots.Select(async robot =>
{
try
{
var response = await HttpClient.GetAsync($"/api/RobotManager/OnlineStatus/{robot.RobotId}");
if (response.IsSuccessStatusCode)
{
var result = await response.Content.ReadFromJsonAsync<MessageResult<bool>>();
robotOnlineStatus[robot.RobotId] = result?.Data ?? false;
}
else
{
robotOnlineStatus[robot.RobotId] = false;
}
}
catch
{
robotOnlineStatus[robot.RobotId] = false;
}
});
await Task.WhenAll(tasks);
}
private string GetMapDisplayName(Guid? mapId)
{
if (!mapId.HasValue) return "N/A";
return mapDisplayNames.TryGetValue(mapId.Value, out var displayName) ? displayName : "Loading...";
}
private bool GetRobotOnlineStatus(string robotId)
{
return robotOnlineStatus.TryGetValue(robotId, out var isOnline) && isOnline;
}
private async Task LoadRobotModelsAsync()
{
try
{
robotModels = await RobotModelApiService.GetAllAsync();
}
catch (Exception ex)
{
Snackbar.Add($"Error loading robot models: {ex.Message}", Severity.Error);
}
}
private async Task OnSearchTextChanged(string text)
{
searchText = text;
await LoadDataAsync();
}
private async Task OnModelFilterChanged()
{
await LoadDataAsync();
}
private async Task OnMapFilterChanged()
{
await LoadDataAsync();
}
private void NavigateToDetail(string robotId)
{
NavigationManager.NavigateTo($"/robots/{robotId}/detail");
}
private async Task HandleEdit(RobotDto robot)
{
var parameters = new DialogParameters
{
["RobotApiService"] = RobotApiService,
["RobotModelApiService"] = RobotModelApiService,
["Robot"] = robot
};
var dialog = await DialogService.ShowAsync<EditRobotDialog>("Edit Robot", parameters,
new DialogOptions { MaxWidth = MaxWidth.Medium, FullWidth = true });
var result = await dialog.Result;
if (result != null && !result.Canceled)
{
await LoadDataAsync();
await OnRobotUpdated.InvokeAsync();
}
}
private async Task HandleDelete(RobotDto robot)
{
var parameters = new DialogParameters
{
["RobotApiService"] = RobotApiService,
["Robot"] = robot
};
var dialog = await DialogService.ShowAsync<DeleteRobotDialog>("Delete Robot", parameters);
var result = await dialog.Result;
if (result != null && !result.Canceled)
{
await LoadDataAsync();
await OnRobotDeleted.InvokeAsync();
}
}
}

View File

@@ -0,0 +1,296 @@
@using RobotNet10.FleetManager.Shared.DTOs.RobotModel
@using RobotNet10.FleetManager.Shared.Enums
@using RobotNet10.FleetManager.Client.Services
@using RobotNet10.MapEditor.Services.API
@using RobotNet10.MapEditor.Shared.DTOs.VehicleType
@inject ISnackbar Snackbar
@inject MapManagerApiService MapApiService
<MudDialog>
<TitleContent>
<MudText Typo="Typo.h6">
<MudIcon Icon="@Icons.Material.Filled.Add" Color="Color.Primary" Class="mr-2" />
Create Robot Model
</MudText>
</TitleContent>
<DialogContent>
<MudStack Spacing="3">
<MudTextField @bind-Value="request.ModelName"
Label="Model Name *"
Variant="Variant.Outlined"
ErrorText="@GetValidationError("ModelName")" />
<MudNumericField T="double" @bind-Value="request.Length"
Label="Length (m) *"
Variant="Variant.Outlined"
Min="0.01"
Max="1000"
Step="0.01"
ErrorText="@GetValidationError("Length")" />
<MudNumericField T="double" @bind-Value="request.Width"
Label="Width (m) *"
Variant="Variant.Outlined"
Min="0.01"
Max="1000"
Step="0.01"
ErrorText="@GetValidationError("Width")" />
<MudNumericField T="double" @bind-Value="request.NavigationPointX"
Label="Navigation Point X (m) *"
Variant="Variant.Outlined"
Step="0.01"
ErrorText="@GetValidationError("NavigationPointX")" />
<MudNumericField T="double" @bind-Value="request.NavigationPointY"
Label="Navigation Point Y (m) *"
Variant="Variant.Outlined"
Step="0.01"
ErrorText="@GetValidationError("NavigationPointY")" />
<MudSelect @bind-Value="request.NavigationType"
Label="Navigation Type *"
Variant="Variant.Outlined"
ErrorText="@GetValidationError("NavigationType")">
@foreach (NavigationType navType in Enum.GetValues<NavigationType>())
{
<MudSelectItem Value="@navType">@navType.ToString()</MudSelectItem>
}
</MudSelect>
<MudSelect @bind-Value="request.VehicleTypeId"
Label="Vehicle Type (Optional)"
Variant="Variant.Outlined"
T="Guid ?"
Clearable="true"
Disabled="@isLoadingVehicleTypes"
ErrorText="@GetValidationError("VehicleTypeId")">
@if (isLoadingVehicleTypes)
{
<MudSelectItem Value="@((Guid?)null)" Disabled="true">Loading vehicle types...</MudSelectItem>
}
else if (vehicleTypes != null && vehicleTypes.Any())
{
<MudSelectItem Value="@((Guid?)null)">None</MudSelectItem>
@foreach (var vehicleType in vehicleTypes)
{
<MudSelectItem Value="@((Guid?)vehicleType.Id)">@($"{vehicleType.VehicleTypeId} - {vehicleType.VehicleTypeName}")</MudSelectItem>
}
}
else
{
<MudSelectItem Value="@((Guid?)null)" Disabled="true">No vehicle types available</MudSelectItem>
}
</MudSelect>
<MudDivider />
<MudText Typo="Typo.subtitle2">Image Upload (Optional)</MudText>
<MudFileUpload T="IBrowserFile"
@bind-Files="selectedFile"
Accept=".png"
MaximumFileCount="1">
<CustomContent>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.CloudUpload"
OnClick="@context.OpenFilePickerAsync">
Upload Image (PNG)
</MudButton>
</CustomContent>
</MudFileUpload>
@if (selectedFile != null)
{
<MudChip T="string" Color="Color.Success" Icon="@Icons.Material.Filled.AttachFile">
@selectedFile.Name (@FormatFileSize(selectedFile.Size))
</MudChip>
}
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Primary"
Variant="Variant.Filled"
OnClick="Submit"
Disabled="@isCreating">
@if (isCreating)
{
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
<span>Creating...</span>
}
else
{
<span>Create</span>
}
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter] private IMudDialogInstance? MudDialog { get; set; }
[Parameter] public RobotModelApiService ApiService { get; set; } = null!;
private CreateRobotModelRequest request = new()
{
NavigationType = NavigationType.Differential, // Default value
ImageWidth = 100, // Default value, will be updated if image is uploaded
ImageHeight = 100 // Default value, will be updated if image is uploaded
};
private IBrowserFile? selectedFile;
private Dictionary<string, string> validationErrors = new();
private bool isCreating = false;
private List<VehicleTypeDto>? vehicleTypes;
private bool isLoadingVehicleTypes = false;
protected override async Task OnInitializedAsync()
{
await LoadVehicleTypesAsync();
}
private async Task LoadVehicleTypesAsync()
{
isLoadingVehicleTypes = true;
StateHasChanged();
try
{
vehicleTypes = await MapApiService.GetVehicleTypesAsync(isActive: true);
}
catch (Exception ex)
{
Snackbar.Add($"Error loading vehicle types: {ex.Message}", Severity.Warning);
vehicleTypes = new List<VehicleTypeDto>();
}
finally
{
isLoadingVehicleTypes = false;
StateHasChanged();
}
}
private bool Validate()
{
validationErrors.Clear();
if (string.IsNullOrWhiteSpace(request.ModelName))
{
validationErrors["ModelName"] = "Model Name is required";
}
else if (request.ModelName.Length > 256)
{
validationErrors["ModelName"] = "Model Name must not exceed 256 characters";
}
if (request.Length <= 0)
{
validationErrors["Length"] = "Length must be greater than 0";
}
else if (request.Length > 1000)
{
validationErrors["Length"] = "Length must not exceed 1000 meters";
}
if (request.Width <= 0)
{
validationErrors["Width"] = "Width must be greater than 0";
}
else if (request.Width > 1000)
{
validationErrors["Width"] = "Width must not exceed 1000 meters";
}
// ImageWidth and ImageHeight are set to default values (100) when request is initialized
// They will be updated by server if image is uploaded
// No need to validate them here as they're always set to valid values (100)
// NavigationPointX and NavigationPointY can be 0 or any decimal value
// They are required fields but can be 0, so no validation needed here
// Server-side validation will handle range checks if needed
return validationErrors.Count == 0;
}
private string? GetValidationError(string fieldName)
{
return validationErrors.TryGetValue(fieldName, out var error) ? error : null;
}
private string FormatFileSize(long bytes)
{
string[] sizes = { "B", "KB", "MB", "GB" };
double len = bytes;
int order = 0;
while (len >= 1024 && order < sizes.Length - 1)
{
order++;
len = len / 1024;
}
return $"{len:0.##} {sizes[order]}";
}
private void Cancel()
{
MudDialog?.Cancel();
}
private async Task Submit()
{
if (!Validate())
{
var errorMessages = string.Join(", ", validationErrors.Values);
Snackbar.Add($"Please fix validation errors: {errorMessages}", Severity.Warning);
StateHasChanged();
return;
}
isCreating = true;
StateHasChanged();
try
{
// ImageWidth and ImageHeight are already set to default values (100) in request initialization
// If image is provided, server will update these values after extracting dimensions
// If no image, default values (100x100) will be used
// Create robot model first
var created = await ApiService.CreateAsync(request);
// Upload image if provided
if (selectedFile != null)
{
try
{
using var stream = selectedFile.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024); // 10MB
await ApiService.UploadImageAsync(created.Id, stream, selectedFile.Name);
}
catch (Exception imageEx)
{
// Log image upload error but don't fail the entire operation
// The robot model was created successfully, image can be uploaded later
Snackbar.Add($"Robot model created but image upload failed: {imageEx.Message}", Severity.Warning);
}
}
Snackbar.Add($"Robot model '{request.ModelName}' created successfully", Severity.Success);
MudDialog?.Close(DialogResult.Ok(created));
}
catch (HttpRequestException httpEx)
{
var errorMessage = httpEx.Message;
if (httpEx.Data.Contains("Response"))
{
errorMessage = $"Network error: {httpEx.Message}";
}
Snackbar.Add($"Error creating robot model: {errorMessage}", Severity.Error);
}
catch (Exception ex)
{
Snackbar.Add($"Error creating robot model: {ex.Message}", Severity.Error);
}
finally
{
isCreating = false;
StateHasChanged();
}
}
}

View File

@@ -0,0 +1,123 @@
@using RobotNet10.FleetManager.Shared.DTOs.RobotModel
@using RobotNet10.FleetManager.Client.Services
@inject ISnackbar Snackbar
<MudDialog>
<TitleContent>
<MudText Typo="Typo.h6">
<MudIcon Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Class="mr-2" />
Delete Robot Model
</MudText>
</TitleContent>
<DialogContent>
<MudStack Spacing="3">
<MudText Typo="Typo.body1">
Are you sure you want to delete the robot model <strong>@RobotModel.ModelName</strong>?
</MudText>
@if (usageInfo != null)
{
@if (usageInfo.RobotCount > 0)
{
<MudAlert Severity="Severity.Error">
<MudText>This robot model is currently being used by <strong>@usageInfo.RobotCount</strong> robot(s).</MudText>
<MudText>You must delete or reassign all robots using this model before you can delete it.</MudText>
</MudAlert>
}
else
{
<MudAlert Severity="Severity.Warning">
<MudText>This action cannot be undone.</MudText>
</MudAlert>
}
}
else if (isLoadingUsageInfo)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" />
}
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Error"
Variant="Variant.Filled"
OnClick="Submit"
Disabled="@(isDeleting || (usageInfo != null && !usageInfo.CanDelete))">
@if (isDeleting)
{
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
<span>Deleting...</span>
}
else
{
<span>Delete</span>
}
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter] private IMudDialogInstance? MudDialog { get; set; }
[Parameter] public RobotModelApiService ApiService { get; set; } = null!;
[Parameter] public RobotModelDto RobotModel { get; set; } = null!;
private RobotModelUsageInfoDto? usageInfo;
private bool isLoadingUsageInfo = true;
private bool isDeleting = false;
protected override async Task OnInitializedAsync()
{
await LoadUsageInfoAsync();
}
private async Task LoadUsageInfoAsync()
{
isLoadingUsageInfo = true;
try
{
usageInfo = await ApiService.GetUsageInfoAsync(RobotModel.Id);
}
catch (Exception ex)
{
Snackbar.Add($"Error loading usage info: {ex.Message}", Severity.Error);
}
finally
{
isLoadingUsageInfo = false;
StateHasChanged();
}
}
private void Cancel()
{
MudDialog?.Cancel();
}
private async Task Submit()
{
if (usageInfo != null && !usageInfo.CanDelete)
{
Snackbar.Add("Cannot delete robot model that is in use", Severity.Warning);
return;
}
isDeleting = true;
StateHasChanged();
try
{
await ApiService.DeleteAsync(RobotModel.Id);
Snackbar.Add($"Robot model '{RobotModel.ModelName}' deleted successfully", Severity.Success);
MudDialog?.Close(DialogResult.Ok(true));
}
catch (Exception ex)
{
Snackbar.Add($"Error deleting robot model: {ex.Message}", Severity.Error);
}
finally
{
isDeleting = false;
StateHasChanged();
}
}
}

View File

@@ -0,0 +1,259 @@
@using RobotNet10.FleetManager.Shared.DTOs.RobotModel
@using RobotNet10.FleetManager.Shared.Enums
@using RobotNet10.FleetManager.Client.Services
@using RobotNet10.MapEditor.Services.API
@using RobotNet10.MapEditor.Shared.DTOs.VehicleType
@inject ISnackbar Snackbar
@inject MapManagerApiService MapApiService
<MudDialog>
<TitleContent>
<MudText Typo="Typo.h6">
<MudIcon Icon="@Icons.Material.Filled.Edit" Color="Color.Primary" Class="mr-2" />
Edit Robot Model
</MudText>
</TitleContent>
<DialogContent>
<MudStack Spacing="3">
<MudTextField @bind-Value="request.ModelName"
Label="Model Name"
Variant="Variant.Outlined"
ErrorText="@GetValidationError("ModelName")" />
<MudNumericField T="double?" @bind-Value="request.Length"
Label="Length (m)"
Variant="Variant.Outlined"
Min="0.01"
Max="1000"
Step="0.01"
ErrorText="@GetValidationError("Length")" />
<MudNumericField T="double?" @bind-Value="request.Width"
Label="Width (m)"
Variant="Variant.Outlined"
Min="0.01"
Max="1000"
Step="0.01"
ErrorText="@GetValidationError("Width")" />
<MudNumericField T="double?" @bind-Value="request.NavigationPointX"
Label="Navigation Point X (m)"
Variant="Variant.Outlined"
Step="0.01"
ErrorText="@GetValidationError("NavigationPointX")" />
<MudNumericField T="double?" @bind-Value="request.NavigationPointY"
Label="Navigation Point Y (m)"
Variant="Variant.Outlined"
Step="0.01"
ErrorText="@GetValidationError("NavigationPointY")" />
<MudSelect @bind-Value="request.NavigationType"
Label="Navigation Type"
Variant="Variant.Outlined"
T="NavigationType?"
ErrorText="@GetValidationError("NavigationType")" ReadOnly>
@foreach (NavigationType navType in Enum.GetValues<NavigationType>())
{
<MudSelectItem Value="@((NavigationType?)navType)">@navType.ToString()</MudSelectItem>
}
</MudSelect>
<MudSelect @bind-Value="request.VehicleTypeId"
Label="Vehicle Type (Optional)"
Variant="Variant.Outlined"
T="Guid?"
Clearable="true"
Disabled="@isLoadingVehicleTypes"
ErrorText="@GetValidationError("VehicleTypeId")">
@if (isLoadingVehicleTypes)
{
<MudSelectItem Value="@((Guid?)null)" Disabled="true">Loading vehicle types...</MudSelectItem>
}
else if (vehicleTypes != null && vehicleTypes.Any())
{
<MudSelectItem Value="@((Guid?)null)">None</MudSelectItem>
@foreach (var vehicleType in vehicleTypes)
{
<MudSelectItem Value="@((Guid?)vehicleType.Id)">@($"{vehicleType.VehicleTypeId} - {vehicleType.VehicleTypeName}")</MudSelectItem>
}
}
else
{
<MudSelectItem Value="@((Guid?)null)" Disabled="true">No vehicle types available</MudSelectItem>
}
</MudSelect>
<MudDivider />
<MudText Typo="Typo.subtitle2">Image Upload (Optional - Leave empty to keep current image)</MudText>
<MudFileUpload T="IBrowserFile"
@bind-Files="selectedFile"
Accept=".png"
MaximumFileCount="1">
<CustomContent>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.CloudUpload"
OnClick="@context.OpenFilePickerAsync">
Upload New Image (PNG)
</MudButton>
</CustomContent>
</MudFileUpload>
@if (selectedFile != null)
{
<MudChip T="string" Color="Color.Success" Icon="@Icons.Material.Filled.AttachFile">
@selectedFile.Name (@FormatFileSize(selectedFile.Size))
</MudChip>
}
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Primary"
Variant="Variant.Filled"
OnClick="Submit"
Disabled="@isUpdating">
@if (isUpdating)
{
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
<span>Updating...</span>
}
else
{
<span>Update</span>
}
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter] private IMudDialogInstance? MudDialog { get; set; }
[Parameter] public RobotModelApiService ApiService { get; set; } = null!;
[Parameter] public RobotModelDto RobotModel { get; set; } = null!;
private UpdateRobotModelRequest request = new();
private IBrowserFile? selectedFile;
private Dictionary<string, string> validationErrors = new();
private bool isUpdating = false;
private List<VehicleTypeDto>? vehicleTypes;
private bool isLoadingVehicleTypes = false;
protected override async Task OnInitializedAsync()
{
// Pre-fill with existing data
request.ModelName = RobotModel.ModelName;
request.Length = RobotModel.Length;
request.Width = RobotModel.Width;
request.NavigationPointX = RobotModel.NavigationPointX;
request.NavigationPointY = RobotModel.NavigationPointY;
request.NavigationType = RobotModel.NavigationType; // This is nullable, but we set it to the actual value
request.VehicleTypeId = RobotModel.VehicleTypeId;
await LoadVehicleTypesAsync();
}
private async Task LoadVehicleTypesAsync()
{
isLoadingVehicleTypes = true;
StateHasChanged();
try
{
vehicleTypes = await MapApiService.GetVehicleTypesAsync(isActive: true);
}
catch (Exception ex)
{
Snackbar.Add($"Error loading vehicle types: {ex.Message}", Severity.Warning);
vehicleTypes = new List<VehicleTypeDto>();
}
finally
{
isLoadingVehicleTypes = false;
StateHasChanged();
}
}
private bool Validate()
{
validationErrors.Clear();
if (!string.IsNullOrWhiteSpace(request.ModelName) && request.ModelName.Length > 256)
{
validationErrors["ModelName"] = "Model Name must be 256 characters or less";
}
if (request.Length.HasValue && request.Length.Value <= 0)
{
validationErrors["Length"] = "Length must be greater than 0";
}
if (request.Width.HasValue && request.Width.Value <= 0)
{
validationErrors["Width"] = "Width must be greater than 0";
}
return validationErrors.Count == 0;
}
private string? GetValidationError(string fieldName)
{
return validationErrors.TryGetValue(fieldName, out var error) ? error : null;
}
private string FormatFileSize(long bytes)
{
string[] sizes = { "B", "KB", "MB", "GB" };
double len = bytes;
int order = 0;
while (len >= 1024 && order < sizes.Length - 1)
{
order++;
len = len / 1024;
}
return $"{len:0.##} {sizes[order]}";
}
private void Cancel()
{
MudDialog?.Cancel();
}
private async Task Submit()
{
if (!Validate())
{
Snackbar.Add("Please fix validation errors", Severity.Warning);
StateHasChanged();
return;
}
isUpdating = true;
StateHasChanged();
try
{
// Update robot model
var updated = await ApiService.UpdateAsync(RobotModel.Id, request);
// Upload new image if provided
if (selectedFile != null)
{
using var stream = selectedFile.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024); // 10MB
await ApiService.UploadImageAsync(updated.Id, stream, selectedFile.Name);
}
Snackbar.Add($"Robot model '{updated.ModelName}' updated successfully", Severity.Success);
MudDialog?.Close(DialogResult.Ok(updated));
}
catch (Exception ex)
{
Snackbar.Add($"Error updating robot model: {ex.Message}", Severity.Error);
}
finally
{
isUpdating = false;
StateHasChanged();
}
}
}

View File

@@ -0,0 +1,144 @@
@*
Component: RobotModelDetailsPanel
Purpose: Displays detailed information about a selected robot model.
Shows image preview with navigation point, dimensions, and usage statistics.
*@
@using RobotNet10.FleetManager.Shared.DTOs.RobotModel
@using RobotNet10.FleetManager.Client.Services
@using RobotNet10.FleetManager.Client.Components.RobotModelManager.Dialogs
@using RobotNet10.MapEditor.Services.API
@using RobotNet10.MapEditor.Shared.DTOs.VehicleType
@inject IDialogService DialogService
@inject MapManagerApiService MapApiService
<!-- Image Preview -->
<RobotModelImagePreview RobotModel="@RobotModel" />
<!-- Details -->
<MudStack Class="mt-2">
<MudSimpleTable>
<tbody>
<tr>
<td><strong>Model Name:</strong></td>
<td>@RobotModel.ModelName</td>
</tr>
<tr>
<td><strong>Navigation Type:</strong></td>
<td>@RobotModel.NavigationType</td>
</tr>
<tr>
<td><strong>Vehicle Type:</strong></td>
<td>
@if (RobotModel.VehicleTypeId.HasValue)
{
@if (vehicleTypeName != null)
{
<MudChip T="string" Size="Size.Small" Color="Color.Info">@vehicleTypeName</MudChip>
}
else if (isLoadingVehicleType)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" />
}
else
{
<MudChip T="string" Size="Size.Small" Color="Color.Warning">@RobotModel.VehicleTypeId.Value.ToString("N")</MudChip>
}
}
else
{
<MudText Typo="Typo.body2" Style="color: gray;">Not assigned</MudText>
}
</td>
</tr>
<tr>
<td><strong>Dimensions:</strong></td>
<td>@($"{RobotModel.Length}m × {RobotModel.Width}m")</td>
</tr>
<tr>
<td><strong>Image Size:</strong></td>
<td>@($"{RobotModel.ImageWidth} × {RobotModel.ImageHeight} px")</td>
</tr>
<tr>
<td><strong>Navigation Point:</strong></td>
<td>@($"X: {RobotModel.NavigationPointX}m, Y: {RobotModel.NavigationPointY}m")</td>
</tr>
<tr>
<td><strong>Robot Count:</strong></td>
<td>@RobotModel.RobotCount</td>
</tr>
<tr>
<td><strong>Created:</strong></td>
<td>@RobotModel.CreatedDate.ToString("g")</td>
</tr>
@if (RobotModel.UpdatedDate.HasValue)
{
<tr>
<td><strong>Updated:</strong></td>
<td>@RobotModel.UpdatedDate.Value.ToString("g")</td>
</tr>
}
</tbody>
</MudSimpleTable>
</MudStack>
@code {
[Parameter]
public RobotModelDto RobotModel { get; set; } = null!;
private string? vehicleTypeName;
private bool isLoadingVehicleType = false;
protected override async Task OnInitializedAsync()
{
if (RobotModel.VehicleTypeId.HasValue)
{
await LoadVehicleTypeNameAsync();
}
}
protected override async Task OnParametersSetAsync()
{
if (RobotModel.VehicleTypeId.HasValue)
{
await LoadVehicleTypeNameAsync();
}
else
{
vehicleTypeName = null;
}
}
private async Task LoadVehicleTypeNameAsync()
{
if (!RobotModel.VehicleTypeId.HasValue)
{
vehicleTypeName = null;
return;
}
isLoadingVehicleType = true;
StateHasChanged();
try
{
var vehicleType = await MapApiService.GetVehicleTypeAsync(RobotModel.VehicleTypeId.Value);
if (vehicleType != null)
{
vehicleTypeName = $"{vehicleType.VehicleTypeId} - {vehicleType.VehicleTypeName}";
}
else
{
vehicleTypeName = null;
}
}
catch (Exception)
{
vehicleTypeName = null;
}
finally
{
isLoadingVehicleType = false;
StateHasChanged();
}
}
}

View File

@@ -0,0 +1,162 @@
@*
Component: RobotModelImagePreview
Purpose: Displays robot model image with navigation point overlay visualization.
The navigation point is shown as arrows (X+ in red, Y+ in green) and a blue marker.
Uses SVG viewBox to automatically scale overlay to match displayed image size.
*@
@using RobotNet10.FleetManager.Shared.DTOs.RobotModel
@using RobotNet10.FleetManager.Client.Services
@inject RobotModelApiService ApiService
@if (imageBase64 != null)
{
<div style="position: relative; width: 100%; max-width: 100%; max-height: 250px; overflow: hidden; display: flex; align-items: center; justify-content: center; border-radius: 4px;">
<div style="position: relative; display: inline-block; max-width: 100%; max-height: 250px;">
<img @ref="imageElement"
src="data:image/png;base64,@imageBase64"
alt="Robot Model Image"
style="max-width: 100%; max-height: 250px; height: auto; width: auto; display: block; object-fit: contain;"
@onload="OnImageLoaded" />
<!-- SVG Overlay for Navigation Point -->
@if (RobotModel.ImageWidth > 0 && RobotModel.ImageHeight > 0)
{
<svg style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none;"
viewBox="0 0 @RobotModel.ImageWidth @RobotModel.ImageHeight"
preserveAspectRatio="xMidYMid meet">
<!-- X-axis arrow (right) - Red arrow pointing right (X+) -->
<line x1="@svgX"
y1="@svgY"
x2="@(svgX + arrowLength)"
y2="@svgY"
stroke="red"
stroke-width="5"
marker-end="url(#arrowhead-x-@uniqueId)" />
<!-- Y-axis arrow (up) - Green arrow pointing up (Y+) -->
<line x1="@svgX"
y1="@svgY"
x2="@svgX"
y2="@(svgY - arrowLength)"
stroke="green"
stroke-width="5"
marker-end="url(#arrowhead-y-@uniqueId)" />
<!-- Navigation point marker -->
<circle cx="@svgX"
cy="@svgY"
r="10"
fill="blue"
stroke="white"
stroke-width="2" />
<!-- Arrow markers definition -->
<defs>
<marker id="arrowhead-x-@uniqueId"
markerWidth="10"
markerHeight="10"
refX="9"
refY="3"
orient="auto"
markerUnits="strokeWidth">
<polygon points="0 0, 10 3, 0 6" fill="red" />
</marker>
<marker id="arrowhead-y-@uniqueId"
markerWidth="10"
markerHeight="10" efX="9"
refY="3"
orient="auto"
markerUnits="strokeWidth">
<polygon points="0 0, 10 3, 0 6" fill="green" />
</marker>
</defs>
</svg>
}
</div>
</div>
}
else
{
<MudAlert Severity="Severity.Info">No image available</MudAlert>
}
@code {
[Parameter]
public RobotModelDto RobotModel { get; set; } = null!;
private string? imageBase64;
private double svgX;
private double svgY;
private double arrowLength;
private ElementReference imageElement;
private string uniqueId = Guid.NewGuid().ToString("N")[..8]; // Unique ID for SVG markers
protected override async Task OnInitializedAsync()
{
await LoadImageAsync();
}
protected override async Task OnParametersSetAsync()
{
await LoadImageAsync();
}
private void OnImageLoaded()
{
CalculateNavigationPoint();
}
private void CalculateNavigationPoint()
{
if (RobotModel.ImageWidth > 0 && RobotModel.ImageHeight > 0 && RobotModel.Length > 0 && RobotModel.Width > 0)
{
// Calculate scale: pixels per meter in original image
// This converts from robot coordinate system (meters) to image coordinate system (pixels)
var scaleX = RobotModel.ImageWidth / RobotModel.Length;
var scaleY = RobotModel.ImageHeight / RobotModel.Width;
// Navigation point coordinates are relative to the bottom-left corner of the image (in meters)
// Image coordinate system: (0,0) is at bottom-left corner
// X-axis: left to right (0 to ImageWidth)
// Y-axis: bottom to top (0 to ImageHeight in robot coords, but SVG Y increases downward)
// Convert navigation point from meters to pixels
// NavigationPointX: distance from left edge (in meters)
// NavigationPointY: distance from bottom edge (in meters)
var navX = RobotModel.NavigationPointX * scaleX;
var navY = RobotModel.NavigationPointY * scaleY;
// Final position in SVG coordinates (using original image coordinate system)
// SVG viewBox will automatically scale to match the displayed image size
// X: from left edge (0 is left, ImageWidth is right)
svgX = navX;
// Y: from bottom edge (0 is bottom in robot coords, but SVG Y=0 is top, Y=ImageHeight is bottom)
// So we need to invert: svgY = ImageHeight - navY
svgY = RobotModel.ImageHeight - navY;
// Arrow length: 15% of the smaller dimension for better visibility
arrowLength = Math.Min(RobotModel.ImageWidth, RobotModel.ImageHeight) * 0.15;
StateHasChanged();
}
}
private async Task LoadImageAsync()
{
try
{
imageBase64 = await ApiService.GetImageAsync(RobotModel.Id);
if (imageBase64 != null)
{
// Calculate navigation point when image loads
CalculateNavigationPoint();
}
}
catch (Exception)
{
imageBase64 = null;
}
}
}

View File

@@ -0,0 +1,252 @@
@using RobotNet10.FleetManager.Shared.DTOs.RobotModel
@using RobotNet10.FleetManager.Client.Services
@using RobotNet10.FleetManager.Client.Components.RobotModelManager.Dialogs
@inject IDialogService DialogService
@inject ISnackbar Snackbar
<style>
.selected {
background-color: #1E88E5 !important;
}
.selected > td {
color: white !important;
}
.selected > td .mud-input {
color: white !important;
}
</style>
<MudPaper Class="pa-4" Elevation="1">
@if (isLoading)
{
<MudStack AlignItems="AlignItems.Center" Spacing="2" Class="my-4">
<MudProgressCircular Color="Color.Primary" Indeterminate="true" Size="Size.Medium" />
<MudText Typo="Typo.body2">Loading robot models...</MudText>
</MudStack>
}
else if (robotModels.Count == 0)
{
<MudPaper Class="pa-4" Elevation="0">
<MudText Typo="Typo.body1" Align="Align.Center" Class="mt-4">
No robot models found. Click "Add Robot Model" to create one.
</MudText>
</MudPaper>
}
else
{
<MudTable Items="@robotModels"
@ref="@table"
T="RobotModelDto"
Hover="true"
Dense="true"
FixedHeader="true"
SelectOnRowClick=true
Elevation="0"
Height="calc(100vh - 254px)"
RowClass="cursor-pointer"
RowClassFunc="@SelectedRowClassFunc"
OnRowClick="RowClickEvent"
SelectedItemChanged="HandleSelectedItemChanged">
<HeaderContent>
<MudTh>Model Name</MudTh>
<MudTh>Navigation Type</MudTh>
<MudTh>Dimensions</MudTh>
<MudTh>Robot Count</MudTh>
<MudTh>Actions</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Model Name">
<MudText Typo="Typo.body2">@context.ModelName</MudText>
</MudTd>
<MudTd DataLabel="Navigation Type">
<MudChip T="string" Size="Size.Small" Color="Color.Primary">
@context.NavigationType.ToString()
</MudChip>
</MudTd>
<MudTd DataLabel="Dimensions">
<MudText Typo="Typo.body2">@($"{context.Length}m × {context.Width}m")</MudText>
</MudTd>
<MudTd DataLabel="Robot Count">
<MudText Typo="Typo.body2">@context.RobotCount</MudText>
</MudTd>
<MudTd DataLabel="Actions">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Color="Color.Primary"
Size="Size.Small"
OnClick="@(() => HandleEdit(context))" />
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Color="Color.Error"
Size="Size.Small"
OnClick="@(() => HandleDelete(context))" />
</MudTd>
</RowTemplate>
<PagerContent>
<div class="d-flex w-100 flex-row-reverse">
<MudTablePager Style="width: 100%;" PageSizeOptions="new[] { 25, 50, 100 }" />
</div>
</PagerContent>
</MudTable>
}
</MudPaper>
@code {
[Parameter]
public RobotModelApiService ApiService { get; set; } = null!;
[Parameter]
public EventCallback<RobotModelDto?> SelectedRobotModelChanged { get; set; }
[Parameter]
public string SearchText { get; set; } = string.Empty;
private List<RobotModelDto> robotModels = new();
private MudTable<RobotModelDto>? table;
private bool isLoading = false;
private int selectedRowNumber = -1;
public RobotModelDto? SelectedRobotModel { get; set; }
protected override async Task OnInitializedAsync()
{
await LoadDataAsync();
}
public async Task LoadDataAsync()
{
isLoading = true;
StateHasChanged();
try
{
robotModels = await ApiService.GetAllAsync();
table?.ReloadServerData();
}
catch (HttpRequestException ex)
{
Snackbar.Add($"Network error loading robot models: {ex.Message}", Severity.Error);
robotModels = new List<RobotModelDto>();
}
catch (Exception ex)
{
Snackbar.Add($"Error loading robot models: {ex.Message}", Severity.Error);
robotModels = new List<RobotModelDto>();
}
finally
{
isLoading = false;
StateHasChanged();
}
}
public async Task SearchAsync(string query)
{
isLoading = true;
StateHasChanged();
try
{
if (string.IsNullOrWhiteSpace(query))
{
robotModels = await ApiService.GetAllAsync();
}
else
{
robotModels = await ApiService.SearchAsync(query);
}
table?.ReloadServerData();
}
catch (HttpRequestException ex)
{
Snackbar.Add($"Network error searching robot models: {ex.Message}", Severity.Error);
robotModels = new List<RobotModelDto>();
}
catch (Exception ex)
{
Snackbar.Add($"Error searching robot models: {ex.Message}", Severity.Error);
robotModels = new List<RobotModelDto>();
}
finally
{
isLoading = false;
StateHasChanged();
}
}
private void RowClickEvent(TableRowClickEventArgs<RobotModelDto> tableRowClickEventArgs) { }
private string SelectedRowClassFunc(RobotModelDto element, int rowNumber)
{
if (selectedRowNumber == rowNumber && table?.SelectedItem != null && !table.SelectedItem.Equals(element))
{
return string.Empty;
}
else if (selectedRowNumber == rowNumber && table?.SelectedItem != null && table.SelectedItem.Equals(element))
{
return "selected";
}
else if (table?.SelectedItem != null && table.SelectedItem.Equals(element))
{
selectedRowNumber = rowNumber;
return "selected";
}
else
{
return string.Empty;
}
}
private void HandleSelectedItemChanged(RobotModelDto element)
{
SelectedRobotModel = element;
_ = SelectedRobotModelChanged.InvokeAsync(element);
}
private async Task HandleEdit(RobotModelDto model)
{
var parameters = new DialogParameters
{
["ApiService"] = ApiService,
["RobotModel"] = model
};
var dialog = await DialogService.ShowAsync<EditRobotModelDialog>("Edit Robot Model", parameters,
new DialogOptions { MaxWidth = MaxWidth.Medium, FullWidth = true });
var result = await dialog.Result;
if (result != null && !result.Canceled)
{
await LoadDataAsync();
// Reload selected model if it was the one edited
if (SelectedRobotModel?.Id == model.Id)
{
var updated = await ApiService.GetByIdAsync(model.Id);
if (updated != null)
{
await SelectedRobotModelChanged.InvokeAsync(updated);
}
}
}
}
private async Task HandleDelete(RobotModelDto model)
{
var parameters = new DialogParameters
{
["ApiService"] = ApiService,
["RobotModel"] = model
};
var dialog = await DialogService.ShowAsync<DeleteRobotModelDialog>("Delete Robot Model", parameters);
var result = await dialog.Result;
if (result != null && !result.Canceled)
{
await LoadDataAsync();
// Clear selection if deleted model was selected
if (SelectedRobotModel?.Id == model.Id)
{
await SelectedRobotModelChanged.InvokeAsync(null);
}
}
}
}

View File

@@ -0,0 +1,205 @@
@using RobotNet10.FleetManager.Client.Services
@implements IDisposable
@if (State.ShowPath)
{
<defs>
<marker id="target" markerWidth="8" markerHeight="8" refX="4" refY="4">
<circle r="0.8" cx="4" cy="4" fill="red" />
<circle r="3" cx="4" cy="4" stroke="red" stroke-width="0.2" fill="transparent" stroke-dasharray="0.2 0.2" />
<line x1="0" y1="4" x2="2" y2="4" stroke="red" stroke-width="0.2" />
<line x1="6" y1="4" x2="8" y2="4" stroke="red" stroke-width="0.2" />
<line x1="4" y1="0" x2="4" y2="2" stroke="red" stroke-width="0.2" />
<line x1="4" y1="6" x2="4" y2="8" stroke="red" stroke-width="0.2" />
</marker>
</defs>
<g id="robot-paths-layer">
@foreach (var robot in State.Robots.Values)
{
if (robot is null || robot.Data is null || robot.Data.Path is null) continue;
var isSelected = State.SelectedRobotId == robot.RobotId;
var strokeColor = isSelected ? "#0288D1" : "#0097A7";
var opacity = isSelected ? "1" : "0.8";
@if (robot.Data.Path.RobotPath.Length > 0)
{
var data = UpdatePath(robot.Data.Path.RobotPath);
var strokeWidth = isSelected ? "0.12" : "0.08";
<path class="robot-path"
d="@data"
fill="none"
stroke="@strokeColor"
stroke-width="@strokeWidth"
opacity="@opacity"
stroke-dasharray="@(isSelected ? "0.2,0.1" : "none")"
marker-end="url(#target)" />
}
@if (robot.Data.Path.RobotBasePath.Length > 0)
{
var data = UpdatePath(robot.Data.Path.RobotBasePath);
var strokeWidth = isSelected ? "0.4" : "0.3";
<path class="robot-path"
d="@data"
fill="none"
stroke="@strokeColor"
stroke-width="@strokeWidth"
opacity="@opacity"
stroke-dasharray="@(isSelected ? "0.2,0.1" : "none")" />
}
}
</g>
}
<g id="robots-layer">
@foreach (var robot in State.Robots.Values)
{
if (robot is null || robot.Data is null) continue;
var svgPos = State.WorldToSvg(robot.Data.AgvPosition.X, robot.Data.AgvPosition.Y);
var degrees = -robot.Data.AgvPosition.Theta * 180.0 / Math.PI;
var baseScale = 2 / State.Viewport.ZoomLevel;
var minScale = 1;
var maxScale = 10.0;
baseScale = Math.Max(minScale, Math.Min(maxScale, baseScale));
@if (robot.Model != null && !string.IsNullOrEmpty(robot.ModelImageBase64))
{
var imageLength = robot.Model.Length * baseScale;
var imageWidth = robot.Model.Width * baseScale;
@* Navigation point offset: position relative to bottom-left corner of image *@
@* Scale navigation point offset with robot size *@
var navPointX = robot.Model.NavigationPointX * baseScale;
var navPointY = robot.Model.NavigationPointY * baseScale;
var imageX = -navPointX;
var imageY = navPointY - imageWidth;
<g transform="translate(@svgPos.X.ToString("F2"), @svgPos.Y.ToString("F2")) rotate(@degrees.ToString("F2"))">
<image href="data:image/png;base64,@robot.ModelImageBase64"
x="@imageX.ToString("F3")"
y="@imageY.ToString("F3")"
width="@imageLength.ToString("F3")"
height="@imageWidth.ToString("F3")"
preserveAspectRatio="xMidYMid"
@onclick="() => HandleRobotClick(robot.RobotId)"
style="cursor: pointer; pointer-events: all;" />
</g>
}
else
{
@* Placeholder circle until image loads - scale with zoom *@
var placeholderRadius = 0.5 * baseScale;
<g transform="translate(@svgPos.X.ToString("F2"), @svgPos.Y.ToString("F2")) rotate(@degrees.ToString("F2"))">
<circle cx="0"
cy="0"
r="@placeholderRadius.ToString("F3")"
fill="var(--mud-palette-error)"
stroke="var(--mud-palette-error-darken)"
stroke-width="@(0.05 * baseScale).ToString(" F3")"
@onclick="() => HandleRobotClick(robot.RobotId)"
style="cursor: pointer;" />
</g>
}
@* Selection highlight *@
@if (State.SelectedRobotId == robot.RobotId)
{
@* Calculate highlight radius based on robot size and scale *@
var highlightRadius = robot.Model != null
? (Math.Max(robot.Model.Length, robot.Model.Width) / 2.0 + 0.2) * baseScale
: 0.5 * baseScale;
<circle class="robot-selection-highlight"
cx="@svgPos.X.ToString("F2")"
cy="@svgPos.Y.ToString("F2")"
r="@highlightRadius.ToString("F2")"
fill="none"
stroke="#1976d2"
stroke-width="@(0.1 * baseScale).ToString(" F3")"
stroke-dasharray="0.15,0.1"
opacity="0.8" />
}
@* Robot name (if ShowName = true) *@
@if (State.ShowName)
{
@* Get robot name from AvailableRobots *@
var robotName = State.AvailableRobots.FirstOrDefault(r => r.RobotId == robot.RobotId)?.Name ?? robot.RobotId;
var isSelected = State.SelectedRobotId == robot.RobotId;
var fontSize = 0.4 / Math.Sqrt(State.Viewport.ZoomLevel / 1.5);
var textColor = isSelected ? "#9C27B0" : "#3F51B5";
var fontWeight = isSelected ? "bold" : "500";
var offset = 0.6 * baseScale;
@RenderSvgText(robotName, svgPos.X, svgPos.Y + offset, fontSize, textColor, fontWeight)
}
}
</g>
@code {
[Parameter]
public RobotMonitorState State { get; set; } = default!;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
if (!firstRender) return;
State.OnDataChanged += StateHasChanged;
}
public void Dispose()
{
State.OnDataChanged -= StateHasChanged;
}
private void HandleRobotClick(string robotId)
{
State.SelectRobot(robotId);
}
/// <summary>
/// Render SVG text element
/// </summary>
private RenderFragment RenderSvgText(string content, double x, double y, double fontSize, string fillColor, string fontWeight) => builder =>
{
builder.OpenElement(0, "text");
builder.AddAttribute(1, "letter-spacing", "-0.01em");
builder.AddAttribute(2, "x", x.ToString("F2"));
builder.AddAttribute(3, "y", y.ToString("F2"));
builder.AddAttribute(4, "font-size", fontSize.ToString("F3"));
builder.AddAttribute(5, "fill", fillColor);
builder.AddAttribute(6, "text-anchor", "middle");
builder.AddAttribute(7, "font-family", "Segoe UI");
builder.AddAttribute(8, "font-weight", fontWeight);
builder.AddAttribute(9, "pointer-events", "none");
builder.AddContent(10, content);
builder.CloseElement();
};
public string UpdatePath(Shared.DTOs.Robot.NavigationPathEdge[] path)
{
if (path.Length > 0)
{
var startSvg = State.WorldToSvg(path[0].StartX, path[0].StartY);
var inPath = $"M {startSvg.X} {startSvg.Y}";
for (int i = 0; i < path.Length; i++)
{
var endSvg = State.WorldToSvg(path[i].EndX, path[i].EndY);
var cp1Svg = State.WorldToSvg(path[i].ControlPoint1X, path[i].ControlPoint1Y);
var cp2Svg = State.WorldToSvg(path[i].ControlPoint2X, path[i].ControlPoint2Y);
if (path[i].Degree == 1) inPath = $"{inPath} L {endSvg.X} {endSvg.Y}";
else if (path[i].Degree == 2) inPath = $"{inPath} Q {cp1Svg.X} {cp1Svg.Y} {endSvg.X} {endSvg.Y}";
else inPath = $"{inPath} C {cp1Svg.X} {cp1Svg.Y} , {cp2Svg.X} {cp2Svg.Y}, {endSvg.X} {endSvg.Y}";
}
return inPath;
}
else return "";
}
}

View File

@@ -0,0 +1,27 @@
/* Robot selection highlight animation */
@keyframes pulse {
0%, 100% {
opacity: 0.6;
stroke-width: 0.08px;
}
50% {
opacity: 1;
stroke-width: 0.12px;
}
}
.robot-selection-highlight {
animation: pulse 1.5s ease-in-out infinite;
stroke-dasharray: 0.15, 0.1;
}
/* Path visualization */
.robot-path {
transition: stroke-opacity 0.3s ease;
}
.robot-path:hover {
stroke-opacity: 1;
}

View File

@@ -0,0 +1,197 @@
@using MudBlazor
@using RobotNet10.FleetManager.Client.Services
<div class="pa-2 monitor-toolbar">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<!-- Viewport Controls -->
<MudTooltip Text="Zoom In">
<MudIconButton Icon="@Icons.Material.Filled.ZoomIn"
Size="Size.Medium"
Color="Color.Primary"
Variant="Variant.Outlined"
OnClick="HandleZoomIn" />
</MudTooltip>
<MudTooltip Text="Zoom Out">
<MudIconButton Icon="@Icons.Material.Filled.ZoomOut"
Size="Size.Medium"
Color="Color.Primary"
Variant="Variant.Outlined"
OnClick="HandleZoomOut" />
</MudTooltip>
<MudTooltip Text="Fit to Screen">
<MudIconButton Icon="@Icons.Material.Filled.FitScreen"
Size="Size.Medium"
Color="Color.Primary"
Variant="Variant.Outlined"
OnClick="HandleFitScale" />
</MudTooltip>
<MudTooltip Text="Focus on Robot">
<MudIconButton Icon="@Icons.Material.Filled.CenterFocusStrong"
Size="Size.Medium"
Color="Color.Primary"
Variant="Variant.Outlined"
OnClick="HandleFocus"
Disabled="@(State.SelectedRobotId == null)" />
</MudTooltip>
<MudDivider Vertical="true" />
<!-- Display Options -->
<MudCheckBox @bind-Value="State.FollowRobot"
Label="Follow Robot"
T=bool
Dense
Size="Size.Small" />
<MudCheckBox @bind-Value="State.ShowPath"
Label="Path"
T=bool
Dense
Size="Size.Small" />
<MudCheckBox @bind-Value="State.ShowName"
Label="Name"
T=bool
Dense
Size="Size.Small" />
<MudCheckBox @bind-Value="State.ShowGrid"
@bind-Value:after="() => State.NotifyStateChanged()"
Label="Grid"
T=bool
Dense
Size="Size.Small" />
<MudDivider Vertical="true" />
<!-- Layout SelectBox -->
<MudSelect Value="@State.SelectedLayoutId"
ValueChanged="@HandleLayoutChanged"
Label="Layout"
Variant="Variant.Outlined"
T="Guid ?"
Dense="true"
Margin="Margin.Dense"
Style="min-width: 150px;">
<MudSelectItem Value="@((Guid?)null)">-- Select Layout --</MudSelectItem>
@foreach (var layout in State.Layouts)
{
<MudSelectItem Value="@((Guid?)layout.Id)">@(layout.LayoutName)</MudSelectItem>
}
</MudSelect>
<!-- Version SelectBox -->
<MudSelect Value="@State.SelectedVersionId"
ValueChanged="@HandleVersionChanged"
Label="Version"
Variant="Variant.Outlined"
T="Guid ?"
Dense="true"
Margin="Margin.Dense"
Style="min-width: 120px;"
Disabled="@(!State.SelectedLayoutId.HasValue)">
<MudSelectItem Value="@((Guid?)null)">-- Select Version --</MudSelectItem>
@foreach (var version in State.AvailableVersions)
{
<MudSelectItem Value="@((Guid?)version.Id)">@version.Version</MudSelectItem>
}
</MudSelect>
<!-- Level SelectBox -->
<MudSelect Value="@State.SelectedLevelId"
ValueChanged="@HandleLevelChanged"
Label="Level"
Variant="Variant.Outlined"
T="Guid ?"
Dense="true"
Margin="Margin.Dense"
Style="min-width: 120px;"
Disabled="@(!State.SelectedVersionId.HasValue)">
<MudSelectItem Value="@((Guid?)null)">-- Select Level --</MudSelectItem>
@foreach (var level in State.AvailableLevels)
{
<MudSelectItem Value="@((Guid?)level.Id)">@level.LayoutLevelId</MudSelectItem>
}
</MudSelect>
<!-- Robot SelectBox (only online robots) -->
<MudSelect Value="@State.SelectedRobotId"
ValueChanged="@HandleRobotChanged"
Label="Robot"
Variant="Variant.Outlined"
T="string"
Dense="true"
Margin="Margin.Dense"
Style="min-width: 150px;">
<MudSelectItem T="string" Value="@(string.Empty)">-- Select Robot --</MudSelectItem>
@foreach (var robot in State.Robots.Values.OrderBy(r => r.RobotId))
{
@* Get robot name from AvailableRobots if available *@
var robotName = State.AvailableRobots.FirstOrDefault(ar => ar.RobotId == robot.RobotId)?.Name ?? robot.RobotId;
<MudSelectItem T="string" Value="@robot.RobotId">@robotName (@robot.RobotId)</MudSelectItem>
}
</MudSelect>
<!-- Expand/Collapse Panel Button (cạnh phía InfoPanel) -->
<MudSpacer />
<MudTooltip Text="@(State.RobotInfoPanelExpanded ? "Collapse Panel" : "Expand Panel")">
<MudIconButton Icon="@(State.RobotInfoPanelExpanded? Icons.Material.Filled.ChevronRight : Icons.Material.Filled.ChevronLeft)"
OnClick="HandleTogglePanel"
Color="Color.Success"
Variant="Variant.Outlined" />
</MudTooltip>
</MudStack>
</div>
@code {
[Parameter]
public RobotMonitorState State { get; set; } = null!;
private void HandleZoomIn()
{
State.ZoomAtCenter(1.2);
}
private void HandleZoomOut()
{
State.ZoomAtCenter(1.0 / 1.2);
}
private void HandleFitScale()
{
State.FitToScreen();
}
private void HandleFocus()
{
if (State.SelectedRobotId != null)
{
State.FocusOnRobot(State.SelectedRobotId);
}
}
private void HandleTogglePanel()
{
State.ToggleRobotInfoPanel();
}
private async Task HandleLayoutChanged(Guid? layoutId)
{
State.SelectedLayoutId = layoutId;
await State.OnLayoutSelectedAsync(layoutId);
}
private async Task HandleVersionChanged(Guid? versionId)
{
State.SelectedVersionId = versionId;
await State.OnVersionSelectedAsync(versionId);
}
private async Task HandleLevelChanged(Guid? levelId)
{
State.SelectedLevelId = levelId;
await State.OnLevelSelectedAsync(levelId);
}
private void HandleRobotChanged(string? robotId)
{
State.SelectRobot(robotId);
}
}

View File

@@ -0,0 +1,10 @@
/* Monitor Toolbar Styles */
.monitor-toolbar {
border-bottom: 1px solid var(--mud-palette-lines-default);
background-color: var(--mud-palette-surface);
flex-shrink: 0; /* Prevent toolbar from shrinking */
width: 100%; /* Full width */
overflow-x: auto; /* Allow horizontal scroll if needed */
overflow-y: hidden;
}

View File

@@ -0,0 +1,18 @@
<div class="mouse-position-display">
<span class="coord-label">X:</span>
<span class="coord-value">@X.ToString("F2") m</span>
<span class="coord-label">Y:</span>
<span class="coord-value">@Y.ToString("F2") m</span>
</div>
@code {
private double X { get; set; }
private double Y { get; set; }
public void Update(double x, double y)
{
X = x;
Y = y;
StateHasChanged();
}
}

View File

@@ -0,0 +1,27 @@
.mouse-position-display {
position: absolute;
top: 10px;
left: 10px;
z-index: 50;
background-color: rgba(33, 33, 33, 0.85);
color: white;
padding: 6px 12px;
border-radius: 4px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 12px;
display: flex;
gap: 8px;
align-items: center;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.coord-label {
color: #aaa;
font-weight: 500;
}
.coord-value {
color: #4fc3f7;
font-weight: bold;
min-width: 70px;
}

View File

@@ -0,0 +1,27 @@
@using MudBlazor
@using RobotNet10.FleetManager.Client.Services
<div class="pa-4 robot-info-panel">
<MudStack Spacing="3">
<!-- Header -->
<MudText Typo="Typo.h6" Class="mb-2">Robot Information</MudText>
@if (State.SelectedRobotId != null && State.Robots.TryGetValue(State.SelectedRobotId, out var robot))
{
<SelectedRobotInfo RobotData="robot" State="State" />
}
else
{
<MudAlert Severity="Severity.Info" Variant="Variant.Text" Class="mt-4">
<MudText Typo="Typo.body2">
No robot selected. Click on a robot on the map to view its information.
</MudText>
</MudAlert>
}
</MudStack>
</div>
@code {
[Parameter]
public RobotMonitorState State { get; set; } = null!;
}

View File

@@ -0,0 +1,45 @@
/* Robot Info Panel Styles */
.robot-info-panel {
height: 100%;
overflow-y: auto;
transition: width 0.3s ease-in-out;
background-color: var(--mud-palette-surface);
border-left: 1px solid var(--mud-palette-lines-default);
flex-shrink: 0;
width: 300px;
min-width: 200px;
max-width: 400px;
}
/* Info panel scrollbar styling */
.robot-info-panel::-webkit-scrollbar {
width: 8px;
}
.robot-info-panel::-webkit-scrollbar-track {
background: var(--mud-palette-background-grey);
}
.robot-info-panel::-webkit-scrollbar-thumb {
background: var(--mud-palette-text-disabled);
border-radius: 4px;
}
.robot-info-panel::-webkit-scrollbar-thumb:hover {
background: var(--mud-palette-text-secondary);
}
/* Responsive adjustments */
@media (max-width: 960px) {
.robot-info-panel {
position: absolute;
right: 0;
top: 0;
bottom: 0;
width: 100%;
max-width: 400px;
z-index: 100;
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1);
}
}

View File

@@ -0,0 +1,97 @@
@using MudBlazor
@using RobotNet10.FleetManager.Client.Services
@inject RobotMonitorState State
@implements IAsyncDisposable
<div class="robot-monitor-container">
@if (State.IsLoading)
{
<MudPaper Class="pa-4" Elevation="1">
<MudStack AlignItems="AlignItems.Center" Spacing="3">
<MudProgressCircular Color="Color.Primary" Indeterminate="true" Size="Size.Large" />
<MudText Typo="Typo.body1">Loading robot monitor...</MudText>
</MudStack>
</MudPaper>
}
else if (!string.IsNullOrEmpty(State.ErrorMessage))
{
<MudPaper Class="pa-4" Elevation="1">
<MudStack Spacing="3">
<MudAlert Severity="Severity.Error" Variant="Variant.Filled">
<MudText Typo="Typo.h6">Error</MudText>
<MudText Typo="Typo.body2">@State.ErrorMessage</MudText>
</MudAlert>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Refresh"
OnClick="HandleReload">
Retry
</MudButton>
</MudStack>
</MudPaper>
}
else
{
<!-- Top: Toolbar (full width) -->
<MonitorToolbar State="@State" />
<!-- Bottom: Canvas and Info Panel -->
<div class="monitor-main-content">
<!-- Left: Canvas -->
<SvgMonitorCanvas State="@State" />
<!-- Right: Robot Info Panel -->
@if (State.RobotInfoPanelExpanded)
{
<RobotInfoPanel State="@State" />
}
</div>
<!-- Overlay for deactivated monitor -->
@if (State.IsMonitorDeactivated)
{
<div class="monitor-deactivated-overlay">
<MudPaper Class="pa-6" Elevation="10">
<MudStack AlignItems="AlignItems.Center" Spacing="4">
<MudIcon Icon="@Icons.Material.Filled.Block" Size="Size.Large" Color="Color.Error" />
<MudText Typo="Typo.h5">Monitor Deactivated</MudText>
<MudText Typo="Typo.body1" Align="Align.Center">
Maximum 5 connections per level reached.<br />
Another connection has taken your place.
</MudText>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Refresh"
OnClick="HandleReload">
Reconnect
</MudButton>
</MudStack>
</MudPaper>
</div>
}
}
</div>
@code {
protected override async Task OnInitializedAsync()
{
State.OnStateChanged += HandleStateChanged;
await State.InitializeAsync();
}
public async ValueTask DisposeAsync()
{
State.OnStateChanged -= HandleStateChanged;
await State.CleanupAsync();
}
private void HandleStateChanged()
{
InvokeAsync(StateHasChanged);
}
private async Task HandleReload()
{
await State.InitializeAsync();
}
}

View File

@@ -0,0 +1,31 @@
/* Robot Monitor Component Styles */
.robot-monitor-container {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
overflow: hidden;
background-color: var(--mud-palette-background);
}
.monitor-main-content {
display: flex;
flex: 1;
overflow: hidden;
min-height: 0; /* Important for flex child overflow */
}
.monitor-deactivated-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
pointer-events: all;
}

View File

@@ -0,0 +1,200 @@
@using MudBlazor
@using RobotNet.VDA5050.State
@using RobotNet.VDA5050.Type
@using RobotNet10.FleetManager.Client.Components.RobotDetail
@using RobotNet10.FleetManager.Client.Components.RobotMonitor
@using RobotNet10.FleetManager.Client.Services
@implements IDisposable
<MudStack Spacing="3">
<!-- Robot Header Info -->
<MudPaper Class="pa-3" Elevation="1" Style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);">
<MudStack Spacing="2">
<MudText Typo="Typo.h6" Style="color: white;">
@GetRobotName()
</MudText>
<MudText Typo="Typo.body2" Style="color: rgba(255, 255, 255, 0.8);">
ID: @RobotData.RobotId
</MudText>
@if (RobotData.Model != null)
{
<MudChip T="string" Size="Size.Small" Style="background: rgba(255, 255, 255, 0.2); color: white;">
@RobotData.Model.ModelName
</MudChip>
}
@if (RobotData.LastUpdateTime != default)
{
<MudText Typo="Typo.caption" Style="color: rgba(255, 255, 255, 0.7);">
Last update: @RobotData.LastUpdateTime.ToLocalTime().ToString("HH:mm:ss")
</MudText>
}
</MudStack>
</MudPaper>
<!-- Position Info (Quick View) -->
@if (RobotData.Data != null)
{
<MudPaper Class="pa-3" Elevation="1">
<MudText Typo="Typo.subtitle2" Class="mb-2">Visualization</MudText>
<MudSimpleTable Dense="true" Elevation="0">
<tbody>
<tr>
<td><strong>X:</strong></td>
<td>@RobotData.Data.AgvPosition.X.ToString("F2") m</td>
</tr>
<tr>
<td><strong>Y:</strong></td>
<td>@RobotData.Data.AgvPosition.Y.ToString("F2") m</td>
</tr>
<tr>
<td><strong>Θ:</strong></td>
<td>@((RobotData.Data.AgvPosition.Theta * 180.0 / Math.PI).ToString("F1"))°</td>
</tr>
<tr>
<td><strong>Vx:</strong></td>
<td>@RobotData.Data.AgvVelocity.Vx.ToString("F2") m/s</td>
</tr>
<tr>
<td><strong>Vy:</strong></td>
<td>@RobotData.Data.AgvVelocity.Vy.ToString("F2") m/s</td>
</tr>
<tr>
<td><strong>Omega:</strong></td>
<td>@RobotData.Data.AgvVelocity.Omega.ToString("F2") rad/s</td>
</tr>
<tr>
<td><strong>Position Initialized:</strong></td>
<td>
<MudChip T="string"
Size="Size.Small"
Color="@(RobotData.Data.AgvPosition.PositionInitialized ? Color.Success : Color.Warning)">
@(RobotData.Data.AgvPosition.PositionInitialized ? "Yes" : "No")
</MudChip>
</td>
</tr>
@if (RobotData.Data.AgvPosition.LocalizationScore >= 0)
{
<tr>
<td><strong>Localization Score:</strong></td>
<td>@RobotData.Data.AgvPosition.LocalizationScore.ToString("F2")</td>
</tr>
}
@if (RobotData.Data.AgvPosition.DeviationRange >= 0)
{
<tr>
<td><strong>Deviation Range:</strong></td>
<td>@RobotData.Data.AgvPosition.DeviationRange.ToString("F2") m</td>
</tr>
}
</tbody>
</MudSimpleTable>
</MudPaper>
}
<!-- Expansion Panels for Detailed Info -->
<MudExpansionPanels Elevation="0" MultiExpansion="true" Gutters="false">
<!-- Battery State Panel -->
<MudExpansionPanel Icon="@Icons.Material.Filled.BatteryChargingFull"
Expanded="true">
<TitleContent>
<MudText>Battery State</MudText>
</TitleContent>
<ChildContent>
<BatteryCard @ref="BatteryCardRef" ShowNameCard="false"/>
</ChildContent>
</MudExpansionPanel>
<!-- Errors Panel -->
<MudExpansionPanel Icon="@Icons.Material.Filled.Error"
Expanded="false">
<TitleContent>
<div class="d-flex">
<MudText>Errors</MudText>
<MudBadge Content="Errors.Length" Color="Color.Info" Overlap="true" Class="d-flex ml-auto">
<MudIcon Icon="@Icons.Material.Filled.Info" Color="Color.Secondary" />
</MudBadge>
</div>
</TitleContent>
<ChildContent>
<MudPaper Class="pa-4" Elevation="2">
@foreach (var error in Errors)
{
<MudTooltip Text="@error.ErrorDescription" Placement="Placement.Top" Color="Color.Info">
<MudButton Class="m-2" Color="@(error.ErrorLevel == ErrorLevel.FATAL ? Color.Error : Color.Warning)" Variant="Variant.Filled" Size="Size.Small" Style="text-transform:none">@error.ErrorType</MudButton>
</MudTooltip>
}
</MudPaper>
</ChildContent>
</MudExpansionPanel>
<!-- Information Panel -->
<MudExpansionPanel Icon="@Icons.Material.Filled.Info"
Expanded="false">
<TitleContent>
<div class="d-flex">
<MudText>Notification</MudText>
<MudBadge Content="Information.Length" Color="Color.Info" Overlap="true" Class="d-flex ml-auto">
<MudIcon Icon="@Icons.Material.Filled.Notifications" Color="Color.Warning" />
</MudBadge>
</div>
</TitleContent>
<ChildContent>
<MudPaper Class="pa-4" Elevation="2">
<div class="d-flex flex-column">
@foreach (var info in Information)
{
<MudTooltip Text="@info.InfoDescription" Placement="Placement.Top" Color="Color.Info">
<MudButton Class="m-2" Color="@(info.InfoLevel == InfoLevel.INFO ? Color.Info : Color.Default)" Variant="Variant.Filled" Size="Size.Small" Style="text-transform:none">@info.InfoType</MudButton>
</MudTooltip>
}
</div>
</MudPaper>
</ChildContent>
</MudExpansionPanel>
</MudExpansionPanels>
</MudStack>
@code {
[Parameter]
public RobotMonitorData RobotData { get; set; } = null!;
[Parameter]
public RobotMonitorState State { get; set; } = null!;
private BatteryCard BatteryCardRef = default!;
private Error[] Errors = [];
private Information[] Information = [];
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
if (!firstRender) return;
State.OnDataChanged += OnDataChanged;
}
private void OnDataChanged()
{
if (State.SelectedRobotId != null && State.Robots.TryGetValue(State.SelectedRobotId, out var robot))
{
BatteryCardRef.Update(robot.Data?.Battery);
Errors = robot.Data?.Errors ?? [];
Information = robot.Data?.Infomations ?? [];
RobotData = robot;
StateHasChanged();
}
}
public void Dispose()
{
State.OnDataChanged -= OnDataChanged;
}
private string GetRobotName()
{
// Try to get robot name from AvailableRobots
var robot = State.AvailableRobots.FirstOrDefault(r => r.RobotId == RobotData.RobotId);
return robot?.Name ?? RobotData.RobotId;
}
}

View File

@@ -0,0 +1,275 @@
@using Microsoft.JSInterop
@using RobotNet10.FleetManager.Client.Services
@using RobotNet10.MapEditor.Shared.DTOs.Edge
@using RobotNet10.MapEditor.Shared.DTOs.Node
@inject IJSRuntime JSRuntime
@implements IAsyncDisposable
<div class="svg-monitor-container" @ref="containerRef">
<!-- Mouse Position Display -->
<MousePositionDisplay @ref="MousePositionDisplayRef" />
<svg @ref="svgRef"
id="monitor-svg"
class="monitor-svg"
viewBox="@State.Viewport.ToViewBoxString()"
preserveAspectRatio="xMidYMid meet">
<!-- SVG Markers -->
<marker id="arrowhead" markerWidth="5" markerHeight="5" refX="0" refY="1.5" orient="auto-start-reverse">
<polygon class="edge-arrow"
points="0 0, 3 1.5, 0 3"
fill="#4caf50" />
</marker>
<marker id="originvector" markerWidth="2.4" markerHeight="2.4" refX="0.4" refY="2">
<line x1="0" y1="2" x2="2" y2="2" stroke="red" stroke-width="0.15" />
<path d="M 2 2.2 L 2.4 2 L 2 1.8 Z" fill="red" stroke-width="0" />
<line x1="0.4" y1="2.4" x2="0.4" y2="0.4" stroke="blue" stroke-width="0.15" />
<path d="M 0.6 0.4 L 0.4 0 L 0.2 0.4 Z" fill="blue" stroke-width="0" />
</marker>
<!-- Layer 1: Background Image -->
@if (State.ShowBackgroundImage && State.BackgroundImage != null && State.Level?.EditorSettings != null)
{
var (physicalWidth, physicalHeight) = State.GetPhysicalDimensions();
var imageDataUrl = $"data:image/png;base64,{Convert.ToBase64String(State.BackgroundImage)}";
<image href="@imageDataUrl"
x="0"
y="0"
width="@physicalWidth.ToString("F2")"
height="@physicalHeight.ToString("F2")"
preserveAspectRatio="none"
style="image-rendering: pixelated"/>
}
<!-- Layer 2: Grid -->
@if (State.ShowGrid && State.Level?.EditorSettings != null)
{
var (physicalWidth, physicalHeight) = State.GetPhysicalDimensions();
var settings = State.Level.EditorSettings;
var originX = settings.OriginX;
var originY = settings.OriginY;
var gridSpacing = 1.0; // Default grid spacing
<g id="grid-layer" stroke="#808080" stroke-width="0.04" opacity="0.7" stroke-dasharray="0.1,0.1">
@* Vertical lines *@
@{
var worldMinX = originX;
var worldMaxX = originX + physicalWidth;
var firstGridXWorld = Math.Floor(worldMinX / gridSpacing) * gridSpacing;
var lastGridXWorld = Math.Ceiling(worldMaxX / gridSpacing) * gridSpacing;
for (double worldX = firstGridXWorld; worldX <= lastGridXWorld; worldX += gridSpacing)
{
var svgX = State.WorldToSvg(worldX, 0).X;
if (svgX >= 0 && svgX <= physicalWidth)
{
<line x1="@svgX.ToString("F2")" y1="0" x2="@svgX.ToString("F2")" y2="@physicalHeight.ToString("F2")" />
}
}
}
@* Horizontal lines *@
@{
var worldMinY = originY;
var worldMaxY = originY + physicalHeight;
var firstGridYWorld = Math.Floor(worldMinY / gridSpacing) * gridSpacing;
var lastGridYWorld = Math.Ceiling(worldMaxY / gridSpacing) * gridSpacing;
for (double worldY = firstGridYWorld; worldY <= lastGridYWorld; worldY += gridSpacing)
{
var svgY = State.WorldToSvg(0, worldY).Y;
if (svgY >= 0 && svgY <= physicalHeight)
{
<line x1="0" y1="@svgY.ToString("F2")" x2="@physicalWidth.ToString("F2")" y2="@svgY.ToString("F2")" />
}
}
}
</g>
}
<!-- Origin Vector (after grid, before edges) -->
@if (State.Level is not null && State.Level.EditorSettings != null)
{
var (_, physicalHeight) = State.GetPhysicalDimensions();
var svgOriginY = physicalHeight + State.Level.EditorSettings.OriginY;
var width = 1.0 / State.Viewport.ZoomLevel;
<line x1="@(-State.Level.EditorSettings.OriginX)" y1="@(svgOriginY)" x2="@(-State.Level.EditorSettings.OriginX)" y2="@(svgOriginY)" fill="none" marker-end="url(#originvector)" stroke-width="@width" />
}
<!-- Layer 3: Edges -->
<g id="edges-layer">
@foreach (var edge in State.Edges)
{
var startNode = State.Nodes.FirstOrDefault(n => n.Id == edge.StartNodeId);
var endNode = State.Nodes.FirstOrDefault(n => n.Id == edge.EndNodeId);
if (startNode != null && endNode != null)
{
var startSvg = State.WorldToSvg(startNode.X, startNode.Y);
var endSvg = State.WorldToSvg(endNode.X, endNode.Y);
<line x1="@startSvg.X.ToString("F2")"
y1="@startSvg.Y.ToString("F2")"
x2="@endSvg.X.ToString("F2")"
y2="@endSvg.Y.ToString("F2")"
stroke="#4caf50"
stroke-width="0.07"
fill="none" />
}
}
</g>
<!-- Layer 4: Nodes -->
<g id="nodes-layer">
@foreach (var node in State.Nodes)
{
var svgPos = State.WorldToSvg(node.X, node.Y);
var nodeRadius = 0.25 / Math.Sqrt(State.Viewport.ZoomLevel / 1.5);
<circle cx="@svgPos.X.ToString("F2")"
cy="@svgPos.Y.ToString("F2")"
r="@nodeRadius.ToString("F3")"
fill="#2196f3"
stroke="#fff"
stroke-width="0.03" />
}
</g>
<RobotNet10.FleetManager.Client.Components.RobotMonitor.Element.RobotView State="State" />
</svg>
</div>
@code {
[Parameter]
public RobotMonitorState State { get; set; } = null!;
private ElementReference containerRef;
private ElementReference svgRef;
private IJSObjectReference? jsModule;
private DotNetObjectReference<SvgMonitorCanvas>? dotNetRef;
// Pan state
private bool isPanning;
private (double X, double Y)? panLastScreen; // Last screen coordinates (for incremental delta calculation)
private MousePositionDisplay MousePositionDisplayRef = default!;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
dotNetRef = DotNetObjectReference.Create(this);
try
{
jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>(
"import", "./js/svgMonitor.js");
await jsModule.InvokeVoidAsync("initMonitor", svgRef, dotNetRef);
}
catch (Exception ex)
{
Console.WriteLine($"Failed to initialize JS module: {ex.Message}");
}
}
}
public async ValueTask DisposeAsync()
{
if (jsModule != null)
{
try
{
await jsModule.InvokeVoidAsync("disposeMonitor");
await jsModule.DisposeAsync();
}
catch { }
}
dotNetRef?.Dispose();
}
// Called from JavaScript
[JSInvokable]
public async Task OnMouseMove(double svgX, double svgY, double screenX = 0, double screenY = 0)
{
// Update mouse position (world coordinates)
var (worldX, worldY) = State.SvgToWorld(svgX, svgY);
MousePositionDisplayRef.Update(worldX, worldY);
// Update pan - use incremental delta to avoid accumulation issues
// Key insight: When panning, ViewBox changes after each pan, which changes SVG coordinates
// of the same screen point. If we calculate delta from the start point each time,
// we get cumulative error because the start point's SVG coordinates change.
// Solution: Calculate delta incrementally from the last mouse position, not from start
if (isPanning && panLastScreen.HasValue)
{
// Calculate incremental delta: from last position to current position
// This avoids accumulation because we're always calculating relative to the last move
if (jsModule != null)
{
await PanIncrementalAsync(panLastScreen.Value.X, panLastScreen.Value.Y, screenX, screenY);
}
// Update last position for next move
panLastScreen = (screenX, screenY);
}
}
private async Task PanIncrementalAsync(double lastScreenX, double lastScreenY, double currentScreenX, double currentScreenY)
{
if (jsModule == null) return;
try
{
// Convert last and current screen positions to SVG coordinates
// using the CURRENT viewBox (before this pan)
var lastSvg = await jsModule.InvokeAsync<double[]>("screenToSvgArray", lastScreenX, lastScreenY);
var currentSvg = await jsModule.InvokeAsync<double[]>("screenToSvgArray", currentScreenX, currentScreenY);
if (lastSvg.Length >= 2 && currentSvg.Length >= 2)
{
// Calculate incremental delta: how much the mouse moved since last position
// Pan moves viewBox in opposite direction of mouse movement
var dx = lastSvg[0] - currentSvg[0];
var dy = lastSvg[1] - currentSvg[1];
State.Pan(dx, dy);
}
}
catch (Exception ex)
{
Console.WriteLine($"Error in PanIncrementalAsync: {ex.Message}");
}
}
[JSInvokable]
public void OnMouseDown(double svgX, double svgY, int button, double screenX = 0, double screenY = 0)
{
// Middle mouse button (button 1) - start pan
if (button == 1)
{
isPanning = true;
panLastScreen = (screenX, screenY);
}
}
[JSInvokable]
public void OnMouseUp(double svgX, double svgY, int button)
{
// End pan
if (button == 1)
{
isPanning = false;
panLastScreen = null;
}
}
[JSInvokable]
public void OnWheel(double svgX, double svgY, double deltaY)
{
// Use same zoom factor as LayoutEditor for consistency
var factor = deltaY > 0 ? 0.9 : 1.1;
State.Zoom(factor, svgX, svgY);
}
}

View File

@@ -0,0 +1,24 @@
/* SVG Monitor Canvas Styles */
.svg-monitor-container {
flex: 1;
position: relative;
overflow: hidden;
background-color: #808080;
border: 1px solid var(--mud-palette-lines-default);
min-width: 0; /* Important for flex child overflow */
min-height: 0; /* Important for flex child overflow */
width: 100%;
height: 100%;
}
.monitor-svg {
width: 100%;
height: 100%;
display: block;
cursor: default;
}
.monitor-svg:active {
cursor: grabbing;
}