diff --git a/solution/src/PlcVanguard.Web/Components/Layout/MainLayout.razor b/solution/src/PlcVanguard.Web/Components/Layout/MainLayout.razor
index 316a7db..20a67de 100644
--- a/solution/src/PlcVanguard.Web/Components/Layout/MainLayout.razor
+++ b/solution/src/PlcVanguard.Web/Components/Layout/MainLayout.razor
@@ -1,37 +1,23 @@
@inherits LayoutComponentBase
-@using System.Security.Claims
-
-
-
-
-
-
-
About
- @if (CurrentContext?.User?.Identity?.IsAuthenticated == true)
- {
- var name = CurrentContext.User.FindFirst(ClaimTypes.Name)?.Value;
-
@name [Sign Out]
- }
- else
- {
-
Sign In
- }
-
-
-
- @Body
-
-
-
+
+
+
+ @Body
+
+
An unhandled error has occurred.
Reload
🗙
-@code {
- [Inject] private HttpContext CurrentContext { get; set; } = default!;
-}
+
diff --git a/solution/src/PlcVanguard.Web/Components/Layout/NavMenu.razor b/solution/src/PlcVanguard.Web/Components/Layout/NavMenu.razor
index 3f56954..0e7d49e 100644
--- a/solution/src/PlcVanguard.Web/Components/Layout/NavMenu.razor
+++ b/solution/src/PlcVanguard.Web/Components/Layout/NavMenu.razor
@@ -1,29 +1,170 @@
-
+@inject PLCService PlcService
+@inject NavigationManager NavManager
+@implements IDisposable
+@using Microsoft.AspNetCore.Components.Routing
+@using System.Security.Claims
+
+
+
-
+@code {
+ [Inject] private HttpContext? CurrentContext { get; set; }
-
+ public void Dispose()
+ {
+ _pollTimer?.Dispose();
+ }
+}
+
+
diff --git a/solution/src/PlcVanguard.Web/Components/Shared/HistoryTable.razor b/solution/src/PlcVanguard.Web/Components/Shared/HistoryTable.razor
new file mode 100644
index 0000000..1c6891a
--- /dev/null
+++ b/solution/src/PlcVanguard.Web/Components/Shared/HistoryTable.razor
@@ -0,0 +1,90 @@
+@using Microsoft.AspNetCore.Components.Web
+@using PlcVanguard.Web.Components.Shared
+
+
+
Recording Sessions
+
+
+
+
+ | Session ID |
+ Timestamp |
+ Status |
+ Action |
+
+
+
+ @if (Sessions.Any())
+ {
+ @foreach (var s in Sessions)
+ {
+
+ | @s.Id |
+ @s.Timestamp.ToString("yyyy-MM-dd HH:mm:ss") |
+
+ @if (s.Status == "Success")
+ {
+ @s.Status
+ }
+ else
+ {
+ @s.Status
+ }
+ |
+
+
+ |
+
+ }
+ }
+ else
+ {
+
+ | No recording sessions found. |
+
+ }
+
+
+
+
+ @if (Sessions.Any())
+ {
+
+
+ Page @Page of @TotalPages
+
+
+ }
+
+
+@code {
+ [Parameter] public List Sessions { get; set; } = new();
+ [Parameter] public EventCallback SelectedSession { get; set; }
+ [Parameter] public int Page { get; set; } = 1;
+ [Parameter] public int PageSize { get; set; } = 10;
+
+ private int TotalPages => (int)Math.Ceiling(Sessions.Count / (double)PageSize);
+
+ private void PrevPage()
+ {
+ if (Page > 1)
+ SelectedSession.InvokeAsync(Sessions[Math.Max(0, (Page - 2) * PageSize % Sessions.Count)].Id);
+ }
+
+ private void NextPage()
+ {
+ if (Page < TotalPages)
+ SelectedSession.InvokeAsync(Sessions[Math.Min(Sessions.Count - 1, Page * PageSize % Sessions.Count)].Id);
+ }
+}
diff --git a/solution/src/PlcVanguard.Web/Components/Shared/LiveChart.razor b/solution/src/PlcVanguard.Web/Components/Shared/LiveChart.razor
new file mode 100644
index 0000000..d8716eb
--- /dev/null
+++ b/solution/src/PlcVanguard.Web/Components/Shared/LiveChart.razor
@@ -0,0 +1,76 @@
+@using Microsoft.JSInterop
+@using PlcVanguard.Web.Components.Shared
+@implements IAsyncDisposable
+
+
+
+
@Title
+ @subtitle
+
+
+
+
+
+
+@code {
+ [Parameter] public string Title { get; set; } = "Live Chart";
+ [Parameter] public string subtitle { get; set; } = "";
+ [Parameter] public List Data { get; set; } = new();
+ [Parameter] public string ChartId { get; set; } = "liveChart";
+ [Parameter] public EventCallback OnChartReady { get; set; }
+
+ [Inject] private IJSRuntime JS { get; set; } = default!;
+ private IJSObjectReference? _jsModule;
+ private bool _initialized;
+
+ protected override async Task OnAfterRenderAsync(bool firstRender)
+ {
+ if (firstRender && Data.Count > 1)
+ {
+ var ok = await EnsureChartAsync();
+ if (ok) await OnChartReady.InvokeAsync();
+ }
+ else if (_initialized && Data.Count > 1)
+ {
+ await UpdateChartAsync();
+ }
+ }
+
+ private async Task EnsureChartAsync()
+ {
+ try
+ {
+ _jsModule = await JS.InvokeAsync("import", "/js/chartHelper.js");
+ await _jsModule.InvokeVoidAsync("initChart", ChartId, Data);
+ _initialized = true;
+ return true;
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine("Chart init failed: " + ex.Message);
+ return false;
+ }
+ }
+
+ private async Task UpdateChartAsync()
+ {
+ try
+ {
+ if (_jsModule == null)
+ _jsModule = await JS.InvokeAsync("import", "/js/chartHelper.js");
+ await _jsModule.InvokeVoidAsync("updateLiveDataset", ChartId, Data);
+ return true;
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine("Chart update failed: " + ex.Message);
+ return false;
+ }
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ if (_jsModule != null)
+ await _jsModule.DisposeAsync();
+ }
+}
diff --git a/solution/src/PlcVanguard.Web/Components/Shared/LivePanel.razor b/solution/src/PlcVanguard.Web/Components/Shared/LivePanel.razor
new file mode 100644
index 0000000..b7ec3b1
--- /dev/null
+++ b/solution/src/PlcVanguard.Web/Components/Shared/LivePanel.razor
@@ -0,0 +1,68 @@
+@using Microsoft.AspNetCore.Components.Web
+@using PlcVanguard.Web.Components.Shared
+
+
+
Control Panel
+
+ @if (IsAcquiring)
+ {
+
+
Acquisition in progress... @($"{ProgressPercent:F0}")%
+ }
+ else
+ {
+ @if (IsConnected)
+ {
+
PLC Connected
@($"{plcIp} | {plcType}")
+
+
+ }
+ else
+ {
+
Target: @($"{plcIp} ({plcType})")
+
+ }
+ }
+
+
+
+@if (IsConnected)
+{
+
+
System Overview
+
+ - PLC: @plcType
+ - Area: DB100 (Continuous)
+ - Mode: @($"{SampleRate}Hz Polling")
+ - Persistence: File + MariaDB
+
+
+}
+
+@code {
+ [Parameter] public bool IsConnected { get; set; }
+ [Parameter] public string plcIp { get; set; } = "192.168.0.102";
+ [Parameter] public string plcType { get; set; } = "S7-1500";
+ [Parameter] public bool IsAcquiring { get; set; }
+ [Parameter] public double ProgressPercent { get; set; }
+ [Parameter] public int SampleRate { get; set; } = 5;
+
+ [Parameter] public EventCallback OnConnect { get; set; }
+ [Parameter] public EventCallback OnDisconnect { get; set; }
+ [Parameter] public EventCallback OnStartAcquisition { get; set; }
+
+ private async Task Connect() => await OnConnect.InvokeAsync();
+ private async Task Disconnect() => await OnDisconnect.InvokeAsync();
+ private async Task StartAcquisition() => await OnStartAcquisition.InvokeAsync();
+}
diff --git a/solution/src/PlcVanguard.Web/Components/Shared/SessionData.cs b/solution/src/PlcVanguard.Web/Components/Shared/SessionData.cs
new file mode 100644
index 0000000..4c5878a
--- /dev/null
+++ b/solution/src/PlcVanguard.Web/Components/Shared/SessionData.cs
@@ -0,0 +1,23 @@
+namespace PlcVanguard.Web.Components.Shared;
+
+public class DataLabel
+{
+ public double X { get; set; }
+ public double Y { get; set; }
+}
+
+public class PointData
+{
+ public int Index { get; set; }
+ public DateTime Timestamp { get; set; }
+ public double Value { get; set; }
+ public bool IsError { get; set; }
+ public bool IsWarning { get; set; }
+}
+
+public class SessionInfo
+{
+ public string Id { get; set; } = string.Empty;
+ public DateTime Timestamp { get; set; }
+ public string Status { get; set; } = "Success";
+}
diff --git a/solution/src/PlcVanguard.Web/Components/Shared/SessionDetail.razor b/solution/src/PlcVanguard.Web/Components/Shared/SessionDetail.razor
new file mode 100644
index 0000000..eb8ec60
--- /dev/null
+++ b/solution/src/PlcVanguard.Web/Components/Shared/SessionDetail.razor
@@ -0,0 +1,116 @@
+@using PlcVanguard.Web.Components.Shared
+
+
+ @if (SessionId == null)
+ {
+
+ Click a session in the left table to load data
+
+
+ Select a recording session to view details
+
+ }
+ else
+ {
+
+ Session @SessionId
+
+
+
+
Session Summary
+
+
+
+ | Index |
+ Timestamp |
+ Value |
+ Status |
+
+
+
+ @foreach (var p in DisplayPoints)
+ {
+ { SelectedPoint = p; StateHasChanged(); }">
+ | @p.Index |
+ @p.Timestamp.ToString("HH:mm:ss.fff") |
+ @p.Value.ToString("F3") |
+
+ @if (p.IsError)
+ {
+ Error
+ }
+ else if (p.IsWarning)
+ {
+ Warning
+ }
+ else
+ {
+ OK
+ }
+ |
+
+ }
+
+
+
+
+
+
+ Page @CurrentPage of @TotalPages
+
+
+
+ @if (SelectedPoint != null)
+ {
+
+
Selected Point
+
Idx = @(SelectedPoint.Index), Val = @(SelectedPoint.Value.ToString("F3")), Time = @(SelectedPoint.Timestamp.ToString("HH:mm:ss.fff"))
+
+ }
+ }
+
+
+@code {
+ [Parameter] public string SessionId { get; set; }
+ [Parameter] public List AllPoints { get; set; } = new();
+ [Parameter] public int WarningLimit { get; set; } = 2;
+ [Parameter] public int ErrorLimit { get; set; } = 3;
+ [Parameter] public double TargetMean { get; set; } = 210;
+
+ private int CurrentPage { get; set; } = 1;
+ private const int PageSize = 10;
+ private int TotalPages => (int)Math.Ceiling(AllPoints.Count / (double)PageSize);
+ private PointData? SelectedPoint { get; set; }
+
+ private List DisplayPoints => AllPoints
+ .Skip((CurrentPage - 1) * PageSize)
+ .Take(PageSize)
+ .ToList();
+
+ private void NextPage()
+ {
+ if (CurrentPage < TotalPages)
+ {
+ CurrentPage++;
+ StateHasChanged();
+ }
+ }
+
+ private void PrevPage()
+ {
+ if (CurrentPage > 1)
+ {
+ CurrentPage--;
+ StateHasChanged();
+ }
+ }
+}
diff --git a/solution/src/PlcVanguard.Web/wwwroot/app.css b/solution/src/PlcVanguard.Web/wwwroot/app.css
index f17c1bb..d7d8b34 100644
--- a/solution/src/PlcVanguard.Web/wwwroot/app.css
+++ b/solution/src/PlcVanguard.Web/wwwroot/app.css
@@ -141,3 +141,8 @@ body {
.chart-polyline {
animation: chart-draw 1s ease-in-out;
}
+
+@keyframes _pv_pulse {
+ 0%, 100% { opacity: 1; transform: scale(1); }
+ 50% { opacity: 0.5; transform: scale(1.3); }
+}
diff --git a/solution/src/PlcVanguard.Web/wwwroot/index.html b/solution/src/PlcVanguard.Web/wwwroot/index.html
index 463ecc1..5a7c8b4 100644
--- a/solution/src/PlcVanguard.Web/wwwroot/index.html
+++ b/solution/src/PlcVanguard.Web/wwwroot/index.html
@@ -1,10 +1,24 @@
-using Microsoft.AspNetCore.Components.Web;
-using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
+
+
+
+
+
+ PlcVanguard
+
+
+
+
+
+
+
+
+
-var builder = WebAssemblyHostBuilder.CreateDefault(args);
-builder.RootComponents.Add("#app");
-builder.RootComponents.Add("head::after");
-
-builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
-
-await builder.Build().RunAsync();
+
+ An unhandled error has occurred.
+
Reload
+
🗙
+
+
+
+
diff --git a/solution/src/PlcVanguard.Web/wwwroot/js/chartHelper.js b/solution/src/PlcVanguard.Web/wwwroot/js/chartHelper.js
new file mode 100644
index 0000000..3afdc70
--- /dev/null
+++ b/solution/src/PlcVanguard.Web/wwwroot/js/chartHelper.js
@@ -0,0 +1,148 @@
+let chartInstances = {};
+
+window.initChart = function(canvasId, dataItems) {
+ if (typeof Chart === 'undefined') return;
+
+ const ctx = document.getElementById(canvasId);
+ if (!ctx) return;
+
+ const values = Array.isArray(dataItems[0])
+ ? dataItems[0]
+ : dataItems;
+
+ // Ensure we have at least 2 points
+ if (values.length < 2) return;
+
+ // Generate y-limits if not provided
+ const minVal = Math.min(...values.map(v => v.Y));
+ const maxVal = Math.max(...values.map(v => v.Y));
+ const range = maxVal - minVal || 1;
+ const padding = range * 0.15;
+
+ chartInstances[canvasId] = new Chart(ctx, {
+ type: 'line',
+ data: {
+ datasets: [
+ {
+ label: 'Value',
+ data: values.map(v => ({ x: v.X, y: v.Y })),
+ borderColor: '#3b82f6',
+ backgroundColor: 'rgba(59, 130, 246, 0.1)',
+ fill: true,
+ pointRadius: 1,
+ borderWidth: 2,
+ tension: 0.1
+ },
+ {
+ label: 'Warning UCL',
+ data: values.map(v => ({ x: v.X, y: minVal + range + padding })),
+ borderColor: '#facc15',
+ borderWidth: 1,
+ pointRadius: 0,
+ fill: false,
+ borderDash: [5, 5]
+ },
+ {
+ label: 'Warning LCL',
+ data: values.map(v => ({ x: v.X, y: minVal - padding })),
+ borderColor: '#facc15',
+ borderWidth: 1,
+ pointRadius: 0,
+ fill: false,
+ borderDash: [5, 5]
+ },
+ {
+ label: 'Error UCL',
+ data: values.map(v => ({ x: v.X, y: minVal + range * 2 + padding })),
+ borderColor: '#f87171',
+ borderWidth: 1,
+ pointRadius: 0,
+ fill: false,
+ borderDash: [5, 5]
+ },
+ {
+ label: 'Error LCL',
+ data: values.map(v => ({ x: v.X, y: minVal - range * 2 - padding })),
+ borderColor: '#f87171',
+ borderWidth: 1,
+ pointRadius: 0,
+ fill: false,
+ borderDash: [5, 5]
+ }
+ ]
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ animation: { duration: 300 },
+ scales: {
+ x: { type: 'linear', title: { display: true, text: 'Acquisition Time' } },
+ y: { min: minVal - padding, max: maxVal + padding, title: { display: true, text: 'Value' } }
+ },
+ plugins: {
+ legend: { display: false },
+ tooltip: { enabled: true, mode: 'index', intersect: false }
+ }
+ }
+ });
+};
+
+window.updateLiveDataset = function(canvasId, dataItems) {
+ const chart = chartInstances[canvasId];
+ if (!chart) return;
+ if (!dataItems || !dataItems.length) return;
+
+ const newData = Array.isArray(dataItems[0])
+ ? dataItems[0]
+ : dataItems;
+
+ if (newData.length < 2) return;
+
+ chart.data.datasets[0].data = newData.map(v => ({ x: v.X, y: v.Y }));
+
+ const minVal = Math.min(...newData.map(v => v.Y));
+ const maxVal = Math.max(...newData.map(v => v.Y));
+ const range = maxVal - minVal || 1;
+ const padding = range * 0.15;
+
+ chart.data.datasets[1].data = newData.map(v => ({ x: v.X, y: minVal + range + padding }));
+ chart.data.datasets[2].data = newData.map(v => ({ x: v.X, y: minVal - padding }));
+ chart.data.datasets[3].data = newData.map(v => ({ x: v.X, y: minVal + range * 2 + padding }));
+ chart.data.datasets[4].data = newData.map(v => ({ x: v.X, y: minVal - range * 2 - padding }));
+
+ chart.options.scales.y.min = minVal - padding;
+ chart.options.scales.y.max = maxVal + padding;
+
+ chart.update();
+};
+
+window.updateFromSession = function(canvasId, dataItems) {
+ const chart = chartInstances[canvasId];
+ if (!chart) return;
+ const newData = Array.isArray(dataItems[0]) ? dataItems[0] : dataItems;
+ if (newData.length < 2) return;
+
+ chart.data.datasets[0].data = newData.map(v => ({ x: v.X, y: v.Y }));
+
+ const minVal = Math.min(...newData.map(v => v.Y));
+ const maxVal = Math.max(...newData.map(v => v.Y));
+ const range = maxVal - minVal || 1;
+ const padding = range * 0.15;
+
+ chart.data.datasets[1].data = newData.map(v => ({ x: v.X, y: minVal + range + padding }));
+ chart.data.datasets[2].data = newData.map(v => ({ x: v.X, y: minVal - padding }));
+ chart.data.datasets[3].data = newData.map(v => ({ x: v.X, y: minVal + range * 2 + padding }));
+ chart.data.datasets[4].data = newData.map(v => ({ x: v.X, y: minVal - range * 2 - padding }));
+
+ chart.options.scales.y.min = minVal - padding;
+ chart.options.scales.y.max = maxVal + padding;
+
+ chart.update();
+};
+
+window.destroyChart = function(canvasId) {
+ if (chartInstances[canvasId]) {
+ chartInstances[canvasId].destroy();
+ chartInstances[canvasId] = null;
+ }
+};