Update grafico nuova gestione pagina

This commit is contained in:
Samuele Locatelli
2026-06-22 17:33:26 +02:00
parent e8ea999de3
commit b1efea27f9
10 changed files with 728 additions and 61 deletions
@@ -1,37 +1,23 @@
@inherits LayoutComponentBase
@using System.Security.Claims
<div class="page">
<div class="sidebar">
<NavMenu />
</div>
<main>
<div class="top-row px-4">
<a href="https://learn.microsoft.com/aspnet/core/" target="_blank">About</a>
@if (CurrentContext?.User?.Identity?.IsAuthenticated == true)
{
var name = CurrentContext.User.FindFirst(ClaimTypes.Name)?.Value;
<span class="ms-auto me-3">@name [Sign Out]</span>
}
else
{
<a href="/login" class="ms-auto me-3">Sign In</a>
}
</div>
<article class="content px-4">
@Body
</article>
</main>
</div>
<NavMenu />
<main class="main-content">
<article class="content px-4 pt-5">
@Body
</article>
</main>
<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="" class="reload">Reload</a>
<a class="dismiss">🗙</a>
</div>
@code {
[Inject] private HttpContext CurrentContext { get; set; } = default!;
}
<style>
body {
margin-top: 56px;
background-color: #f1f5f9;
}
.main-content {
min-height: calc(100vh - 56px);
}
</style>
@@ -1,29 +1,170 @@
<div class="top-row ps-3 navbar navbar-dark">
@inject PLCService PlcService
@inject NavigationManager NavManager
@implements IDisposable
@using Microsoft.AspNetCore.Components.Routing
@using System.Security.Claims
<nav class="navbar-custom">
<div class="container-fluid">
<a class="navbar-brand" href="">PlcVanguard</a>
<div class="nav-brand d-flex align-items-center">
<NavLink Match="NavLinkMatch.All" href="/" class="d-flex align-items-center text-decoration-none">
<span class="logo-p rounded-circle me-2">P</span>
<span class="brand-text fw-semibold">PlcVanguard</span>
</NavLink>
</div>
<div class="nav-links d-flex align-items-center">
<NavLink href="/counter" class="nav-link-custom" Match="NavLinkMatch.Prefix">
History
</NavLink>
<NavLink href="/comparison" class="nav-link-custom" Match="NavLinkMatch.Prefix">
Comparison
</NavLink>
</div>
<div class="nav-right d-flex align-items-center gap-3">
<div class="connection-status d-flex align-items-center gap-2">
<span class="pulse-dot @(state.IsConnected ? "pulse-active" : "pulse-inactive")"></span>
<span class="status-text @(state.IsConnected ? "text-success" : "text-danger")">
@if (state.IsConnected)
{
<text>Connected: @state.IpAddress (@state.CpuType)</text>
}
else
{
<text>Disconnected</text>
}
</span>
</div>
@if (CurrentContext?.User?.Identity?.IsAuthenticated == true)
{
var name = CurrentContext.User.Claims.Where(c => c.Type == ClaimTypes.Name).Select(c => c.Value).FirstOrDefault();
<a href="/logout" class="text-white-50 small">Sign Out @name</a>
}
else
{
<a href="/login" class="text-white-50 small">Sign In</a>
}
</div>
</div>
</div>
</nav>
<input type="checkbox" title="Navigation menu" class="navbar-toggler" />
@code {
[Inject] private HttpContext? CurrentContext { get; set; }
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
<nav class="flex-column">
<div class="nav-item px-3">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
<span class="bi bi-house-door-fill" aria-hidden="true"></span> Dashboard
</NavLink>
</div>
private PlcState state = new();
private Timer? _pollTimer;
<div class="nav-item px-3">
<NavLink class="nav-link" href="counter">
<span class="bi bi-plus-square" aria-hidden="true"></span> Historical
</NavLink>
</div>
protected override void OnInitialized()
{
state = PlcService.GetState();
_pollTimer = new Timer(_ =>
{
InvokeAsync(() =>
{
state = PlcService.GetState();
StateHasChanged();
});
}, null, 1000, 1000);
}
<div class="nav-item px-3">
<NavLink class="nav-link" href="comparison">
<span class="bi bi-graph-up" aria-hidden="true"></span> Comparison
</NavLink>
</div>
</nav>
</div>
public void Dispose()
{
_pollTimer?.Dispose();
}
}
<style>
.navbar-custom {
position: fixed;
top: 0;
left: 0;
right: 0;
background-color: #0f172a;
height: 56px;
border-bottom: 1px solid #1e293b;
z-index: 1040;
}
.navbar-custom .container-fluid {
display: flex;
justify-content: space-between;
align-items: center;
height: 100%;
padding: 0 16px;
}
.brand-link {
text-decoration: none;
display: flex;
align-items: center;
}
.brand-text {
color: #f8fafc;
font-size: 1.1rem;
}
.logo-p {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background-color: #3b82f6;
color: #ffffff;
font-weight: bold;
font-size: 14px;
border-radius: 6px;
flex-shrink: 0;
}
.nav-link-custom {
color: #94a3b8 !important;
padding: 8px 16px;
border-radius: 6px;
font-size: 0.9rem;
transition: background-color 0.2s, color 0.2s;
text-decoration: none !important;
}
.nav-link-custom:hover {
background-color: rgba(255, 255, 255, 0.05) !important;
color: #f8fafc !important;
}
.nav-link-custom.active {
background-color: rgba(59, 130, 246, 0.2) !important;
color: #60a5fa !important;
}
.nav-links {
display: flex;
gap: 4px;
}
.connection-status {
font-size: 0.82rem;
}
.status-text {
font-weight: 500;
white-space: nowrap;
}
.pulse-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #64748b;
flex-shrink: 0;
}
.pulse-active {
background-color: #22c55e;
box-shadow: 0 0 6px rgba(34, 197, 94, 0.6);
animation: _pv_pulse 2s ease-in-out infinite;
}
.pulse-inactive {
background-color: #dc2626;
}
</style>
@@ -0,0 +1,90 @@
@using Microsoft.AspNetCore.Components.Web
@using PlcVanguard.Web.Components.Shared
<div class="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
<h2 class="font-semibold text-lg mb-4">Recording Sessions</h2>
<div class="overflow-x-auto">
<table class="w-full text-left text-xs">
<thead class="bg-gray-50">
<tr class="border-b">
<th class="p-2">Session ID</th>
<th class="p-2">Timestamp</th>
<th class="p-2">Status</th>
<th class="p-2">Action</th>
</tr>
</thead>
<tbody>
@if (Sessions.Any())
{
@foreach (var s in Sessions)
{
<tr class="border-b hover:bg-gray-50 transition">
<td class="p-2 font-mono font-semibold text-blue-600">@s.Id</td>
<td class="p-2">@s.Timestamp.ToString("yyyy-MM-dd HH:mm:ss")</td>
<td class="p-2">
@if (s.Status == "Success")
{
<span class="px-2 py-1 rounded-full text-[10px] font-bold bg-green-100 text-green-700">@s.Status</span>
}
else
{
<span class="px-2 py-1 rounded-full text-[10px] font-bold bg-gray-100 text-gray-700">@s.Status</span>
}
</td>
<td class="p-2">
<button class="text-blue-600 hover:underline font-semibold"
@onclick="() => SelectedSession.InvokeAsync(s.Id)">
Load
</button>
</td>
</tr>
}
}
else
{
<tr>
<td colspan="4" class="p-4 text-center text-gray-400 italic">No recording sessions found.</td>
</tr>
}
</tbody>
</table>
</div>
@if (Sessions.Any())
{
<div class="flex justify-between items-center mt-4">
<button class="px-3 py-1 bg-gray-200 hover:bg-gray-300 rounded text-xs font-semibold transition"
@onclick="PrevPage"
disabled="@(Page == 1)">
Prev
</button>
<span class="text-xs text-gray-600">Page @Page of @TotalPages</span>
<button class="px-3 py-1 bg-gray-200 hover:bg-gray-300 rounded text-xs font-semibold transition"
@onclick="NextPage"
disabled="@(Page >= TotalPages)">
Next
</button>
</div>
}
</div>
@code {
[Parameter] public List<SessionInfo> Sessions { get; set; } = new();
[Parameter] public EventCallback<string> 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);
}
}
@@ -0,0 +1,76 @@
@using Microsoft.JSInterop
@using PlcVanguard.Web.Components.Shared
@implements IAsyncDisposable
<div class="rounded-xl border border-gray-200 bg-white p-6 shadow-sm h-full flex flex-col">
<div class="flex justify-between items-center mb-4 flex-shrink-0">
<h2 class="font-semibold text-lg">@Title</h2>
<span class="text-sm text-gray-500">@subtitle</span>
</div>
<div class="flex-1 min-h-0 relative">
<canvas id="@ChartId"></canvas>
</div>
</div>
@code {
[Parameter] public string Title { get; set; } = "Live Chart";
[Parameter] public string subtitle { get; set; } = "";
[Parameter] public List<DataLabel> 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<bool> EnsureChartAsync()
{
try
{
_jsModule = await JS.InvokeAsync<IJSObjectReference>("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<bool> UpdateChartAsync()
{
try
{
if (_jsModule == null)
_jsModule = await JS.InvokeAsync<IJSObjectReference>("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();
}
}
@@ -0,0 +1,68 @@
@using Microsoft.AspNetCore.Components.Web
@using PlcVanguard.Web.Components.Shared
<div class="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
<h2 class="font-semibold text-lg mb-4">Control Panel</h2>
<div class="space-y-4">
@if (IsAcquiring)
{
<div class="w-full bg-gray-200 rounded-full h-2.5">
<div class="bg-blue-600 h-2.5 rounded-full transition-all duration-300" style="width: @(ProgressPercent.ToString("F0"))%"></div>
</div>
<p class="text-xs text-gray-400 mt-2">Acquisition in progress... @($"{ProgressPercent:F0}")%</p>
}
else
{
@if (IsConnected)
{
<p class="text-sm text-gray-500">PLC Connected<br>@($"{plcIp} | {plcType}")</p>
<button class="w-full bg-green-600 hover:bg-green-700 text-white font-bold py-3 rounded-lg transition shadow-md"
@onclick="StartAcquisition">
Start Acquisition (@SampleRate Hz)
</button>
<button class="w-full bg-red-600 hover:bg-red-700 text-white font-bold py-2 rounded-lg transition"
@onclick="Disconnect">
Disconnect
</button>
}
else
{
<p class="text-sm text-gray-500">Target: @($"{plcIp} ({plcType})")</p>
<button class="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 rounded-lg transition shadow-md"
@onclick="Connect">
Connect to PLC
</button>
}
}
</div>
</div>
@if (IsConnected)
{
<div class="rounded-xl border border-gray-200 bg-slate-900 text-white p-6 shadow-lg mt-6">
<h2 class="font-semibold text-lg mb-2">System Overview</h2>
<ul class="text-sm space-y-2 opacity-90">
<li>PLC: @plcType</li>
<li>Area: DB100 (Continuous)</li>
<li>Mode: @($"{SampleRate}Hz Polling")</li>
<li>Persistence: File + MariaDB</li>
</ul>
</div>
}
@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();
}
@@ -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";
}
@@ -0,0 +1,116 @@
@using PlcVanguard.Web.Components.Shared
<div class="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
@if (SessionId == null)
{
<h2 class="font-semibold text-lg mb-4">
<span class="text-gray-400 italic">Click a session in the left table to load data</span>
</h2>
<div class="text-gray-400 italic text-center py-20">
Select a recording session to view details
</div>
}
else
{
<h2 class="font-semibold text-lg mb-4">
Session @SessionId
</h2>
<div class="p-3 bg-gray-50 rounded-lg border border-gray-100 mb-4">
<h3 class="text-xs uppercase font-bold text-gray-400 mb-2">Session Summary</h3>
<table class="w-full text-left text-xs">
<thead class="bg-gray-50 border-b">
<tr>
<th class="p-2">Index</th>
<th class="p-2">Timestamp</th>
<th class="p-2">Value</th>
<th class="p-2">Status</th>
</tr>
</thead>
<tbody>
@foreach (var p in DisplayPoints)
{
<tr class="border-b hover:bg-blue-50 transition cursor-pointer"
@onclick="() => { SelectedPoint = p; StateHasChanged(); }">
<td class="p-2">@p.Index</td>
<td class="p-2">@p.Timestamp.ToString("HH:mm:ss.fff")</td>
<td class="p-2 font-mono font-bold">@p.Value.ToString("F3")</td>
<td class="p-2">
@if (p.IsError)
{
<span class="px-2 py-0.5 rounded-full text-[10px] font-bold bg-red-100 text-red-700">Error</span>
}
else if (p.IsWarning)
{
<span class="px-2 py-0.5 rounded-full text-[10px] font-bold bg-yellow-100 text-yellow-700">Warning</span>
}
else
{
<span class="px-2 py-0.5 rounded-full text-[10px] font-bold bg-green-100 text-green-700">OK</span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="flex justify-between items-center mt-4">
<button class="px-3 py-1 bg-gray-200 hover:bg-gray-300 rounded text-xs font-semibold transition"
@onclick="PrevPage"
disabled="@(CurrentPage == 1)">
Prev
</button>
<span class="text-xs text-gray-600">Page @CurrentPage of @TotalPages</span>
<button class="px-3 py-1 bg-gray-200 hover:bg-gray-300 rounded text-xs font-semibold transition"
@onclick="NextPage"
disabled="@(CurrentPage >= TotalPages)">
Next
</button>
</div>
@if (SelectedPoint != null)
{
<div class="mt-4 p-3 bg-blue-50 border border-blue-200 rounded">
<h4 class="text-xs font-bold text-blue-800 mb-1">Selected Point</h4>
<p class="text-xs font-mono">Idx = @(SelectedPoint.Index), Val = @(SelectedPoint.Value.ToString("F3")), Time = @(SelectedPoint.Timestamp.ToString("HH:mm:ss.fff"))</p>
</div>
}
}
</div>
@code {
[Parameter] public string SessionId { get; set; }
[Parameter] public List<PointData> 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<PointData> 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();
}
}
}
@@ -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); }
}
@@ -1,10 +1,24 @@
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PlcVanguard</title>
<base href="/" />
<link rel="stylesheet" href="bootstrap/bootstrap.min.css" />
<link rel="stylesheet" href="app.css" />
<link rel="icon" type="image/png" href="favicon.png" />
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<component type="typeof(HeadOutlet)" render-mode="HeadOutlet" />
</head>
<body>
<component type="typeof(App)" render-mode="ServerPrerendered" />
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
await builder.Build().RunAsync();
<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="" class="reload">Reload</a>
<a class="dismiss">🗙</a>
</div>
<script src="_framework/blazor.server.js"></script>
</body>
</html>
@@ -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;
}
};