Initial commit
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 "";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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!;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using Microsoft.AspNetCore.Components.Routing;
|
||||
using RobotNet10.Components;
|
||||
|
||||
namespace RobotNet10.FleetManager.Client;
|
||||
|
||||
public static class Extensions
|
||||
{
|
||||
extension(IServiceCollection services)
|
||||
{
|
||||
public void AddNavigationMenu(string version = "dev")
|
||||
{
|
||||
services.AddSingleton(sp => new OptionLayout()
|
||||
{
|
||||
AppName = "FleetManager",
|
||||
Version = version,
|
||||
NavModels = [
|
||||
new("mdi-view-dashboard", "/", "Dashboard", NavLinkMatch.All),
|
||||
new("mdi-map", "/layout-manager", "Map Editor", NavLinkMatch.All),
|
||||
new("mdi-robot-mower-outline", "/vehicle-manager", "Vehicle", NavLinkMatch.All),
|
||||
new("mdi-robot-industrial", "/robot-models", "RobotModel", NavLinkMatch.All),
|
||||
new("mdi-robot", "/robots", "Robot", NavLinkMatch.All),
|
||||
new("mdi-monitor-eye", "/robots/monitor", "Monitor", NavLinkMatch.All),
|
||||
new("mdi-file-code", "/programming", "Programming", NavLinkMatch.All),
|
||||
new("mdi-flag-checkered", "/missions", "Missions", NavLinkMatch.All),
|
||||
new("mdi-application-cog-outline", "/config-manager", "Configuration", NavLinkMatch.All),
|
||||
new("mdi-math-log", "/logs", "Logs", NavLinkMatch.All),
|
||||
]
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
@page "/config-manager"
|
||||
@rendermode RobotNet10.Components.RenderMode.InteractiveWebAssemblyNoPrerender
|
||||
|
||||
<PageTitle>Configuration Manager</PageTitle>
|
||||
|
||||
<RobotNet10.CustomConfigurationEditor.Components.ConfigManager.ConfigManagerComponent />
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
@@ -0,0 +1,16 @@
|
||||
@page "/layout-editor/{LevelId:guid}"
|
||||
@rendermode RobotNet10.Components.RenderMode.InteractiveWebAssemblyNoPrerender
|
||||
|
||||
<PageTitle>Layout Editor</PageTitle>
|
||||
|
||||
<RobotNet10.MapEditor.Components.LayoutEditor.LayoutEditorComponent LevelId="@LevelId" />
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public Guid LevelId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
@page "/layout-manager"
|
||||
@rendermode InteractiveWebAssemblyNoPrerender
|
||||
|
||||
<PageTitle>Layout Manager</PageTitle>
|
||||
|
||||
<RobotNet10.MapEditor.Components.LayoutManager.LayoutManagerComponent />
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
@@ -0,0 +1,225 @@
|
||||
@page "/logs"
|
||||
@rendermode InteractiveWebAssemblyNoPrerender
|
||||
|
||||
@using Microsoft.AspNetCore.Components.WebAssembly.Authentication
|
||||
@using System.Text.Json.Serialization
|
||||
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject HttpClient Http
|
||||
@inject IConfiguration Configuration
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<PageTitle>Logs</PageTitle>
|
||||
|
||||
<div class="w-100 h-100 d-flex flex-column">
|
||||
<div class="d-flex flex-row align-items-center justify-content-between" style="border-bottom: 1px solid silver">
|
||||
<MudTextField Class="mt-1 ms-2" T="string" Value="FilterLog" Adornment="Adornment.End" ValueChanged="OnSearch" AdornmentIcon="@Icons.Material.Filled.Search"
|
||||
IconSize="Size.Medium" Variant="Variant.Outlined" Margin="Margin.Dense" AdornmentColor="Color.Secondary" Label="Search"></MudTextField>
|
||||
<MudSpacer />
|
||||
<div class="m-1 d-flex flex-row">
|
||||
<MudDatePicker Class="mx-4" Label="Date" Date="DateLog" DateChanged="OnDateChanged" MaxDate="DateTime.Today" Variant="Variant.Outlined" Color="Color.Primary"
|
||||
ShowToolbar="false" Margin="Margin.Dense" AdornmentColor="Color.Primary" />
|
||||
|
||||
<MudTooltip Text="Export">
|
||||
<MudFab Class="mt-2" Color="Color.Info" StartIcon="@Icons.Material.Filled.ImportExport" Size="Size.Small" OnClick="ExportLogs" />
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Refresh">
|
||||
<MudFab Class="mx-4 mt-2" StartIcon="@Icons.Material.Filled.Refresh" Color="Color.Primary" Size="Size.Small" OnClick="LoadLogs" />
|
||||
</MudTooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow-1 mt-2 ms-2 position-relative" style="background-color: rgba(0, 0, 0, 0);">
|
||||
<MudOverlay Visible="IsLoading" DarkBackground="true" Absolute="true">
|
||||
<MudProgressCircular Color="Color.Info" Indeterminate="true" />
|
||||
</MudOverlay>
|
||||
<div class="h-100 w-100 position-relative">
|
||||
<div class="log-container" @ref="LogContainerRef">
|
||||
@if (ShowRawLog)
|
||||
{
|
||||
<div class="d-flex justify-content-center my-3">
|
||||
<div><MudButton Variant="Variant.Outlined" Size="Size.Small" OnClick="@(() => ShowRawLog = false)">Normal</MudButton></div>
|
||||
</div>
|
||||
<big style="font-size: 14px;">
|
||||
@foreach (var log in ShowLogs)
|
||||
{
|
||||
@log <br />
|
||||
}
|
||||
</big>
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (SearchLogs.Count < ShowLogs.Count)
|
||||
{
|
||||
<div class="d-flex justify-content-center my-3">
|
||||
<div><MudButton Variant="Variant.Outlined" Size="Size.Small" OnClick="@(() => ShowRawLog = true)">Raw log</MudButton></div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@foreach (var log in SearchLogs)
|
||||
{
|
||||
<div class="log">
|
||||
<span class="log-head @log.BackgroundClass">
|
||||
@log.Time <span class="log-level">@log.Level</span>
|
||||
</span>
|
||||
<span>@log.Message</span>
|
||||
@if (log.HasException)
|
||||
{
|
||||
<br />
|
||||
<pre class="log-exception">
|
||||
@log.Exception
|
||||
</pre>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.ScrollToBottom = (element) => {
|
||||
if (element) {
|
||||
element.scrollTop = element.scrollHeight;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
@code {
|
||||
private DateTime DateLog = DateTime.Today;
|
||||
private bool IsLoading;
|
||||
private readonly List<string> ShowLogs = new();
|
||||
private readonly List<LoggerModel> SearchLogs = new();
|
||||
private ElementReference LogContainerRef { get; set; }
|
||||
private bool ShowRawLog { get; set; }
|
||||
private string? FilterLog { get; set; }
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
await base.OnAfterRenderAsync(firstRender);
|
||||
if (!firstRender) return;
|
||||
|
||||
await LoadLogs();
|
||||
}
|
||||
|
||||
private async Task LoadLogs()
|
||||
{
|
||||
try
|
||||
{
|
||||
IsLoading = true;
|
||||
ShowLogs.Clear();
|
||||
StateHasChanged();
|
||||
|
||||
var logs = await Http.GetFromJsonAsync<IEnumerable<string>>($"api/LogsManager?date={DateLog}");
|
||||
ShowLogs.AddRange(logs ?? []);
|
||||
|
||||
IsLoading = false;
|
||||
StateHasChanged();
|
||||
|
||||
await ReloadLogs();
|
||||
}
|
||||
catch (AccessTokenNotAvailableException ex)
|
||||
{
|
||||
ex.Redirect();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReloadLogs()
|
||||
{
|
||||
IsLoading = true;
|
||||
SearchLogs.Clear();
|
||||
StateHasChanged();
|
||||
|
||||
foreach (var line in ShowLogs.Where(log => string.IsNullOrEmpty(FilterLog) || log.Contains(FilterLog)).TakeLast(2000))
|
||||
{
|
||||
try
|
||||
{
|
||||
var log = System.Text.Json.JsonSerializer.Deserialize<LoggerModel>(line);
|
||||
if (log is not null) SearchLogs.Add(log);
|
||||
}
|
||||
catch (System.Text.Json.JsonException)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
IsLoading = false;
|
||||
StateHasChanged();
|
||||
await JSRuntime.InvokeVoidAsync("ScrollToBottom", LogContainerRef);
|
||||
}
|
||||
|
||||
private async Task OnSearch(string text)
|
||||
{
|
||||
FilterLog = text;
|
||||
await ReloadLogs();
|
||||
}
|
||||
|
||||
private async Task OnDateChanged(DateTime? date)
|
||||
{
|
||||
if (date is not null && date.HasValue)
|
||||
{
|
||||
DateLog = date.Value;
|
||||
await LoadLogs();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ExportLogs()
|
||||
{
|
||||
try
|
||||
{
|
||||
var fileContent = await Http.GetFromJsonAsync<IEnumerable<string>>($"api/LogsManager?date={DateLog}");
|
||||
var formattedContent = string.Join("\n", fileContent ?? []);
|
||||
var fileName = $"LogsManager_{DateLog.ToShortDateString()}.txt";
|
||||
await JSRuntime.InvokeVoidAsync("downloadFile", fileName, formattedContent, "text/plain");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Lỗi khi tải file: {ex.Message}", Severity.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
public class LoggerModel
|
||||
{
|
||||
[JsonPropertyName("time")]
|
||||
public string? Time { get; set; }
|
||||
|
||||
[JsonPropertyName("level")]
|
||||
public string? Level { get; set; }
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public string? Message { get; set; }
|
||||
|
||||
[JsonPropertyName("exception")]
|
||||
public string? Exception { get; set; }
|
||||
|
||||
public string ColorClass => Level switch
|
||||
{
|
||||
"WARN" => "text-warning",
|
||||
"INFO" => "text-info",
|
||||
"DEBUG" => "text-success",
|
||||
"ERROR" => "text-danger",
|
||||
"FATAL" => "text-secondary",
|
||||
_ => "text-muted",
|
||||
};
|
||||
|
||||
public string BackgroundClass => Level switch
|
||||
{
|
||||
"WARN" => "bg-warning text-dark",
|
||||
"INFO" => "bg-info text-dark",
|
||||
"DEBUG" => "bg-success text-white",
|
||||
"ERROR" => "bg-danger text-white",
|
||||
"FATAL" => "bg-secondary text-white",
|
||||
_ => "bg-dark text-white",
|
||||
};
|
||||
|
||||
public bool HasException => !string.IsNullOrEmpty(Exception);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
.log-container {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.log {
|
||||
word-wrap: break-word;
|
||||
line-height: 18px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.log-logger {
|
||||
color: rgba(0, 0, 0, 0.3);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.log-level {
|
||||
display: inline-block;
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.log-head {
|
||||
border-radius: 3px;
|
||||
padding: 2px 5px;
|
||||
}
|
||||
|
||||
.log-exception {
|
||||
line-height: 16px;
|
||||
margin-left: 30px;
|
||||
color: crimson;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
@page "/missions"
|
||||
@rendermode InteractiveWebAssemblyNoPrerender
|
||||
|
||||
<PageTitle>Mission Manager</PageTitle>
|
||||
|
||||
<RobotNet10.ScriptEditor.InstanceMissionManager />
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
@@ -0,0 +1,281 @@
|
||||
@page "/robots/{robotId}/detail"
|
||||
@rendermode RobotNet10.Components.RenderMode.InteractiveWebAssemblyNoPrerender
|
||||
@implements IAsyncDisposable
|
||||
|
||||
<PageTitle>Robot Detail - @robotName</PageTitle>
|
||||
|
||||
@using RobotNet10.FleetManager.Client.Services
|
||||
@using RobotNet10.FleetManager.Client.Components.RobotDetail
|
||||
@using RobotNet10.FleetManager.Shared.DTOs.Robot
|
||||
@using RobotNet.VDA5050.State
|
||||
@using RobotNet.VDA5050.Visualization
|
||||
@using Microsoft.AspNetCore.SignalR.Client
|
||||
@inject RobotApiService RobotApiService
|
||||
@inject RobotStateHubClient HubClient
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraExtraLarge" Class="mt-4">
|
||||
<!-- Header -->
|
||||
<MudPaper Class="pa-4 mb-4 align-content-center" MinHeight="80px">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Spacing="3">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ArrowBack" OnClick="NavigateBack" />
|
||||
<MudText Typo="Typo.h5">Robot Detail: @robotName</MudText>
|
||||
</MudStack>
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Refresh"
|
||||
Variant="Variant.Outlined"
|
||||
Color="Color.Primary"
|
||||
OnClick="HandleRefresh"
|
||||
Disabled="@isRefreshing">
|
||||
@if (isRefreshing)
|
||||
{
|
||||
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
|
||||
}
|
||||
Refresh
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
|
||||
@if (isLoadingRobot)
|
||||
{
|
||||
<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 information...</MudText>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
}
|
||||
else if (robot == null)
|
||||
{
|
||||
<MudPaper Class="pa-4" Elevation="1">
|
||||
<MudAlert Severity="Severity.Error" Variant="Variant.Filled">
|
||||
<MudText Typo="Typo.h6">Robot Not Found</MudText>
|
||||
<MudText Typo="Typo.body2">The robot with ID '@RobotId' could not be found.</MudText>
|
||||
<MudButton Variant="Variant.Text" Color="Color.Error" OnClick="NavigateBack" Class="mt-2">
|
||||
<MudIcon Icon="@Icons.Material.Filled.ArrowBack" Class="mr-2" />
|
||||
Go Back to Robot List
|
||||
</MudButton>
|
||||
</MudAlert>
|
||||
</MudPaper>
|
||||
}
|
||||
else
|
||||
{
|
||||
<!-- Main Content -->
|
||||
<MudGrid>
|
||||
<!-- Left: State Cards with Tabs (60%) -->
|
||||
<MudItem xs="12" md="7">
|
||||
<MudPaper Class="pa-2" Elevation="1" Style="height: calc(100vh - 200px); overflow: hidden">
|
||||
<MudTabs @bind-ActivePanelIndex="ActivePanelIndex" Elevation="0" Rounded="true" Color="Color.Primary">
|
||||
<!-- Overview Tab -->
|
||||
<MudTabPanel Text="Overview" Icon="@Icons.Material.Filled.Dashboard">
|
||||
<MudStack Spacing="3" Class="mt-2" Style="overflow-y: auto; height: calc(100vh - 288px);">
|
||||
<HeaderInfoCard @ref="HeaderInfoCardRef" />
|
||||
|
||||
<MudGrid Spacing="3">
|
||||
<MudItem xs="12" md="6">
|
||||
<PositionCard @ref="PositionCardRef" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="6">
|
||||
<VelocityCard @ref="VelocityCardRef" />
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
<MudGrid Spacing="3">
|
||||
<MudItem xs="12" md="6">
|
||||
<BatteryCard @ref="BatteryCardRef" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="6">
|
||||
<SafetyStateCard @ref="SafetyStateCardRef" />
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudStack>
|
||||
</MudTabPanel>
|
||||
|
||||
<!-- Navigation Tab -->
|
||||
<MudTabPanel Text="Navigation" Icon="@Icons.Material.Filled.Navigation">
|
||||
<MudStack Spacing="3" Class="mt-2" Style="overflow-y: auto; height: calc(100vh - 288px);">
|
||||
<MapsCard @ref="MapsCardRef" />
|
||||
<NodeStatesCard @ref="NodeStatesCardRef" />
|
||||
<EdgeStatesCard @ref="EdgeStatesCardRef" />
|
||||
</MudStack>
|
||||
</MudTabPanel>
|
||||
|
||||
<!-- Orders & Actions Tab -->
|
||||
<MudTabPanel Text="Orders & Actions" Icon="@Icons.Material.Filled.ListAlt">
|
||||
<MudStack Spacing="3" Class="mt-2" Style="overflow-y: auto; height: calc(100vh - 288px);">
|
||||
<OrderInfoCard @ref="OrderInfoCardRef" />
|
||||
<ActionStatesCard @ref="ActionStatesCardRef" />
|
||||
<LoadsCard @ref="LoadsCardRef" />
|
||||
</MudStack>
|
||||
</MudTabPanel>
|
||||
|
||||
<!-- Errors & Information Tab -->
|
||||
<MudTabPanel Text="Errors & Info" Icon="@Icons.Material.Filled.Info">
|
||||
<MudStack Spacing="3" Class="mt-2" Style="overflow-y: auto; height: calc(100vh - 288px);">
|
||||
<ErrorsCard @ref="ErrorsCardRef" />
|
||||
<InformationCard @ref="InformationCardRef" />
|
||||
</MudStack>
|
||||
</MudTabPanel>
|
||||
</MudTabs>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
|
||||
<!-- Right: Manual Actions & Orders (40%) -->
|
||||
<MudItem xs="12" md="5">
|
||||
<MudStack Spacing="3" Style="max-height: calc(100vh - 200px); overflow-y: auto;">
|
||||
<MudPaper Class="pa-2" Elevation="2">
|
||||
<ManualOrderPanel RobotId="@RobotId" MapId="@robot?.MapId" />
|
||||
</MudPaper>
|
||||
<MudPaper Class="pa-2" Elevation="2">
|
||||
<ManualActionsPanel RobotId="@RobotId" />
|
||||
</MudPaper>
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
}
|
||||
</MudContainer>
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public string RobotId { get; set; } = string.Empty;
|
||||
|
||||
private RobotDto? robot;
|
||||
private string robotName = "Loading...";
|
||||
private bool isLoadingRobot = true;
|
||||
private bool isRefreshing = false;
|
||||
|
||||
private HeaderInfoCard HeaderInfoCardRef = default!;
|
||||
private PositionCard PositionCardRef = default!;
|
||||
private VelocityCard VelocityCardRef = default!;
|
||||
private BatteryCard BatteryCardRef = default!;
|
||||
private SafetyStateCard SafetyStateCardRef = default!;
|
||||
private MapsCard MapsCardRef = default!;
|
||||
private NodeStatesCard NodeStatesCardRef = default!;
|
||||
private EdgeStatesCard EdgeStatesCardRef = default!;
|
||||
private OrderInfoCard OrderInfoCardRef = default!;
|
||||
private ActionStatesCard ActionStatesCardRef = default!;
|
||||
private LoadsCard LoadsCardRef = default!;
|
||||
private ErrorsCard ErrorsCardRef = default!;
|
||||
private InformationCard InformationCardRef = default!;
|
||||
|
||||
private int ActivePanelIndex = 0;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadRobotAsync();
|
||||
await InitializeSignalRAsync();
|
||||
}
|
||||
|
||||
private async Task LoadRobotAsync()
|
||||
{
|
||||
isLoadingRobot = true;
|
||||
try
|
||||
{
|
||||
robot = await RobotApiService.GetByRobotIdAsync(RobotId);
|
||||
if (robot != null)
|
||||
{
|
||||
robotName = robot.Name;
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add($"Robot with ID '{RobotId}' not found", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error loading robot: {ex.Message}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
isLoadingRobot = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task InitializeSignalRAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Register event handlers
|
||||
HubClient.OnStateUpdate += HandleStateUpdate;
|
||||
HubClient.OnConnectionError += HandleConnectionError;
|
||||
|
||||
// Connect and subscribe
|
||||
await HubClient.ConnectAsync();
|
||||
await HubClient.SubscribeToRobotAsync(RobotId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error connecting to SignalR: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void StateUpdate(StateMsg? state)
|
||||
{
|
||||
switch (ActivePanelIndex)
|
||||
{
|
||||
case 0:
|
||||
HeaderInfoCardRef.Update(state);
|
||||
BatteryCardRef.Update(state?.BatteryState);
|
||||
SafetyStateCardRef.Update(state);
|
||||
PositionCardRef.Update(state);
|
||||
VelocityCardRef.Update(state);
|
||||
break;
|
||||
case 1:
|
||||
MapsCardRef.Update(state);
|
||||
NodeStatesCardRef.Update(state);
|
||||
EdgeStatesCardRef.Update(state);
|
||||
break;
|
||||
case 2:
|
||||
OrderInfoCardRef.Update(state);
|
||||
ActionStatesCardRef.Update(state);
|
||||
LoadsCardRef.Update(state);
|
||||
break;
|
||||
case 3:
|
||||
ErrorsCardRef.Update(state?.Errors);
|
||||
InformationCardRef.Update(state?.Information);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleStateUpdate(StateMsg state)
|
||||
{
|
||||
if (state.SerialNumber != RobotId) return;
|
||||
StateUpdate(state);
|
||||
}
|
||||
|
||||
private void HandleConnectionError(string error)
|
||||
{
|
||||
Snackbar.Add($"SignalR connection error: {error}", Severity.Warning);
|
||||
}
|
||||
|
||||
private async Task HandleRefresh()
|
||||
{
|
||||
isRefreshing = true;
|
||||
StateHasChanged();
|
||||
await LoadRobotAsync();
|
||||
isRefreshing = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void NavigateBack()
|
||||
{
|
||||
NavigationManager.NavigateTo("/robots");
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (HubClient != null)
|
||||
{
|
||||
HubClient.OnStateUpdate -= HandleStateUpdate;
|
||||
HubClient.OnConnectionError -= HandleConnectionError;
|
||||
await HubClient.UnsubscribeFromRobotAsync(RobotId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
@page "/robots"
|
||||
@rendermode RobotNet10.Components.RenderMode.InteractiveWebAssemblyNoPrerender
|
||||
|
||||
<PageTitle>Robot Management</PageTitle>
|
||||
|
||||
@using RobotNet10.FleetManager.Client.Services
|
||||
@using RobotNet10.FleetManager.Client.Components.RobotManager
|
||||
@using RobotNet10.FleetManager.Client.Components.RobotManager.Dialogs
|
||||
@using RobotNet10.FleetManager.Shared.DTOs.Robot
|
||||
@inject RobotApiService RobotApiService
|
||||
@inject RobotModelApiService RobotModelApiService
|
||||
@inject IDialogService DialogService
|
||||
@inject ISnackbar Snackbar
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraExtraLarge" Class="mt-4">
|
||||
<!-- Header -->
|
||||
<MudPaper Class="pa-4 mb-4 align-content-center" MinHeight="80px">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Spacing="3">
|
||||
<MudText Typo="Typo.h5">Robot Management</MudText>
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Add"
|
||||
Variant="Variant.Filled"
|
||||
Color="Color.Success"
|
||||
OnClick="HandleCreate">
|
||||
Add Robot
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
|
||||
<!-- Main Content -->
|
||||
<RobotTable @ref="TableRef"
|
||||
RobotApiService="@RobotApiService"
|
||||
RobotModelApiService="@RobotModelApiService"
|
||||
OnRobotDeleted="@HandleDeleted"
|
||||
OnRobotUpdated="@HandleUpdated" />
|
||||
</MudContainer>
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
@code {
|
||||
private RobotTable? TableRef;
|
||||
|
||||
private async Task HandleCreate()
|
||||
{
|
||||
var parameters = new DialogParameters
|
||||
{
|
||||
["RobotApiService"] = RobotApiService,
|
||||
["RobotModelApiService"] = RobotModelApiService
|
||||
};
|
||||
|
||||
var dialog = await DialogService.ShowAsync<CreateRobotDialog>("Create Robot", parameters,
|
||||
new DialogOptions { MaxWidth = MaxWidth.Medium, FullWidth = true });
|
||||
var result = await dialog.Result;
|
||||
|
||||
if (result != null && !result.Canceled)
|
||||
{
|
||||
await TableRef?.LoadDataAsync()!;
|
||||
Snackbar.Add("Robot created successfully", Severity.Success);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleDeleted()
|
||||
{
|
||||
await TableRef?.LoadDataAsync()!;
|
||||
Snackbar.Add("Robot deleted successfully", Severity.Success);
|
||||
}
|
||||
|
||||
private async Task HandleUpdated()
|
||||
{
|
||||
await TableRef?.LoadDataAsync()!;
|
||||
Snackbar.Add("Robot updated successfully", Severity.Success);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
@page "/robot-models"
|
||||
|
||||
@rendermode InteractiveWebAssemblyNoPrerender
|
||||
|
||||
<PageTitle>Robot Model Management</PageTitle>
|
||||
|
||||
@using RobotNet10.FleetManager.Client.Services
|
||||
@using RobotNet10.FleetManager.Client.Components.RobotModelManager
|
||||
@using RobotNet10.FleetManager.Client.Components.RobotModelManager.Dialogs
|
||||
@using RobotNet10.FleetManager.Shared.DTOs.RobotModel
|
||||
@inject RobotModelApiService ApiService
|
||||
@inject IDialogService DialogService
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraExtraLarge" Class="mt-4">
|
||||
<!-- Header -->
|
||||
<MudPaper Class="pa-4 mb-4 align-content-center" MinHeight="80px">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Spacing="3">
|
||||
<MudText Typo="Typo.h5">Robot Model Management</MudText>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudTextField Value="@searchText"
|
||||
Placeholder="Search robot models..."
|
||||
Variant="Variant.Outlined"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Search"
|
||||
Margin="Margin.Dense"
|
||||
Style="min-width: 250px;"
|
||||
Immediate="false"
|
||||
T="string"
|
||||
ValueChanged="TextSearchChanged"
|
||||
Clearable="true" />
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Add"
|
||||
Variant="Variant.Filled"
|
||||
Color="Color.Success"
|
||||
OnClick="HandleCreate">
|
||||
Add Robot Model
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
|
||||
<!-- Main Content -->
|
||||
<MudGrid>
|
||||
<!-- Left: List Panel (60%) -->
|
||||
<MudItem xs="12" md="7">
|
||||
<RobotModelListPanel @ref="ListPanelRef"
|
||||
ApiService="@ApiService"
|
||||
SelectedRobotModelChanged="SelectedRobotModelChanged"
|
||||
SearchText="@searchText" />
|
||||
</MudItem>
|
||||
|
||||
<!-- Right: Details Panel (40%) -->
|
||||
<MudItem xs="12" md="5">
|
||||
<MudPaper Class="pa-3" Elevation="1" Style="height: calc(100vh - 169px); overflow-y: auto;">
|
||||
|
||||
@if (selectedRobotModel != null)
|
||||
{
|
||||
<RobotModelDetailsPanel RobotModel="@selectedRobotModel" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.body1" Align="Align.Center" Class="mt-8">
|
||||
Select a robot model to view details
|
||||
</MudText>
|
||||
}
|
||||
</MudPaper>
|
||||
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudContainer>
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
@code {
|
||||
private string searchText = string.Empty;
|
||||
private RobotModelDto? selectedRobotModel;
|
||||
private RobotModelListPanel? ListPanelRef;
|
||||
|
||||
private async Task TextSearchChanged(string text)
|
||||
{
|
||||
searchText = text;
|
||||
if (ListPanelRef != null)
|
||||
{
|
||||
await ListPanelRef.SearchAsync(text);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleCreate()
|
||||
{
|
||||
var parameters = new DialogParameters
|
||||
{
|
||||
["ApiService"] = ApiService
|
||||
};
|
||||
|
||||
var dialog = await DialogService.ShowAsync<CreateRobotModelDialog>("Create Robot Model", parameters,
|
||||
new DialogOptions { MaxWidth = MaxWidth.Medium, FullWidth = true });
|
||||
var result = await dialog.Result;
|
||||
|
||||
if (result != null && !result.Canceled)
|
||||
{
|
||||
await ListPanelRef?.LoadDataAsync()!;
|
||||
Snackbar.Add("Robot model created successfully", Severity.Success);
|
||||
}
|
||||
}
|
||||
|
||||
private void SelectedRobotModelChanged(RobotModelDto? model)
|
||||
{
|
||||
selectedRobotModel = model;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
@page "/robots/monitor"
|
||||
@rendermode RobotNet10.Components.RenderMode.InteractiveWebAssemblyNoPrerender
|
||||
@using RobotNet10.FleetManager.Client.Components.RobotMonitor
|
||||
|
||||
<PageTitle>Robot Monitor</PageTitle>
|
||||
|
||||
<RobotMonitorComponent />
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
@@ -0,0 +1,12 @@
|
||||
@page "/programming"
|
||||
@rendermode InteractiveWebAssemblyNoPrerender
|
||||
|
||||
<PageTitle>Script Editor</PageTitle>
|
||||
|
||||
<RobotNet10.ScriptEditor.ScriptEditor />
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
@page "/station-manager/{LevelId:guid}"
|
||||
@rendermode RobotNet10.Components.RenderMode.InteractiveWebAssemblyNoPrerender
|
||||
|
||||
<PageTitle>Station Manager</PageTitle>
|
||||
|
||||
<RobotNet10.MapEditor.Components.StationManager.StationManagerComponent LayoutLevelId="LevelId" />
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
@code
|
||||
{
|
||||
[Parameter]
|
||||
public Guid LevelId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
@page "/vehicletypes/edit/{Id:guid}"
|
||||
@rendermode RobotNet10.Components.RenderMode.InteractiveWebAssemblyNoPrerender
|
||||
|
||||
<PageTitle>Vehicle Editor</PageTitle>
|
||||
|
||||
<RobotNet10.MapEditor.Components.VehicleTypeManager.VehicleTypeEditComponent Id="@Id" />
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public Guid Id { get; set; }
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
@page "/vehicle-manager"
|
||||
@rendermode RobotNet10.Components.RenderMode.InteractiveWebAssemblyNoPrerender
|
||||
|
||||
<PageTitle>Vehicle Manager</PageTitle>
|
||||
|
||||
<RobotNet10.MapEditor.Components.VehicleTypeManager.VehicleTypeManagerComponent />
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
|
||||
@attribute [Authorize]
|
||||
@@ -0,0 +1,76 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
|
||||
using MudBlazor;
|
||||
using MudBlazor.Services;
|
||||
using RobotNet10.CustomConfigurationEditor.Services.API;
|
||||
using RobotNet10.CustomConfigurationEditor.Services.State;
|
||||
using RobotNet10.FleetManager.Client;
|
||||
using RobotNet10.FleetManager.Client.Services;
|
||||
using RobotNet10.MapEditor.Services.API;
|
||||
using RobotNet10.MapEditor.Services.State;
|
||||
using RobotNet10.ScriptEditor;
|
||||
using System.Globalization;
|
||||
|
||||
CultureInfo.DefaultThreadCurrentCulture = new CultureInfo("en-US");
|
||||
var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
||||
|
||||
builder.Logging.AddFilter("System.Net.Http.HttpClient", LogLevel.Warning);
|
||||
builder.Services.AddAuthorizationCore();
|
||||
builder.Services.AddCascadingAuthenticationState();
|
||||
builder.Services.AddAuthenticationStateDeserialization();
|
||||
|
||||
builder.Services.AddNavigationMenu();
|
||||
|
||||
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress), });
|
||||
builder.Services.AddMudServices(config =>
|
||||
{
|
||||
config.SnackbarConfiguration.VisibleStateDuration = 2000;
|
||||
config.SnackbarConfiguration.HideTransitionDuration = 500;
|
||||
config.SnackbarConfiguration.ShowTransitionDuration = 500;
|
||||
config.SnackbarConfiguration.PositionClass = Defaults.Classes.Position.BottomLeft;
|
||||
});
|
||||
|
||||
// MapEditor Services
|
||||
builder.Services.AddScoped<MapManagerApiService>();
|
||||
builder.Services.AddScoped<LayoutManagerState>();
|
||||
builder.Services.AddScoped<LayoutEditorState>();
|
||||
builder.Services.AddScoped<VehicleTypeManagerState>();
|
||||
builder.Services.AddScoped<VehicleTypeEditState>();
|
||||
builder.Services.AddScoped<StationManagerState>();
|
||||
|
||||
// Robot Management Services
|
||||
builder.Services.AddScoped<RobotModelApiService>();
|
||||
builder.Services.AddScoped<RobotApiService>();
|
||||
|
||||
// SignalR Hub Client - Use factory to create per-scope instances
|
||||
builder.Services.AddScoped<RobotStateHubClient>(sp =>
|
||||
{
|
||||
var navigationManager = sp.GetRequiredService<NavigationManager>();
|
||||
var logger = sp.GetService<ILogger<RobotStateHubClient>>();
|
||||
return new RobotStateHubClient(navigationManager, logger);
|
||||
});
|
||||
|
||||
// Robot Monitor State
|
||||
builder.Services.AddScoped<RobotMonitorState>(sp =>
|
||||
{
|
||||
var mapApiService = sp.GetRequiredService<MapManagerApiService>();
|
||||
var robotApiService = sp.GetRequiredService<RobotApiService>();
|
||||
var robotModelApiService = sp.GetRequiredService<RobotModelApiService>();
|
||||
var hubClient = sp.GetRequiredService<RobotStateHubClient>();
|
||||
return new RobotMonitorState(mapApiService, robotApiService, robotModelApiService, hubClient);
|
||||
});
|
||||
|
||||
// Config Manager Services
|
||||
builder.Services.AddScoped<ConfigApiService>();
|
||||
builder.Services.AddScoped<ConfigManagerState>(sp =>
|
||||
{
|
||||
var configApi = sp.GetRequiredService<ConfigApiService>();
|
||||
var authStateProvider = sp.GetService<AuthenticationStateProvider>();
|
||||
return new ConfigManagerState(configApi, authStateProvider, "Distributor");
|
||||
});
|
||||
|
||||
// Script Engine Services
|
||||
builder.Services.AddScriptEditor<ScriptEngineResource>();
|
||||
|
||||
await builder.Build().RunAsync();
|
||||
@@ -0,0 +1,8 @@
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
@code {
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
NavigationManager.NavigateTo($"Account/Login?returnUrl={Uri.EscapeDataString(NavigationManager.Uri)}", forceLoad: true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<NoDefaultLaunchSettingsFile>true</NoDefaultLaunchSettingsFile>
|
||||
<StaticWebAssetProjectMode>Default</StaticWebAssetProjectMode>
|
||||
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.3" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Authentication" Version="10.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Commons\RobotNet10.Script\RobotNet10.Script.csproj" />
|
||||
<ProjectReference Include="..\..\Components\RobotNet10.CustomConfigurationEditor\RobotNet10.CustomConfigurationEditor.csproj" />
|
||||
<ProjectReference Include="..\..\Components\RobotNet10.MapEditor\RobotNet10.MapEditor.csproj" />
|
||||
<ProjectReference Include="..\..\Components\RobotNet10.Components\RobotNet10.Components.csproj" />
|
||||
<ProjectReference Include="..\..\Components\RobotNet10.ScriptEditor\RobotNet10.ScriptEditor.csproj" />
|
||||
<ProjectReference Include="..\..\Shared\RobotNet10.ScriptEngine.Shared\RobotNet10.ScriptEngine.Shared.csproj" />
|
||||
<ProjectReference Include="..\..\Shared\RobotNet10.Shared\RobotNet10.Shared.csproj" />
|
||||
<ProjectReference Include="..\RobotNet10.FleetManager.Script.Shared\RobotNet10.FleetManager.Script.Shared.csproj" />
|
||||
<ProjectReference Include="..\RobotNet10.FleetManager.Script\RobotNet10.FleetManager.Script.csproj" />
|
||||
<ProjectReference Include="..\RobotNet10.FleetManager.Shared\RobotNet10.FleetManager.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,181 @@
|
||||
using System.Net.Http.Json;
|
||||
using RobotNet10.FleetManager.Shared.DTOs.Robot;
|
||||
using RobotNet10.FleetManager.Shared.DTOs.Responses;
|
||||
|
||||
namespace RobotNet10.FleetManager.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// API service for robot operations.
|
||||
/// Provides methods to interact with the robot API endpoints.
|
||||
/// </summary>
|
||||
public class RobotApiService
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<RobotApiService>? _logger;
|
||||
|
||||
public RobotApiService(HttpClient httpClient, ILogger<RobotApiService>? logger = null)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all robots with optional filters
|
||||
/// </summary>
|
||||
public async Task<List<RobotDto>> GetAllAsync(Guid? modelId = null, Guid? mapId = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var queryParams = new List<string>();
|
||||
if (modelId.HasValue)
|
||||
queryParams.Add($"modelId={modelId.Value}");
|
||||
if (mapId.HasValue)
|
||||
queryParams.Add($"mapId={mapId.Value}");
|
||||
|
||||
var queryString = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : "";
|
||||
var response = await _httpClient.GetAsync($"/api/robots{queryString}");
|
||||
response.EnsureSuccessStatusCode();
|
||||
return await response.Content.ReadFromJsonAsync<List<RobotDto>>() ?? new List<RobotDto>();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogError(ex, "Error getting all robots");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get robot by ID
|
||||
/// </summary>
|
||||
public async Task<RobotDto?> GetByIdAsync(Guid id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.GetAsync($"/api/robots/{id}");
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
response.EnsureSuccessStatusCode();
|
||||
return await response.Content.ReadFromJsonAsync<RobotDto>();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogError(ex, "Error getting robot {Id}", id);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get robot by RobotId (string identifier)
|
||||
/// </summary>
|
||||
public async Task<RobotDto?> GetByRobotIdAsync(string robotId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.GetAsync($"/api/robots/robotId/{Uri.EscapeDataString(robotId)}");
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
response.EnsureSuccessStatusCode();
|
||||
return await response.Content.ReadFromJsonAsync<RobotDto>();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogError(ex, "Error getting robot with RobotId {RobotId}", robotId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Search robots
|
||||
/// </summary>
|
||||
public async Task<List<RobotDto>> SearchAsync(string query)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.GetAsync($"/api/robots/search?query={Uri.EscapeDataString(query)}");
|
||||
response.EnsureSuccessStatusCode();
|
||||
return await response.Content.ReadFromJsonAsync<List<RobotDto>>() ?? new List<RobotDto>();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogError(ex, "Error searching robots with query {Query}", query);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all robots by model ID
|
||||
/// </summary>
|
||||
public async Task<List<RobotDto>> GetByModelIdAsync(Guid modelId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.GetAsync($"/api/robots/model/{modelId}");
|
||||
response.EnsureSuccessStatusCode();
|
||||
return await response.Content.ReadFromJsonAsync<List<RobotDto>>() ?? new List<RobotDto>();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogError(ex, "Error getting robots by model {ModelId}", modelId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a new robot
|
||||
/// </summary>
|
||||
public async Task<RobotDto> CreateAsync(CreateRobotRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.PostAsJsonAsync("/api/robots", request);
|
||||
response.EnsureSuccessStatusCode();
|
||||
return await response.Content.ReadFromJsonAsync<RobotDto>()
|
||||
?? throw new InvalidOperationException("Failed to deserialize created robot");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogError(ex, "Error creating robot");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update an existing robot
|
||||
/// </summary>
|
||||
public async Task<RobotDto> UpdateAsync(Guid id, UpdateRobotRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.PutAsJsonAsync($"/api/robots/{id}", request);
|
||||
response.EnsureSuccessStatusCode();
|
||||
return await response.Content.ReadFromJsonAsync<RobotDto>()
|
||||
?? throw new InvalidOperationException("Failed to deserialize updated robot");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogError(ex, "Error updating robot {Id}", id);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete a robot
|
||||
/// </summary>
|
||||
public async Task DeleteAsync(Guid id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.DeleteAsync($"/api/robots/{id}");
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogError(ex, "Error deleting robot {Id}", id);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
using System.Net.Http.Json;
|
||||
using RobotNet10.FleetManager.Shared.DTOs.RobotModel;
|
||||
using RobotNet10.FleetManager.Shared.DTOs.Responses;
|
||||
|
||||
namespace RobotNet10.FleetManager.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// API service for robot model operations.
|
||||
/// Provides methods to interact with the robot model API endpoints.
|
||||
/// </summary>
|
||||
public class RobotModelApiService
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<RobotModelApiService>? _logger;
|
||||
|
||||
public RobotModelApiService(HttpClient httpClient, ILogger<RobotModelApiService>? logger = null)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all robot models
|
||||
/// </summary>
|
||||
public async Task<List<RobotModelDto>> GetAllAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.GetAsync("/api/robot-models");
|
||||
response.EnsureSuccessStatusCode();
|
||||
return await response.Content.ReadFromJsonAsync<List<RobotModelDto>>() ?? new List<RobotModelDto>();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogError(ex, "Error getting all robot models");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get robot model by ID
|
||||
/// </summary>
|
||||
public async Task<RobotModelDto?> GetByIdAsync(Guid id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.GetAsync($"/api/robot-models/{id}");
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
response.EnsureSuccessStatusCode();
|
||||
return await response.Content.ReadFromJsonAsync<RobotModelDto>();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogError(ex, "Error getting robot model {Id}", id);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Search robot models
|
||||
/// </summary>
|
||||
public async Task<List<RobotModelDto>> SearchAsync(string query)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.GetAsync($"/api/robot-models/search?query={Uri.EscapeDataString(query)}");
|
||||
response.EnsureSuccessStatusCode();
|
||||
return await response.Content.ReadFromJsonAsync<List<RobotModelDto>>() ?? new List<RobotModelDto>();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogError(ex, "Error searching robot models with query {Query}", query);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get usage information for a robot model
|
||||
/// </summary>
|
||||
public async Task<RobotModelUsageInfoDto> GetUsageInfoAsync(Guid id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.GetAsync($"/api/robot-models/{id}/usage");
|
||||
response.EnsureSuccessStatusCode();
|
||||
return await response.Content.ReadFromJsonAsync<RobotModelUsageInfoDto>()
|
||||
?? throw new InvalidOperationException("Failed to deserialize usage info");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogError(ex, "Error getting usage info for robot model {Id}", id);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a new robot model
|
||||
/// </summary>
|
||||
public async Task<RobotModelDto> CreateAsync(CreateRobotModelRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.PostAsJsonAsync("/api/robot-models", request);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync();
|
||||
_logger?.LogError("Error creating robot model. Status: {StatusCode}, Response: {Error}",
|
||||
response.StatusCode, errorContent);
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.BadRequest)
|
||||
{
|
||||
throw new InvalidOperationException($"Validation error: {errorContent}");
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<RobotModelDto>()
|
||||
?? throw new InvalidOperationException("Failed to deserialize created robot model");
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger?.LogError(ex, "HTTP error creating robot model");
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogError(ex, "Error creating robot model");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update an existing robot model
|
||||
/// </summary>
|
||||
public async Task<RobotModelDto> UpdateAsync(Guid id, UpdateRobotModelRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.PutAsJsonAsync($"/api/robot-models/{id}", request);
|
||||
response.EnsureSuccessStatusCode();
|
||||
return await response.Content.ReadFromJsonAsync<RobotModelDto>()
|
||||
?? throw new InvalidOperationException("Failed to deserialize updated robot model");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogError(ex, "Error updating robot model {Id}", id);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete a robot model
|
||||
/// </summary>
|
||||
public async Task DeleteAsync(Guid id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.DeleteAsync($"/api/robot-models/{id}");
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogError(ex, "Error deleting robot model {Id}", id);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get robot model image
|
||||
/// </summary>
|
||||
public async Task<string?> GetImageAsync(Guid id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.GetAsync($"/api/robot-models/{id}/image");
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var imageBytes = await response.Content.ReadAsByteArrayAsync();
|
||||
return Convert.ToBase64String(imageBytes);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogError(ex, "Error getting image for robot model {Id}", id);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Upload robot model image
|
||||
/// </summary>
|
||||
public async Task UploadImageAsync(Guid id, Stream imageStream, string fileName)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var content = new MultipartFormDataContent();
|
||||
using var streamContent = new StreamContent(imageStream);
|
||||
streamContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("image/png");
|
||||
content.Add(streamContent, "file", fileName);
|
||||
|
||||
var response = await _httpClient.PostAsync($"/api/robot-models/{id}/image", content);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync();
|
||||
_logger?.LogError("Error uploading image. Status: {StatusCode}, Response: {Error}",
|
||||
response.StatusCode, errorContent);
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.BadRequest)
|
||||
{
|
||||
throw new InvalidOperationException($"Image upload validation error: {errorContent}");
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger?.LogError(ex, "HTTP error uploading image for robot model {Id}", id);
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogError(ex, "Error uploading image for robot model {Id}", id);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete robot model image
|
||||
/// </summary>
|
||||
public async Task DeleteImageAsync(Guid id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.DeleteAsync($"/api/robot-models/{id}/image");
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogError(ex, "Error deleting image for robot model {Id}", id);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,874 @@
|
||||
using RobotNet10.FleetManager.Client;
|
||||
using RobotNet10.FleetManager.Shared.DTOs.Robot;
|
||||
using RobotNet10.FleetManager.Shared.DTOs.RobotModel;
|
||||
using RobotNet10.MapEditor.Services.API;
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Edge;
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Layout;
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Node;
|
||||
|
||||
namespace RobotNet10.FleetManager.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Viewport state for SVG canvas
|
||||
/// </summary>
|
||||
public class ViewportState
|
||||
{
|
||||
public double ViewBoxX { get; set; }
|
||||
public double ViewBoxY { get; set; }
|
||||
public double ViewBoxWidth { get; set; }
|
||||
public double ViewBoxHeight { get; set; }
|
||||
public double ZoomLevel { get; set; } = 1.0;
|
||||
|
||||
public string ToViewBoxString() =>
|
||||
$"{ViewBoxX:F2} {ViewBoxY:F2} {ViewBoxWidth:F2} {ViewBoxHeight:F2}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Robot data for monitoring
|
||||
/// </summary>
|
||||
public class RobotMonitorData
|
||||
{
|
||||
public string RobotId { get; set; } = string.Empty;
|
||||
public Guid? ModelId { get; set; }
|
||||
public RobotModelDto? Model { get; set; } // Robot model info (Length, Width, etc.)
|
||||
public string? ModelImageBase64 { get; set; }
|
||||
public DateTime LastUpdateTime { get; set; }
|
||||
public RobotMonitorBoardcastData? Data { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// State management for RobotMonitor page
|
||||
/// </summary>
|
||||
public class RobotMonitorState(
|
||||
MapManagerApiService mapApiService,
|
||||
RobotApiService robotApiService,
|
||||
RobotModelApiService robotModelApiService,
|
||||
RobotStateHubClient? hubClient = null)
|
||||
{
|
||||
// ===== DATA =====
|
||||
public List<LayoutDto> Layouts { get; private set; } = [];
|
||||
public List<LayoutVersionDto> AvailableVersions { get; private set; } = [];
|
||||
public List<LayoutLevelDto> AvailableLevels { get; private set; } = [];
|
||||
public List<RobotDto> AvailableRobots { get; private set; } = [];
|
||||
|
||||
public Guid? SelectedLayoutId { get; set; }
|
||||
public Guid? SelectedVersionId { get; set; }
|
||||
public Guid? SelectedLevelId { get; set; }
|
||||
public LayoutLevelDto? Level { get; private set; }
|
||||
public List<NodeDto> Nodes { get; private set; } = [];
|
||||
public List<EdgeDto> Edges { get; private set; } = [];
|
||||
public byte[]? BackgroundImage { get; private set; }
|
||||
|
||||
// ===== ROBOTS =====
|
||||
public Dictionary<string, RobotMonitorData> Robots { get; private set; } = [];
|
||||
public string? SelectedRobotId { get; set; }
|
||||
|
||||
// ===== DISPLAY OPTIONS =====
|
||||
public bool ShowGrid { get; set; } = false;
|
||||
public bool ShowBackgroundImage { get; set; } = true;
|
||||
public bool ShowPath { get; set; } = false;
|
||||
public bool ShowName { get; set; } = true;
|
||||
public bool FollowRobot { get; set; } = false;
|
||||
public bool RobotInfoPanelExpanded { get; set; } = false;
|
||||
|
||||
// ===== VIEWPORT =====
|
||||
public ViewportState Viewport { get; } = new();
|
||||
|
||||
// ===== UI STATE =====
|
||||
public bool IsLoading { get; private set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
public bool IsMonitorDeactivated { get; private set; } = false;
|
||||
|
||||
// ===== EVENTS =====
|
||||
public event Action? OnDataChanged;
|
||||
public event Action? OnStateChanged;
|
||||
|
||||
// ===== DEPENDENCIES =====
|
||||
private readonly MapManagerApiService _mapApiService = mapApiService;
|
||||
private readonly RobotApiService _robotApiService = robotApiService;
|
||||
private readonly RobotModelApiService _robotModelApiService = robotModelApiService;
|
||||
private readonly RobotStateHubClient? _hubClient = hubClient;
|
||||
|
||||
// ===== ROBOT MODEL CACHE =====
|
||||
private readonly Dictionary<Guid, string> _robotModelImageCache = [];
|
||||
private readonly Dictionary<Guid, RobotModelDto> _robotModelCache = []; // modelId -> RobotModelDto
|
||||
private readonly Dictionary<string, Guid> _robotModelIdCache = []; // robotId -> modelId
|
||||
|
||||
// ===== TIMEOUT CONFIGURATION =====
|
||||
private const int RobotTimeoutSeconds = 10; // Remove robots after 10 seconds of no updates
|
||||
|
||||
/// <summary>
|
||||
/// Initialize monitor state
|
||||
/// </summary>
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
IsLoading = true;
|
||||
ErrorMessage = null;
|
||||
NotifyStateChanged();
|
||||
|
||||
try
|
||||
{
|
||||
// Load layouts
|
||||
await LoadLayoutsAsync();
|
||||
|
||||
// Load robots and their models
|
||||
await LoadRobotsAsync();
|
||||
|
||||
// Connect SignalR if available
|
||||
if (_hubClient != null)
|
||||
{
|
||||
await InitializeSignalRAsync();
|
||||
|
||||
// Register OnMonitorDeactivated event handler
|
||||
_hubClient.OnMonitorDeactivated += HandleMonitorDeactivated;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorMessage = $"Failed to initialize: {ex.Message}";
|
||||
// Log error for debugging
|
||||
System.Diagnostics.Debug.WriteLine($"RobotMonitor initialization error: {ex}");
|
||||
}
|
||||
|
||||
IsLoading = false;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load all layouts
|
||||
/// </summary>
|
||||
public async Task LoadLayoutsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var allLayouts = await _mapApiService.SearchLayoutsAsync();
|
||||
// Only get active layouts
|
||||
Layouts = [.. allLayouts.Where(l => l.IsActive)];
|
||||
ErrorMessage = null; // Clear any previous errors
|
||||
|
||||
// Auto-select first layout, last version, last level
|
||||
if (Layouts.Count > 0)
|
||||
{
|
||||
var firstLayout = Layouts[0];
|
||||
SelectedLayoutId = firstLayout.Id;
|
||||
|
||||
// Load versions for first layout
|
||||
AvailableVersions = await _mapApiService.GetVersionsAsync(firstLayout.Id);
|
||||
|
||||
if (AvailableVersions.Count > 0)
|
||||
{
|
||||
var lastVersion = AvailableVersions.Last();
|
||||
SelectedVersionId = lastVersion.Id;
|
||||
|
||||
// Load levels for last version
|
||||
AvailableLevels = await _mapApiService.GetLevelsAsync(lastVersion.Id);
|
||||
|
||||
if (AvailableLevels.Count > 0)
|
||||
{
|
||||
var lastLevel = AvailableLevels.Last();
|
||||
SelectedLevelId = lastLevel.Id;
|
||||
|
||||
// Load layout data for last level
|
||||
await LoadLayoutDataAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Build detailed error message
|
||||
var errorDetails = new System.Text.StringBuilder();
|
||||
errorDetails.AppendLine($"Failed to load layouts: {ex.Message}");
|
||||
|
||||
// Add inner exception details if available
|
||||
if (ex.InnerException != null)
|
||||
{
|
||||
errorDetails.AppendLine($"Inner exception: {ex.InnerException.Message}");
|
||||
}
|
||||
|
||||
// Add stack trace for debugging (first few lines only)
|
||||
if (ex.StackTrace != null)
|
||||
{
|
||||
var stackLines = ex.StackTrace.Split('\n').Take(3);
|
||||
errorDetails.AppendLine($"Stack trace: {string.Join(" ", stackLines)}");
|
||||
}
|
||||
|
||||
ErrorMessage = errorDetails.ToString().Trim();
|
||||
Layouts = [];
|
||||
}
|
||||
NotifyStateChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle layout selection change
|
||||
/// </summary>
|
||||
public async Task OnLayoutSelectedAsync(Guid? layoutId)
|
||||
{
|
||||
SelectedLayoutId = layoutId;
|
||||
SelectedVersionId = null;
|
||||
SelectedLevelId = null;
|
||||
AvailableVersions = [];
|
||||
AvailableLevels = [];
|
||||
|
||||
if (layoutId.HasValue)
|
||||
{
|
||||
// Load versions for selected layout
|
||||
try
|
||||
{
|
||||
AvailableVersions = await _mapApiService.GetVersionsAsync(layoutId.Value);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var errorDetails = GetDetailedErrorMessage(ex, "load versions");
|
||||
ErrorMessage = errorDetails;
|
||||
AvailableVersions = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Clear layout data
|
||||
await LoadLayoutDataAsync();
|
||||
NotifyStateChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle version selection change
|
||||
/// </summary>
|
||||
public async Task OnVersionSelectedAsync(Guid? versionId)
|
||||
{
|
||||
SelectedVersionId = versionId;
|
||||
SelectedLevelId = null;
|
||||
AvailableLevels = [];
|
||||
|
||||
if (versionId.HasValue)
|
||||
{
|
||||
// Load levels for selected version
|
||||
try
|
||||
{
|
||||
AvailableLevels = await _mapApiService.GetLevelsAsync(versionId.Value);
|
||||
AvailableLevels = [.. AvailableLevels.OrderBy(l => l.LevelOrder)];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var errorDetails = GetDetailedErrorMessage(ex, "load levels");
|
||||
ErrorMessage = errorDetails;
|
||||
AvailableLevels = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Clear layout data
|
||||
await LoadLayoutDataAsync();
|
||||
NotifyStateChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle level selection change
|
||||
/// </summary>
|
||||
public async Task OnLevelSelectedAsync(Guid? levelId)
|
||||
{
|
||||
// Unsubscribe from old level if exists
|
||||
if (SelectedLevelId.HasValue && _hubClient != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _hubClient.UnsubscribeFromLevelForMonitorAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore unsubscribe errors
|
||||
}
|
||||
}
|
||||
|
||||
SelectedLevelId = levelId;
|
||||
await LoadLayoutDataAsync();
|
||||
|
||||
// Subscribe to new level
|
||||
if (SelectedLevelId.HasValue && _hubClient != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _hubClient.SubscribeToLevelForMonitorAsync(SelectedLevelId.Value);
|
||||
IsMonitorDeactivated = false; // Reset deactivated state when subscribing
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore subscription errors
|
||||
}
|
||||
}
|
||||
|
||||
NotifyStateChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle robot selection change (from dropdown)
|
||||
/// </summary>
|
||||
public void OnRobotSelected(string? robotId)
|
||||
{
|
||||
SelectRobot(robotId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load layout data for selected level
|
||||
/// </summary>
|
||||
public async Task LoadLayoutDataAsync()
|
||||
{
|
||||
if (!SelectedLevelId.HasValue)
|
||||
{
|
||||
Level = null;
|
||||
Nodes = [];
|
||||
Edges = [];
|
||||
BackgroundImage = null;
|
||||
NotifyStateChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
IsLoading = true;
|
||||
ErrorMessage = null;
|
||||
NotifyStateChanged();
|
||||
|
||||
try
|
||||
{
|
||||
// Load level info
|
||||
Level = await _mapApiService.GetLevelAsync(SelectedLevelId.Value);
|
||||
|
||||
// Load layout data (nodes, edges)
|
||||
var layoutData = await _mapApiService.GetLayoutDataAsync(SelectedLevelId.Value);
|
||||
Nodes = layoutData.Nodes?.ToList() ?? [];
|
||||
Edges = layoutData.Edges?.ToList() ?? [];
|
||||
|
||||
// Load background image
|
||||
try
|
||||
{
|
||||
BackgroundImage = await _mapApiService.GetLayoutImageAsync(SelectedLevelId.Value);
|
||||
}
|
||||
catch
|
||||
{
|
||||
BackgroundImage = null;
|
||||
}
|
||||
|
||||
// Initialize viewport
|
||||
InitializeViewport();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var errorDetails = GetDetailedErrorMessage(ex, "load layout data");
|
||||
ErrorMessage = errorDetails;
|
||||
}
|
||||
|
||||
IsLoading = false;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load robots from API
|
||||
/// </summary>
|
||||
private async Task LoadRobotsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
AvailableRobots = await _robotApiService.GetAllAsync();
|
||||
|
||||
// Cache robot model IDs
|
||||
foreach (var robot in AvailableRobots)
|
||||
{
|
||||
if (robot.ModelId != Guid.Empty)
|
||||
{
|
||||
_robotModelIdCache[robot.RobotId] = robot.ModelId;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var errorDetails = GetDetailedErrorMessage(ex, "load robots");
|
||||
ErrorMessage = errorDetails;
|
||||
AvailableRobots = [];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initialize SignalR connection
|
||||
/// </summary>
|
||||
private async Task InitializeSignalRAsync()
|
||||
{
|
||||
if (_hubClient == null) return;
|
||||
|
||||
try
|
||||
{
|
||||
// Register event handlers
|
||||
_hubClient.OnMonitorBoardcastUpdate += HandleStateUpdate;
|
||||
//_hubClient.OnVisualizationUpdate += HandleVisualizationUpdate;
|
||||
_hubClient.OnConnectionError += HandleConnectionError;
|
||||
// Connect
|
||||
await _hubClient.ConnectAsync();
|
||||
|
||||
// Subscribe to all robots
|
||||
// Subscribe to level will be done in OnLevelSelectedAsync when level is selected
|
||||
if (SelectedLevelId.HasValue && _hubClient != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _hubClient.SubscribeToLevelForMonitorAsync(SelectedLevelId.Value);
|
||||
IsMonitorDeactivated = false; // Reset deactivated state when subscribing
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore subscription errors
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var errorDetails = GetDetailedErrorMessage(ex, "connect SignalR");
|
||||
ErrorMessage = errorDetails;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle monitor deactivated event (when connection is evicted due to max connections)
|
||||
/// </summary>
|
||||
private void HandleMonitorDeactivated()
|
||||
{
|
||||
IsMonitorDeactivated = true;
|
||||
ErrorMessage = "Monitor connection deactivated: Maximum 5 connections per level reached. Another connection has taken your place.";
|
||||
NotifyStateChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle SignalR connection error
|
||||
/// </summary>
|
||||
private void HandleConnectionError(string error)
|
||||
{
|
||||
ErrorMessage = $"SignalR connection error: {error}";
|
||||
NotifyStateChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initialize viewport to fit the image bounds
|
||||
/// </summary>
|
||||
private void InitializeViewport()
|
||||
{
|
||||
if (Level?.EditorSettings != null)
|
||||
{
|
||||
var settings = Level.EditorSettings;
|
||||
|
||||
// Calculate physical dimensions
|
||||
double physicalWidth = (settings.ImageWidth ?? 1000) * settings.Resolution;
|
||||
double physicalHeight = (settings.ImageHeight ?? 500) * settings.Resolution;
|
||||
|
||||
// Set viewport to fit image
|
||||
Viewport.ViewBoxX = 0;
|
||||
Viewport.ViewBoxY = 0;
|
||||
Viewport.ViewBoxWidth = physicalWidth;
|
||||
Viewport.ViewBoxHeight = physicalHeight;
|
||||
Viewport.ZoomLevel = 1.0;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Default viewport
|
||||
Viewport.ViewBoxX = 0;
|
||||
Viewport.ViewBoxY = 0;
|
||||
Viewport.ViewBoxWidth = 50;
|
||||
Viewport.ViewBoxHeight = 30;
|
||||
Viewport.ZoomLevel = 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// SIGNALR HANDLERS
|
||||
// ==========================================
|
||||
|
||||
/// <summary>
|
||||
/// Handle StateMsg update from SignalR
|
||||
/// </summary>
|
||||
private void HandleStateUpdate(RobotMonitorBoardcastData state)
|
||||
{
|
||||
var robotId = state.RobotId;
|
||||
|
||||
// Get or create robot data
|
||||
if (!Robots.TryGetValue(robotId, out var robotData))
|
||||
{
|
||||
robotData = new RobotMonitorData
|
||||
{
|
||||
RobotId = robotId,
|
||||
ModelId = _robotModelIdCache.TryGetValue(robotId, out var modelId) ? modelId : null
|
||||
};
|
||||
Robots[robotId] = robotData;
|
||||
|
||||
// Load robot model image asynchronously
|
||||
_ = LoadRobotModelImageAsync(robotData);
|
||||
}
|
||||
|
||||
// Update position and state
|
||||
robotData.Data = state;
|
||||
robotData.LastUpdateTime = DateTime.UtcNow;
|
||||
|
||||
// If FollowRobot and this is selected robot, update viewport
|
||||
if (FollowRobot && SelectedRobotId == robotId)
|
||||
{
|
||||
FocusOnRobot(robotId);
|
||||
}
|
||||
|
||||
// Remove inactive robots periodically (check every update)
|
||||
RemoveInactiveRobots();
|
||||
|
||||
OnDataChanged?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load robot model image and info
|
||||
/// </summary>
|
||||
private async Task LoadRobotModelImageAsync(RobotMonitorData robotData)
|
||||
{
|
||||
if (!robotData.ModelId.HasValue) return;
|
||||
|
||||
try
|
||||
{
|
||||
// Load model info if not cached
|
||||
if (!_robotModelCache.TryGetValue(robotData.ModelId.Value, out var model))
|
||||
{
|
||||
model = await _robotModelApiService.GetByIdAsync(robotData.ModelId.Value);
|
||||
if (model != null)
|
||||
{
|
||||
_robotModelCache[robotData.ModelId.Value] = model;
|
||||
}
|
||||
}
|
||||
|
||||
if (model != null)
|
||||
{
|
||||
robotData.Model = model;
|
||||
}
|
||||
|
||||
// Check image cache first
|
||||
if (_robotModelImageCache.TryGetValue(robotData.ModelId.Value, out var cachedImage))
|
||||
{
|
||||
robotData.ModelImageBase64 = cachedImage;
|
||||
NotifyStateChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
// Load image from API
|
||||
var imageBase64 = await _robotModelApiService.GetImageAsync(robotData.ModelId.Value);
|
||||
if (imageBase64 != null)
|
||||
{
|
||||
_robotModelImageCache[robotData.ModelId.Value] = imageBase64;
|
||||
robotData.ModelImageBase64 = imageBase64;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore errors loading images
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// ROBOT SELECTION
|
||||
// ==========================================
|
||||
|
||||
/// <summary>
|
||||
/// Select a robot
|
||||
/// </summary>
|
||||
public void SelectRobot(string? robotId)
|
||||
{
|
||||
SelectedRobotId = robotId;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// VIEWPORT OPERATIONS
|
||||
// ==========================================
|
||||
|
||||
/// <summary>
|
||||
/// Pan the viewport by delta (in SVG units)
|
||||
/// </summary>
|
||||
public void Pan(double deltaX, double deltaY)
|
||||
{
|
||||
Viewport.ViewBoxX += deltaX;
|
||||
Viewport.ViewBoxY += deltaY;
|
||||
NotifyStateChanged(); // Use immediate for smooth panning
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Zoom the viewport around a point (in SVG/world coordinates)
|
||||
/// </summary>
|
||||
public void Zoom(double factor, double svgCenterX, double svgCenterY)
|
||||
{
|
||||
// Limit zoom level
|
||||
var newZoom = Viewport.ZoomLevel * factor;
|
||||
if (newZoom < 0.1 || newZoom > 10) return;
|
||||
|
||||
// Calculate the ratio of the cursor position within the current viewBox
|
||||
var ratioX = (svgCenterX - Viewport.ViewBoxX) / Viewport.ViewBoxWidth;
|
||||
var ratioY = (svgCenterY - Viewport.ViewBoxY) / Viewport.ViewBoxHeight;
|
||||
|
||||
// New dimensions after zoom
|
||||
var newWidth = Viewport.ViewBoxWidth / factor;
|
||||
var newHeight = Viewport.ViewBoxHeight / factor;
|
||||
|
||||
// Adjust ViewBox position so the cursor point stays at the same world position
|
||||
Viewport.ViewBoxX = svgCenterX - ratioX * newWidth;
|
||||
Viewport.ViewBoxY = svgCenterY - ratioY * newHeight;
|
||||
Viewport.ViewBoxWidth = newWidth;
|
||||
Viewport.ViewBoxHeight = newHeight;
|
||||
Viewport.ZoomLevel = newZoom;
|
||||
|
||||
NotifyStateChanged(); // Use immediate for smooth zooming
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Zoom in/out centered at the viewport center
|
||||
/// </summary>
|
||||
public void ZoomAtCenter(double factor)
|
||||
{
|
||||
// Limit zoom level
|
||||
var newZoom = Viewport.ZoomLevel * factor;
|
||||
if (newZoom < 0.1 || newZoom > 10) return;
|
||||
|
||||
// Calculate center of current viewport
|
||||
var centerX = Viewport.ViewBoxX + Viewport.ViewBoxWidth / 2;
|
||||
var centerY = Viewport.ViewBoxY + Viewport.ViewBoxHeight / 2;
|
||||
|
||||
// New dimensions after zoom
|
||||
var newWidth = Viewport.ViewBoxWidth / factor;
|
||||
var newHeight = Viewport.ViewBoxHeight / factor;
|
||||
|
||||
// Adjust viewBox to keep the same center point
|
||||
Viewport.ViewBoxX = centerX - newWidth / 2;
|
||||
Viewport.ViewBoxY = centerY - newHeight / 2;
|
||||
Viewport.ViewBoxWidth = newWidth;
|
||||
Viewport.ViewBoxHeight = newHeight;
|
||||
Viewport.ZoomLevel = newZoom;
|
||||
|
||||
NotifyStateChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fit viewport to image bounds
|
||||
/// </summary>
|
||||
public void FitToScreen()
|
||||
{
|
||||
InitializeViewport();
|
||||
NotifyStateChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Focus viewport on selected robot (with optional zoom)
|
||||
/// </summary>
|
||||
public void FocusOnRobot(string robotId, bool zoomToFit = false)
|
||||
{
|
||||
if (!Robots.TryGetValue(robotId, out var robot) || robot is null || robot.Data is null)
|
||||
return;
|
||||
|
||||
// Center viewport on robot
|
||||
var (X, Y) = WorldToSvg(robot.Data.AgvPosition.X, robot.Data.AgvPosition.Y);
|
||||
|
||||
if (zoomToFit && robot.Model != null)
|
||||
{
|
||||
// Zoom to fit robot with some padding
|
||||
var padding = 2.0; // meters padding around robot
|
||||
var robotSize = Math.Max(robot.Model.Length, robot.Model.Width);
|
||||
var viewSize = robotSize + padding * 2;
|
||||
|
||||
Viewport.ViewBoxWidth = viewSize;
|
||||
Viewport.ViewBoxHeight = viewSize;
|
||||
Viewport.ZoomLevel = GetPhysicalDimensions().Width / viewSize;
|
||||
}
|
||||
|
||||
Viewport.ViewBoxX = X - Viewport.ViewBoxWidth / 2;
|
||||
Viewport.ViewBoxY = Y - Viewport.ViewBoxHeight / 2;
|
||||
|
||||
NotifyStateChanged();
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// COORDINATE TRANSFORM
|
||||
// ==========================================
|
||||
|
||||
/// <summary>
|
||||
/// Get physical dimensions of the layout
|
||||
/// </summary>
|
||||
public (double Width, double Height) GetPhysicalDimensions()
|
||||
{
|
||||
if (Level?.EditorSettings == null)
|
||||
return (50, 30);
|
||||
|
||||
var settings = Level.EditorSettings;
|
||||
return (
|
||||
(settings.ImageWidth ?? 1000) * settings.Resolution,
|
||||
(settings.ImageHeight ?? 500) * settings.Resolution
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Transform world coordinates (layout) to SVG coordinates
|
||||
/// </summary>
|
||||
public (double X, double Y) WorldToSvg(double worldX, double worldY)
|
||||
{
|
||||
var (_, physicalHeight) = GetPhysicalDimensions();
|
||||
|
||||
var originX = Level != null && Level.EditorSettings != null ? Level.EditorSettings.OriginX : 0;
|
||||
var originY = Level != null && Level.EditorSettings != null ? Level.EditorSettings.OriginY : 0;
|
||||
return (worldX - originX, physicalHeight - (worldY - originY));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Transform SVG coordinates to world coordinates (layout)
|
||||
/// </summary>
|
||||
public (double X, double Y) SvgToWorld(double svgX, double svgY)
|
||||
{
|
||||
var (_, physicalHeight) = GetPhysicalDimensions();
|
||||
|
||||
var originX = Level != null && Level.EditorSettings != null ? Level.EditorSettings.OriginX : 0;
|
||||
var originY = Level != null && Level.EditorSettings != null ? Level.EditorSettings.OriginY : 0;
|
||||
return (svgX + originX, physicalHeight - svgY + originY);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// DISPLAY OPTIONS
|
||||
// ==========================================
|
||||
|
||||
/// <summary>
|
||||
/// Toggle follow robot mode
|
||||
/// </summary>
|
||||
public void ToggleFollowRobot()
|
||||
{
|
||||
FollowRobot = !FollowRobot;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Toggle robot info panel visibility
|
||||
/// </summary>
|
||||
public void ToggleRobotInfoPanel()
|
||||
{
|
||||
RobotInfoPanelExpanded = !RobotInfoPanelExpanded;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// UTILITIES
|
||||
// ==========================================
|
||||
|
||||
/// <summary>
|
||||
/// Remove robots that haven't been updated for a while
|
||||
/// </summary>
|
||||
private void RemoveInactiveRobots()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var timeout = TimeSpan.FromSeconds(RobotTimeoutSeconds);
|
||||
var robotsToRemove = new List<string>();
|
||||
|
||||
foreach (var kvp in Robots)
|
||||
{
|
||||
if (now - kvp.Value.LastUpdateTime > timeout)
|
||||
{
|
||||
robotsToRemove.Add(kvp.Key);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var robotId in robotsToRemove)
|
||||
{
|
||||
Robots.Remove(robotId);
|
||||
|
||||
// Clear selection if removed robot was selected
|
||||
if (SelectedRobotId == robotId)
|
||||
{
|
||||
SelectedRobotId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cleanup resources (unsubscribe from SignalR, etc.)
|
||||
/// </summary>
|
||||
public async Task CleanupAsync()
|
||||
{
|
||||
if (_hubClient != null)
|
||||
{
|
||||
// Unregister event handlers
|
||||
_hubClient.OnMonitorBoardcastUpdate -= HandleStateUpdate;
|
||||
//_hubClient.OnVisualizationUpdate -= HandleVisualizationUpdate;
|
||||
_hubClient.OnConnectionError -= HandleConnectionError;
|
||||
_hubClient.OnMonitorDeactivated -= HandleMonitorDeactivated;
|
||||
|
||||
// Unsubscribe from level before disconnecting
|
||||
try
|
||||
{
|
||||
await _hubClient.UnsubscribeFromLevelForMonitorAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore unsubscribe errors
|
||||
}
|
||||
|
||||
// Disconnect
|
||||
try
|
||||
{
|
||||
await _hubClient.DisconnectAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore disconnect errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get detailed error message from exception
|
||||
/// </summary>
|
||||
private static string GetDetailedErrorMessage(Exception ex, string operation)
|
||||
{
|
||||
var errorDetails = new System.Text.StringBuilder();
|
||||
errorDetails.AppendLine($"Failed to {operation}: {ex.Message}");
|
||||
|
||||
// Add inner exception details if available
|
||||
if (ex.InnerException != null)
|
||||
{
|
||||
errorDetails.AppendLine($"Inner exception: {ex.InnerException.Message}");
|
||||
}
|
||||
|
||||
// For HttpRequestException, try to extract more details
|
||||
if (ex is System.Net.Http.HttpRequestException httpEx)
|
||||
{
|
||||
var message = httpEx.Message;
|
||||
|
||||
// Extract status code if present
|
||||
if (message.Contains("404"))
|
||||
{
|
||||
errorDetails.AppendLine("Status: 404 Not Found - The requested resource was not found on the server.");
|
||||
}
|
||||
else if (message.Contains("401"))
|
||||
{
|
||||
errorDetails.AppendLine("Status: 401 Unauthorized - Authentication required.");
|
||||
}
|
||||
else if (message.Contains("403"))
|
||||
{
|
||||
errorDetails.AppendLine("Status: 403 Forbidden - Access denied.");
|
||||
}
|
||||
else if (message.Contains("500"))
|
||||
{
|
||||
errorDetails.AppendLine("Status: 500 Internal Server Error - Server encountered an error.");
|
||||
}
|
||||
|
||||
// Add URL if present in message
|
||||
if (message.Contains("URL:"))
|
||||
{
|
||||
var urlStart = message.IndexOf("URL:");
|
||||
if (urlStart >= 0)
|
||||
{
|
||||
var urlPart = message[urlStart..];
|
||||
errorDetails.AppendLine(urlPart);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errorDetails.ToString().Trim();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notify state changed immediately (for critical updates)
|
||||
/// </summary>
|
||||
public void NotifyStateChanged()
|
||||
{
|
||||
OnStateChanged?.Invoke();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using RobotNet.VDA5050.State;
|
||||
using RobotNet10.FleetManager.Shared.DTOs.Robot;
|
||||
|
||||
namespace RobotNet10.FleetManager.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// SignalR Hub Client for robot state and visualization updates.
|
||||
/// Manages connection to the SignalR hub and provides methods to subscribe/unsubscribe to robot updates.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This client automatically handles reconnection and provides events for state and visualization updates.
|
||||
/// Dispose the client when done to properly clean up the connection.
|
||||
/// </remarks>
|
||||
public class RobotStateHubClient : IAsyncDisposable
|
||||
{
|
||||
private readonly HubConnection _hubConnection;
|
||||
private readonly ILogger<RobotStateHubClient>? _logger;
|
||||
|
||||
public event Action<RobotMonitorBoardcastData>? OnMonitorBoardcastUpdate;
|
||||
public event Action<StateMsg>? OnStateUpdate;
|
||||
public event Action<string>? OnConnectionError;
|
||||
public event Action? OnMonitorDeactivated;
|
||||
|
||||
public RobotStateHubClient(NavigationManager navigationManager, ILogger<RobotStateHubClient>? logger = null)
|
||||
{
|
||||
_logger = logger;
|
||||
var hubUrl = navigationManager.ToAbsoluteUri("/hubs/robot-state");
|
||||
|
||||
_hubConnection = new HubConnectionBuilder()
|
||||
.WithUrl(hubUrl)
|
||||
.WithAutomaticReconnect()
|
||||
.Build();
|
||||
|
||||
// Register event handlers
|
||||
_hubConnection.On<StateMsg>("OnStateUpdate", (state) =>
|
||||
{
|
||||
OnStateUpdate?.Invoke(state);
|
||||
});
|
||||
|
||||
_hubConnection.On<RobotMonitorBoardcastData>("OnMonitorUpdate", (state) =>
|
||||
{
|
||||
OnMonitorBoardcastUpdate?.Invoke(state);
|
||||
});
|
||||
|
||||
_hubConnection.On("OnMonitorDeactivated", () =>
|
||||
{
|
||||
OnMonitorDeactivated?.Invoke();
|
||||
});
|
||||
|
||||
_hubConnection.Closed += async (error) =>
|
||||
{
|
||||
if (error != null)
|
||||
{
|
||||
_logger?.LogError(error, "SignalR connection closed with error");
|
||||
OnConnectionError?.Invoke(error.Message);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger?.LogInformation("SignalR connection closed");
|
||||
}
|
||||
await Task.CompletedTask;
|
||||
};
|
||||
|
||||
_hubConnection.Reconnecting += async (error) =>
|
||||
{
|
||||
_logger?.LogWarning(error, "SignalR connection reconnecting");
|
||||
await Task.CompletedTask;
|
||||
};
|
||||
|
||||
_hubConnection.Reconnected += async (connectionId) =>
|
||||
{
|
||||
_logger?.LogInformation("SignalR connection reconnected with ID {ConnectionId}", connectionId);
|
||||
await Task.CompletedTask;
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Connect to the SignalR hub
|
||||
/// </summary>
|
||||
public async Task ConnectAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_hubConnection.State == HubConnectionState.Disconnected)
|
||||
{
|
||||
await _hubConnection.StartAsync();
|
||||
_logger?.LogInformation("SignalR hub connected");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogError(ex, "Error connecting to SignalR hub");
|
||||
OnConnectionError?.Invoke(ex.Message);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disconnect from the SignalR hub
|
||||
/// </summary>
|
||||
public async Task DisconnectAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_hubConnection.State != HubConnectionState.Disconnected)
|
||||
{
|
||||
await _hubConnection.StopAsync();
|
||||
_logger?.LogInformation("SignalR hub disconnected");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogError(ex, "Error disconnecting from SignalR hub");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subscribe to receive updates for a specific robot
|
||||
/// </summary>
|
||||
/// <param name="robotId">Robot identifier (serialNumber)</param>
|
||||
public async Task SubscribeToRobotAsync(string robotId)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_hubConnection.State == HubConnectionState.Connected)
|
||||
{
|
||||
await _hubConnection.InvokeAsync("SubscribeToRobot", robotId);
|
||||
_logger?.LogInformation("Subscribed to robot {RobotId}", robotId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger?.LogWarning("Cannot subscribe to robot {RobotId}: Hub not connected", robotId);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogError(ex, "Error subscribing to robot {RobotId}", robotId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unsubscribe from updates for a specific robot
|
||||
/// </summary>
|
||||
/// <param name="robotId">Robot identifier (serialNumber)</param>
|
||||
public async Task UnsubscribeFromRobotAsync(string robotId)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_hubConnection.State == HubConnectionState.Connected)
|
||||
{
|
||||
await _hubConnection.InvokeAsync("UnsubscribeFromRobot", robotId);
|
||||
_logger?.LogInformation("Unsubscribed from robot {RobotId}", robotId);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogError(ex, "Error unsubscribing from robot {RobotId}", robotId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subscribe to receive monitor updates for a specific levelId
|
||||
/// Each connection can only subscribe to one levelId at a time.
|
||||
/// Maximum 5 connections per levelId (FIFO).
|
||||
/// </summary>
|
||||
/// <param name="levelId">Level identifier (LayoutLevel.Id)</param>
|
||||
public async Task SubscribeToLevelForMonitorAsync(Guid levelId)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_hubConnection.State == HubConnectionState.Connected)
|
||||
{
|
||||
await _hubConnection.InvokeAsync("SubscribeToLevelForMonitor", levelId);
|
||||
_logger?.LogInformation("Subscribed to level {LevelId} for monitor", levelId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger?.LogWarning("Cannot subscribe to level {LevelId}: Hub not connected", levelId);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogError(ex, "Error subscribing to level {LevelId} for monitor", levelId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unsubscribe from monitor updates for the current level
|
||||
/// </summary>
|
||||
public async Task UnsubscribeFromLevelForMonitorAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_hubConnection.State == HubConnectionState.Connected)
|
||||
{
|
||||
await _hubConnection.InvokeAsync("UnsubscribeFromLevelForMonitor");
|
||||
_logger?.LogInformation("Unsubscribed from level for monitor");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogError(ex, "Error unsubscribing from level for monitor");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the current connection state
|
||||
/// </summary>
|
||||
public HubConnectionState ConnectionState => _hubConnection.State;
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_hubConnection != null)
|
||||
{
|
||||
await DisconnectAsync();
|
||||
await _hubConnection.DisposeAsync();
|
||||
}
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using RobotNet10.ScriptEngine.Shared;
|
||||
using System.Collections.Immutable;
|
||||
using RobotNet10.FleetManager.Script.Shared;
|
||||
|
||||
|
||||
namespace RobotNet10.FleetManager.Client.Services;
|
||||
|
||||
public class ScriptEngineResource : IScriptEngineResource
|
||||
{
|
||||
public Type AppGlobalType => FleetManagerScriptEngineResource.GlobalType;
|
||||
|
||||
public ImmutableArray<string> UsingNamespaces => FleetManagerScriptEngineResource.UsingNamespaces;
|
||||
|
||||
public ImmutableArray<string> Modules => FleetManagerScriptEngineResource.Modules;
|
||||
|
||||
public ImmutableArray<string> DocModules => FleetManagerScriptEngineResource.DocModules;
|
||||
|
||||
public IDictionary<string, object?> GetMissionGlobals(Guid id, CancellationToken cancellationToken)
|
||||
=> new Dictionary<string, object?>();
|
||||
|
||||
public IDictionary<string, object?> GetTaskGlobals()
|
||||
=> new Dictionary<string, object?>();
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
@using System.Net.Http
|
||||
@using System.Net.Http.Json
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using static RobotNet10.Components.RenderMode
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
@using Microsoft.JSInterop
|
||||
@using RobotNet10.FleetManager.Client
|
||||
@using MudBlazor
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// SVG Monitor JavaScript Module for RobotMonitor
|
||||
// Handles mouse events for zoom and pan (simplified version of svgEditor.js)
|
||||
|
||||
let svgElement = null;
|
||||
let dotNetRef = null;
|
||||
let isInitialized = false;
|
||||
|
||||
/**
|
||||
* Initialize the SVG monitor with event listeners
|
||||
* @param {SVGElement} svg - The SVG element
|
||||
* @param {DotNetObjectReference} dotNet - Reference to Blazor component
|
||||
*/
|
||||
export function initMonitor(svg, dotNet) {
|
||||
svgElement = svg;
|
||||
dotNetRef = dotNet;
|
||||
|
||||
if (!svgElement || !dotNetRef) {
|
||||
console.error('SVG Monitor: Invalid initialization parameters');
|
||||
return;
|
||||
}
|
||||
|
||||
// Mouse events (only zoom and pan)
|
||||
svgElement.addEventListener('mousemove', handleMouseMove);
|
||||
svgElement.addEventListener('mousedown', handleMouseDown);
|
||||
svgElement.addEventListener('mouseup', handleMouseUp);
|
||||
svgElement.addEventListener('wheel', handleWheel, { passive: false });
|
||||
svgElement.addEventListener('contextmenu', handleContextMenu);
|
||||
|
||||
// Prevent default drag behavior
|
||||
svgElement.addEventListener('dragstart', (e) => e.preventDefault());
|
||||
|
||||
isInitialized = true;
|
||||
console.log('SVG Monitor initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose the monitor and remove event listeners
|
||||
*/
|
||||
export function disposeMonitor() {
|
||||
if (svgElement) {
|
||||
svgElement.removeEventListener('mousemove', handleMouseMove);
|
||||
svgElement.removeEventListener('mousedown', handleMouseDown);
|
||||
svgElement.removeEventListener('mouseup', handleMouseUp);
|
||||
svgElement.removeEventListener('wheel', handleWheel);
|
||||
svgElement.removeEventListener('contextmenu', handleContextMenu);
|
||||
}
|
||||
|
||||
svgElement = null;
|
||||
dotNetRef = null;
|
||||
isInitialized = false;
|
||||
console.log('SVG Monitor disposed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert screen coordinates to SVG coordinates
|
||||
* @param {number} screenX - Screen X coordinate
|
||||
* @param {number} screenY - Screen Y coordinate
|
||||
* @returns {{x: number, y: number}} SVG coordinates
|
||||
*/
|
||||
function screenToSvg(screenX, screenY) {
|
||||
if (!svgElement) return { x: 0, y: 0 };
|
||||
|
||||
const pt = svgElement.createSVGPoint();
|
||||
pt.x = screenX;
|
||||
pt.y = screenY;
|
||||
|
||||
const ctm = svgElement.getScreenCTM();
|
||||
if (!ctm) return { x: 0, y: 0 };
|
||||
|
||||
const svgPt = pt.matrixTransform(ctm.inverse());
|
||||
return { x: svgPt.x, y: svgPt.y };
|
||||
}
|
||||
|
||||
/**
|
||||
* Export screenToSvg for use from Blazor
|
||||
* @param {number} screenX - Screen X coordinate
|
||||
* @param {number} screenY - Screen Y coordinate
|
||||
* @returns {number[]} SVG coordinates as [x, y]
|
||||
*/
|
||||
export function screenToSvgArray(screenX, screenY) {
|
||||
const coords = screenToSvg(screenX, screenY);
|
||||
return [coords.x, coords.y];
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mouse move event
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
function handleMouseMove(e) {
|
||||
if (!dotNetRef) return;
|
||||
|
||||
const svgCoords = screenToSvg(e.clientX, e.clientY);
|
||||
// Pass SVG coordinates and screen coordinates for panning
|
||||
dotNetRef.invokeMethodAsync('OnMouseMove', svgCoords.x, svgCoords.y, e.clientX, e.clientY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mouse down event
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
function handleMouseDown(e) {
|
||||
if (!dotNetRef) return;
|
||||
|
||||
const svgCoords = screenToSvg(e.clientX, e.clientY);
|
||||
|
||||
// Middle mouse button (button 1) - start pan
|
||||
// Also pass screen coordinates for incremental pan calculation
|
||||
dotNetRef.invokeMethodAsync('OnMouseDown', svgCoords.x, svgCoords.y, e.button, e.clientX, e.clientY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mouse up event
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
function handleMouseUp(e) {
|
||||
if (!dotNetRef) return;
|
||||
|
||||
const svgCoords = screenToSvg(e.clientX, e.clientY);
|
||||
dotNetRef.invokeMethodAsync('OnMouseUp', svgCoords.x, svgCoords.y, e.button);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mouse wheel event (zoom)
|
||||
* @param {WheelEvent} e
|
||||
*/
|
||||
function handleWheel(e) {
|
||||
if (!dotNetRef) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const svgCoords = screenToSvg(e.clientX, e.clientY);
|
||||
dotNetRef.invokeMethodAsync('OnWheel', svgCoords.x, svgCoords.y, e.deltaY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle context menu (right click)
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
function handleContextMenu(e) {
|
||||
// Prevent default context menu
|
||||
e.preventDefault();
|
||||
}
|
||||
Reference in New Issue
Block a user