Compare commits
10 commits
main
...
v0.6-histo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19fc203185 | ||
|
|
acd6053789 | ||
|
|
9aa31dde1f | ||
|
|
d84f8a818a | ||
|
|
54f8b3be25 | ||
|
|
2bf108905c | ||
|
|
ec6bc2f06f | ||
|
|
88dfa74ebe | ||
|
|
15fd1306c3 | ||
|
|
f4abdc0039 |
34 changed files with 2803 additions and 220 deletions
|
|
@ -6,10 +6,10 @@
|
||||||
|---------|------|--------|--------|-----------|
|
|---------|------|--------|--------|-----------|
|
||||||
| v0.1 | Walking Skeleton (MTConnect) | 1 | ✅ Shipped | 2026-07-21 |
|
| v0.1 | Walking Skeleton (MTConnect) | 1 | ✅ Shipped | 2026-07-21 |
|
||||||
| v0.2 | Machine Management UX | 2, 2.1 | ✅ Shipped (2.1 commit pending) | 2026-07-21 |
|
| v0.2 | Machine Management UX | 2, 2.1 | ✅ Shipped (2.1 commit pending) | 2026-07-21 |
|
||||||
| v0.3 | Data-Item Selection + UX | 3, 3.1 | 🚧 Phase 3 done, 3.1 (theming) next | - |
|
| v0.3 | Data-Item Selection + UX | 3, 3.1 | ✅ Shipped (branch v0.3.1-theming) | 2026-07-22 |
|
||||||
| v0.4 | OPC UA Protocol | 4 | 📋 Planned | - |
|
| v0.4 | OPC UA Protocol | 4 | ✅ Shipped (branch v0.4-opcua, awaiting merge) | 2026-07-22 |
|
||||||
| v0.5 | Fanuc FOCAS Protocol | 5 | 📋 Planned | - |
|
| v0.5 | Fanuc FOCAS Protocol | 5 | ⏸️ Deferred (no native/Windows on dev host) | - |
|
||||||
| v0.6 | History & Trends | 6 | 📋 Planned | - |
|
| v0.6 | History & Trends | 6 | ✅ Shipped (branch v0.6-history, awaiting merge) | 2026-07-22 |
|
||||||
|
|
||||||
## 🚧 Active Milestone: v0.2 Machine Management UX
|
## 🚧 Active Milestone: v0.2 Machine Management UX
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,18 +5,31 @@
|
||||||
See: .paul/PROJECT.md (updated 2026-07-21)
|
See: .paul/PROJECT.md (updated 2026-07-21)
|
||||||
|
|
||||||
**Core value:** Operators see live state of every configured machine across heterogeneous protocols in one place, adding machines/protocols without code.
|
**Core value:** Operators see live state of every configured machine across heterogeneous protocols in one place, adding machines/protocols without code.
|
||||||
**Current focus:** v0.3 Phase 3 (item selection) shipped → PAUSED. Next: Phase 3.1 theming/UX.
|
**Current focus:** Autonomous finish run — branch-per-milestone. v0.3.1 theming SHIPPED. Now v0.4 OPC UA.
|
||||||
|
|
||||||
## Current Position
|
## Current Position
|
||||||
|
|
||||||
Milestone: v0.3 Data-Item Selection + UX
|
Milestone: ALL IMPLEMENTED — autonomous finish run complete. Awaiting user branch merges.
|
||||||
Phase: 3 (item selection) ✓ SHIPPED — Phase 3.1 (theming/UX) NOT started
|
Status: Build 0/0, 196 tests + 2 docker-skip (v0.6 tip).
|
||||||
Plan: v0.3-backend ✓, v0.3-appui ✓
|
Last activity: 2026-07-22 — v0.6 history & trends shipped on branch v0.6-history. All planned milestones (v0.3.1, v0.4, v0.6) implemented + pushed as review branches. v0.5 FOCAS deferred.
|
||||||
Status: PAUSED (user requested). Build 0/0, 138 tests + 2 docker-skip.
|
|
||||||
Last activity: 2026-07-22 — v0.3 item selection: Machine.MonitoredItemIds (opt-in), IProtocolDriver.ProbeAsync, MTConnect probe+filter, Persistence col+migration, monitor.ProbeAsync, config probe→checklist UI (47 items live), detail empty-state.
|
|
||||||
|
|
||||||
Progress:
|
Progress:
|
||||||
- v0.3: Phase 3 done, Phase 3.1 (theming) pending
|
- v0.3.1 theming ✓ (+ config full-width fix + dashboard Details-clip fix)
|
||||||
|
- v0.4 OPC UA ✓ (OPCFoundation 1.5.378.156 MIT, net48;net8.0 plugin, IUaClient seam)
|
||||||
|
- v0.5 FOCAS: DEFERRED (no native fwlib32/Windows on Linux dev host)
|
||||||
|
- v0.6 history & trends ✓ (snapshot_history table, retention prune, Sparkline control)
|
||||||
|
|
||||||
|
## Review branches (pushed to origin, NOT merged to main — user merges one at a time)
|
||||||
|
|
||||||
|
- v0.3.1-theming (from main): theming + layout fixes
|
||||||
|
- v0.4-opcua (from v0.3.1): OPC UA plugin (+ propagated fixes)
|
||||||
|
- v0.6-history (from v0.4, skips deferred v0.5): history & trends (+ propagated fixes)
|
||||||
|
UI fixes fixed on v0.3.1 then merged forward v0.3.1→v0.4→v0.6.
|
||||||
|
|
||||||
|
## Branch-per-milestone (user directive 2026-07-22)
|
||||||
|
|
||||||
|
Chain, each branch from previous; user merges one at a time; push branches to origin, NO auto-merge to main:
|
||||||
|
v0.3.1-theming (from main) ✓ → v0.4-opcua → v0.6-history (skips deferred v0.5).
|
||||||
|
|
||||||
## Loop Position
|
## Loop Position
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{362A84DF
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Junction.Tests", "tests\Junction.Tests\Junction.Tests.csproj", "{618E6634-A16C-4806-836A-185ECDEE7312}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Junction.Tests", "tests\Junction.Tests\Junction.Tests.csproj", "{618E6634-A16C-4806-836A-185ECDEE7312}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Junction.Protocols.OpcUa", "src\Junction.Protocols.OpcUa\Junction.Protocols.OpcUa.csproj", "{93FC27CB-C147-4BE6-8E86-F46192B72099}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
|
@ -52,6 +54,10 @@ Global
|
||||||
{618E6634-A16C-4806-836A-185ECDEE7312}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{618E6634-A16C-4806-836A-185ECDEE7312}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{618E6634-A16C-4806-836A-185ECDEE7312}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{618E6634-A16C-4806-836A-185ECDEE7312}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{618E6634-A16C-4806-836A-185ECDEE7312}.Release|Any CPU.Build.0 = Release|Any CPU
|
{618E6634-A16C-4806-836A-185ECDEE7312}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{93FC27CB-C147-4BE6-8E86-F46192B72099}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{93FC27CB-C147-4BE6-8E86-F46192B72099}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{93FC27CB-C147-4BE6-8E86-F46192B72099}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{93FC27CB-C147-4BE6-8E86-F46192B72099}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(NestedProjects) = preSolution
|
GlobalSection(NestedProjects) = preSolution
|
||||||
{EE2D0A1D-3135-4065-A9EF-AB99F4FCC062} = {6DDDE464-201D-4B4F-A23E-566B5FC111BC}
|
{EE2D0A1D-3135-4065-A9EF-AB99F4FCC062} = {6DDDE464-201D-4B4F-A23E-566B5FC111BC}
|
||||||
|
|
@ -60,5 +66,6 @@ Global
|
||||||
{BBAD50B2-18B5-4F4B-B8E3-CE749DB46C83} = {6DDDE464-201D-4B4F-A23E-566B5FC111BC}
|
{BBAD50B2-18B5-4F4B-B8E3-CE749DB46C83} = {6DDDE464-201D-4B4F-A23E-566B5FC111BC}
|
||||||
{965918A3-4C29-4F6A-B53D-0A72BDF4376C} = {6DDDE464-201D-4B4F-A23E-566B5FC111BC}
|
{965918A3-4C29-4F6A-B53D-0A72BDF4376C} = {6DDDE464-201D-4B4F-A23E-566B5FC111BC}
|
||||||
{618E6634-A16C-4806-836A-185ECDEE7312} = {362A84DF-21D2-4DA7-B8B5-8C5E3C6E8500}
|
{618E6634-A16C-4806-836A-185ECDEE7312} = {362A84DF-21D2-4DA7-B8B5-8C5E3C6E8500}
|
||||||
|
{93FC27CB-C147-4BE6-8E86-F46192B72099} = {6DDDE464-201D-4B4F-A23E-566B5FC111BC}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,113 @@
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:local="clr-namespace:Junction.App"
|
xmlns:local="clr-namespace:Junction.App"
|
||||||
xmlns:conv="clr-namespace:Junction.App.Converters"
|
xmlns:conv="clr-namespace:Junction.App.Converters"
|
||||||
x:Class="Junction.App.App">
|
x:Class="Junction.App.App"
|
||||||
|
RequestedThemeVariant="Default">
|
||||||
|
<!-- RequestedThemeVariant="Default" = follow the OS light/dark preference. -->
|
||||||
|
|
||||||
<Application.Resources>
|
<Application.Resources>
|
||||||
<conv:StatusKindToBrushConverter x:Key="StatusKindToBrush" />
|
<ResourceDictionary>
|
||||||
|
<!-- Per-variant palette. Fluent supplies the base chrome; these are Junction's
|
||||||
|
accent + surface + status tokens, adapted for Light and Dark. -->
|
||||||
|
<ResourceDictionary.ThemeDictionaries>
|
||||||
|
<ResourceDictionary x:Key="Light">
|
||||||
|
<SolidColorBrush x:Key="AccentBrush" Color="#2563EB" />
|
||||||
|
<SolidColorBrush x:Key="AccentHoverBrush" Color="#1D4FD7" />
|
||||||
|
<SolidColorBrush x:Key="AppBackgroundBrush" Color="#F4F6F9" />
|
||||||
|
<SolidColorBrush x:Key="CardBackgroundBrush" Color="#FFFFFF" />
|
||||||
|
<SolidColorBrush x:Key="CardBorderBrush" Color="#E2E6EC" />
|
||||||
|
<SolidColorBrush x:Key="HeaderForegroundBrush" Color="#161A1F" />
|
||||||
|
<SolidColorBrush x:Key="SubtleForegroundBrush" Color="#697586" />
|
||||||
|
<SolidColorBrush x:Key="DangerBrush" Color="#C62828" />
|
||||||
|
<SolidColorBrush x:Key="OnAccentBrush" Color="#FFFFFF" />
|
||||||
|
</ResourceDictionary>
|
||||||
|
<ResourceDictionary x:Key="Dark">
|
||||||
|
<SolidColorBrush x:Key="AccentBrush" Color="#4C8DF6" />
|
||||||
|
<SolidColorBrush x:Key="AccentHoverBrush" Color="#6BA1F8" />
|
||||||
|
<SolidColorBrush x:Key="AppBackgroundBrush" Color="#1B1E23" />
|
||||||
|
<SolidColorBrush x:Key="CardBackgroundBrush" Color="#24282E" />
|
||||||
|
<SolidColorBrush x:Key="CardBorderBrush" Color="#3A3F46" />
|
||||||
|
<SolidColorBrush x:Key="HeaderForegroundBrush" Color="#F2F4F7" />
|
||||||
|
<SolidColorBrush x:Key="SubtleForegroundBrush" Color="#9AA3AF" />
|
||||||
|
<SolidColorBrush x:Key="DangerBrush" Color="#EF5350" />
|
||||||
|
<SolidColorBrush x:Key="OnAccentBrush" Color="#FFFFFF" />
|
||||||
|
</ResourceDictionary>
|
||||||
|
</ResourceDictionary.ThemeDictionaries>
|
||||||
|
|
||||||
|
<!-- Spacing scale (variant-invariant). -->
|
||||||
|
<Thickness x:Key="PageMargin">20</Thickness>
|
||||||
|
|
||||||
|
<!-- Variant-invariant converters. -->
|
||||||
|
<conv:StatusKindToBrushConverter x:Key="StatusKindToBrush" />
|
||||||
|
<conv:ProtocolIdToIconConverter x:Key="ProtocolIdToIcon" />
|
||||||
|
</ResourceDictionary>
|
||||||
</Application.Resources>
|
</Application.Resources>
|
||||||
|
|
||||||
<Application.DataTemplates>
|
<Application.DataTemplates>
|
||||||
<local:ViewLocator />
|
<local:ViewLocator />
|
||||||
</Application.DataTemplates>
|
</Application.DataTemplates>
|
||||||
|
|
||||||
<Application.Styles>
|
<Application.Styles>
|
||||||
<FluentTheme />
|
<FluentTheme />
|
||||||
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml" />
|
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml" />
|
||||||
|
|
||||||
|
<!-- Inter (bundled via WithInterFont) as the shell default for a consistent, crisp face. -->
|
||||||
|
<Style Selector="Window">
|
||||||
|
<Setter Property="FontFamily" Value="avares://Avalonia.Fonts.Inter/Assets#Inter" />
|
||||||
|
<Setter Property="Background" Value="{DynamicResource AppBackgroundBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- Reusable text roles. -->
|
||||||
|
<Style Selector="TextBlock.h1">
|
||||||
|
<Setter Property="FontSize" Value="22" />
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold" />
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource HeaderForegroundBrush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBlock.h2">
|
||||||
|
<Setter Property="FontSize" Value="16" />
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold" />
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource HeaderForegroundBrush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBlock.subtle">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource SubtleForegroundBrush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBlock.fieldLabel">
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold" />
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource SubtleForegroundBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- Card / section container. -->
|
||||||
|
<Style Selector="Border.card">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource CardBackgroundBrush}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource CardBorderBrush}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="CornerRadius" Value="6" />
|
||||||
|
<Setter Property="Padding" Value="16" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- Primary (accent) button. -->
|
||||||
|
<Style Selector="Button.accent">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource AccentBrush}" />
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource OnAccentBrush}" />
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.accent:pointerover /template/ ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource AccentHoverBrush}" />
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource OnAccentBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- Destructive button. -->
|
||||||
|
<Style Selector="Button.danger">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource DangerBrush}" />
|
||||||
|
<Setter Property="Foreground" Value="#FFFFFF" />
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- Protocol glyph. -->
|
||||||
|
<Style Selector="PathIcon.protocol">
|
||||||
|
<Setter Property="Width" Value="16" />
|
||||||
|
<Setter Property="Height" Value="16" />
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource AccentBrush}" />
|
||||||
|
</Style>
|
||||||
</Application.Styles>
|
</Application.Styles>
|
||||||
</Application>
|
</Application>
|
||||||
|
|
|
||||||
121
src/Junction.App/Controls/Sparkline.cs
Normal file
121
src/Junction.App/Controls/Sparkline.cs
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Media;
|
||||||
|
|
||||||
|
namespace Junction.App.Controls
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Lightweight per-item trend line: draws a numeric time-series (oldest → newest, left → right)
|
||||||
|
/// as a single polyline scaled to fill the control. No axes, no labels — a table-cell sparkline.
|
||||||
|
/// Rendering-only; the parsing/collection of trend values lives in the view-model layer, so this
|
||||||
|
/// control stays a pure <see cref="Control"/> with no data-shaping logic to unit-test.
|
||||||
|
/// <para>net48-safe: uses only core Avalonia drawing APIs (StreamGeometry + Pen), no external
|
||||||
|
/// charting dependency. Blank when there is nothing meaningful to draw (< 2 points).</para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class Sparkline : Control
|
||||||
|
{
|
||||||
|
/// <summary>Trend samples in ascending time order. < 2 points renders blank.</summary>
|
||||||
|
public static readonly StyledProperty<IReadOnlyList<double>?> PointsProperty =
|
||||||
|
AvaloniaProperty.Register<Sparkline, IReadOnlyList<double>?>(nameof(Points));
|
||||||
|
|
||||||
|
/// <summary>Line color. Defaults to Junction's accent blue; the view binds the themed brush.</summary>
|
||||||
|
public static readonly StyledProperty<IBrush?> StrokeProperty =
|
||||||
|
AvaloniaProperty.Register<Sparkline, IBrush?>(
|
||||||
|
nameof(Stroke),
|
||||||
|
new SolidColorBrush(Color.FromRgb(0x25, 0x63, 0xEB)));
|
||||||
|
|
||||||
|
static Sparkline()
|
||||||
|
{
|
||||||
|
AffectsRender<Sparkline>(PointsProperty, StrokeProperty);
|
||||||
|
AffectsMeasure<Sparkline>(PointsProperty);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<double>? Points
|
||||||
|
{
|
||||||
|
get => GetValue(PointsProperty);
|
||||||
|
set => SetValue(PointsProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IBrush? Stroke
|
||||||
|
{
|
||||||
|
get => GetValue(StrokeProperty);
|
||||||
|
set => SetValue(StrokeProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override Size MeasureOverride(Size availableSize)
|
||||||
|
{
|
||||||
|
// Own no intrinsic width (stretch into the column); pick a sensible default height
|
||||||
|
// when the parent imposes none.
|
||||||
|
double height = double.IsInfinity(availableSize.Height) ? 24 : availableSize.Height;
|
||||||
|
return new Size(0, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Render(DrawingContext context)
|
||||||
|
{
|
||||||
|
base.Render(context);
|
||||||
|
|
||||||
|
var points = Points;
|
||||||
|
if (points == null || points.Count < 2)
|
||||||
|
{
|
||||||
|
return; // nothing meaningful to draw
|
||||||
|
}
|
||||||
|
|
||||||
|
double width = Bounds.Width;
|
||||||
|
double height = Bounds.Height;
|
||||||
|
if (width <= 0 || height <= 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
double min = points[0];
|
||||||
|
double max = points[0];
|
||||||
|
for (int i = 1; i < points.Count; i++)
|
||||||
|
{
|
||||||
|
double v = points[i];
|
||||||
|
if (v < min) min = v;
|
||||||
|
if (v > max) max = v;
|
||||||
|
}
|
||||||
|
double range = max - min;
|
||||||
|
|
||||||
|
// Small vertical inset so the stroke never clips at the top/bottom edge.
|
||||||
|
const double pad = 1.5;
|
||||||
|
double usableHeight = Math.Max(0, height - (2 * pad));
|
||||||
|
double stepX = width / (points.Count - 1);
|
||||||
|
|
||||||
|
var pen = new Pen(Stroke ?? Brushes.Gray, 1.5)
|
||||||
|
{
|
||||||
|
LineCap = PenLineCap.Round,
|
||||||
|
LineJoin = PenLineJoin.Round
|
||||||
|
};
|
||||||
|
|
||||||
|
var geometry = new StreamGeometry();
|
||||||
|
using (var ctx = geometry.Open())
|
||||||
|
{
|
||||||
|
for (int i = 0; i < points.Count; i++)
|
||||||
|
{
|
||||||
|
double x = i * stepX;
|
||||||
|
// All-equal series → flat mid line; else normalize into [0,1] and invert
|
||||||
|
// so a higher value sits higher on screen.
|
||||||
|
double norm = range > 0 ? (points[i] - min) / range : 0.5;
|
||||||
|
double y = pad + ((1 - norm) * usableHeight);
|
||||||
|
|
||||||
|
var p = new Point(x, y);
|
||||||
|
if (i == 0)
|
||||||
|
{
|
||||||
|
ctx.BeginFigure(p, false);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ctx.LineTo(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.EndFigure(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
context.DrawGeometry(null, pen, geometry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/Junction.App/Converters/ProtocolIdToIconConverter.cs
Normal file
75
src/Junction.App/Converters/ProtocolIdToIconConverter.cs
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using Avalonia.Data.Converters;
|
||||||
|
using Avalonia.Media;
|
||||||
|
|
||||||
|
namespace Junction.App.Converters
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Maps a machine's <c>ProtocolId</c> ("mtconnect", future "opcua"/"fanuc", …) to a vector
|
||||||
|
/// glyph (<see cref="Geometry"/>) for a <c>PathIcon</c>. Keeps view-models framework-agnostic:
|
||||||
|
/// the icon decision lives entirely in the view layer, mirroring
|
||||||
|
/// <see cref="StatusKindToBrushConverter"/>. Unknown/empty ids fall back to a generic glyph.
|
||||||
|
/// <para>Glyphs are line-only 24×24 path data (no arcs/curves) so they always parse and read
|
||||||
|
/// as distinct filled shapes. No external image assets — net48 packaging stays a single exe set.</para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ProtocolIdToIconConverter : IValueConverter
|
||||||
|
{
|
||||||
|
public static readonly ProtocolIdToIconConverter Instance = new ProtocolIdToIconConverter();
|
||||||
|
|
||||||
|
// Signal-strength bars — a live telemetry feed.
|
||||||
|
private const string MtconnectData = "M4,14 H7 V20 H4 Z M10,10 H13 V20 H10 Z M16,5 H19 V20 H16 Z";
|
||||||
|
|
||||||
|
// Stacked diamonds — the OPC UA layered address space.
|
||||||
|
private const string OpcuaData = "M12,2 L20,7 L12,12 L4,7 Z M12,13 L20,18 L12,23 L4,18 Z";
|
||||||
|
|
||||||
|
// Plus/cross — an industrial CNC controller marker.
|
||||||
|
private const string FanucData = "M10,3 H14 V10 H21 V14 H14 V21 H10 V14 H3 V10 H10 Z";
|
||||||
|
|
||||||
|
// Octagon node — generic / unknown protocol.
|
||||||
|
private const string FallbackData = "M8,4 H16 L20,8 V16 L16,20 H8 L4,16 V8 Z";
|
||||||
|
|
||||||
|
private static Geometry? _mtconnect;
|
||||||
|
private static Geometry? _opcua;
|
||||||
|
private static Geometry? _fanuc;
|
||||||
|
private static Geometry? _fallback;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pure lookup of the raw path-data string for a protocol id. View-framework-free and
|
||||||
|
/// side-effect-free so it is unit-testable without an Avalonia platform. Known ids return a
|
||||||
|
/// distinct non-empty string; anything else returns the generic fallback. Never throws.
|
||||||
|
/// </summary>
|
||||||
|
public static string PathDataFor(string? protocolId)
|
||||||
|
{
|
||||||
|
switch (protocolId == null ? null : protocolId.Trim().ToLowerInvariant())
|
||||||
|
{
|
||||||
|
case "mtconnect":
|
||||||
|
return MtconnectData;
|
||||||
|
case "opcua":
|
||||||
|
return OpcuaData;
|
||||||
|
case "fanuc":
|
||||||
|
return FanucData;
|
||||||
|
default:
|
||||||
|
return FallbackData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
{
|
||||||
|
switch (PathDataFor(value as string))
|
||||||
|
{
|
||||||
|
case MtconnectData:
|
||||||
|
return _mtconnect ?? (_mtconnect = Geometry.Parse(MtconnectData));
|
||||||
|
case OpcuaData:
|
||||||
|
return _opcua ?? (_opcua = Geometry.Parse(OpcuaData));
|
||||||
|
case FanucData:
|
||||||
|
return _fanuc ?? (_fanuc = Geometry.Parse(FanucData));
|
||||||
|
default:
|
||||||
|
return _fallback ?? (_fallback = Geometry.Parse(FallbackData));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,8 @@
|
||||||
<OutputType>WinExe</OutputType>
|
<OutputType>WinExe</OutputType>
|
||||||
<RootNamespace>Junction.App</RootNamespace>
|
<RootNamespace>Junction.App</RootNamespace>
|
||||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||||
|
<!-- net48 needs binding redirects for the M.E.Logging/System.* shims the OPC UA SDK drags in. -->
|
||||||
|
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<!-- Avalonia 11.3.x supports net48; Avalonia 12 DROPS net48. PIN 11.3.10 exact everywhere. -->
|
<!-- Avalonia 11.3.x supports net48; Avalonia 12 DROPS net48. PIN 11.3.10 exact everywhere. -->
|
||||||
|
|
@ -42,6 +44,8 @@
|
||||||
<!-- Plugin = RUNTIME artifact, NOT a compile reference. Build-order-only so we can copy its output. -->
|
<!-- Plugin = RUNTIME artifact, NOT a compile reference. Build-order-only so we can copy its output. -->
|
||||||
<ProjectReference Include="..\Junction.Protocols.MTConnect\Junction.Protocols.MTConnect.csproj"
|
<ProjectReference Include="..\Junction.Protocols.MTConnect\Junction.Protocols.MTConnect.csproj"
|
||||||
ReferenceOutputAssembly="false" Private="false" />
|
ReferenceOutputAssembly="false" Private="false" />
|
||||||
|
<ProjectReference Include="..\Junction.Protocols.OpcUa\Junction.Protocols.OpcUa.csproj"
|
||||||
|
ReferenceOutputAssembly="false" Private="false" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<!-- Copy MTConnect plugin (dll + manifest) into OutDir/plugins/mtconnect/ after build. -->
|
<!-- Copy MTConnect plugin (dll + manifest) into OutDir/plugins/mtconnect/ after build. -->
|
||||||
|
|
@ -53,4 +57,15 @@
|
||||||
<Copy SourceFiles="@(MtconnectPluginFiles)" DestinationFolder="$(OutDir)plugins\mtconnect\" SkipUnchangedFiles="true" />
|
<Copy SourceFiles="@(MtconnectPluginFiles)" DestinationFolder="$(OutDir)plugins\mtconnect\" SkipUnchangedFiles="true" />
|
||||||
</Target>
|
</Target>
|
||||||
|
|
||||||
|
<!-- Copy OPC UA plugin (its dll + FULL transitive SDK closure + manifest) into OutDir/plugins/opcua/
|
||||||
|
after build. Host $(TargetFramework) is net48 or net8.0; the plugin multi-targets the same TFMs,
|
||||||
|
so copy the matching per-TFM output. -->
|
||||||
|
<Target Name="CopyOpcuaPlugin" AfterTargets="Build" Condition="'$(TargetFramework)' != ''">
|
||||||
|
<ItemGroup>
|
||||||
|
<OpcuaPluginFiles Include="$(MSBuildThisFileDirectory)..\Junction.Protocols.OpcUa\bin\$(Configuration)\$(TargetFramework)\*.dll" />
|
||||||
|
<OpcuaPluginFiles Include="$(MSBuildThisFileDirectory)..\Junction.Protocols.OpcUa\bin\$(Configuration)\$(TargetFramework)\plugin.manifest.json" />
|
||||||
|
</ItemGroup>
|
||||||
|
<Copy SourceFiles="@(OpcuaPluginFiles)" DestinationFolder="$(OutDir)plugins\opcua\" SkipUnchangedFiles="true" />
|
||||||
|
</Target>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,8 @@
|
||||||
x:DataType="vm:MainWindowViewModel"
|
x:DataType="vm:MainWindowViewModel"
|
||||||
x:CompileBindings="True"
|
x:CompileBindings="True"
|
||||||
Title="Junction"
|
Title="Junction"
|
||||||
Width="800" Height="600">
|
Width="900" Height="640"
|
||||||
|
Background="{DynamicResource AppBackgroundBrush}">
|
||||||
<!-- Shell: hosts the current page; ViewLocator maps the VM to its View. -->
|
<!-- Shell: hosts the current page; ViewLocator maps the VM to its View. -->
|
||||||
<ContentControl Content="{Binding CurrentPage}" />
|
<ContentControl Content="{Binding CurrentPage}" />
|
||||||
</Window>
|
</Window>
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,33 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using Junction.Domain.Models;
|
using Junction.Domain.Models;
|
||||||
|
|
||||||
namespace Junction.App.ViewModels
|
namespace Junction.App.ViewModels
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One row of the machine-detail data-item table. Immutable projection of a
|
/// One row of the machine-detail data-item table. The string fields are a fixed projection of a
|
||||||
/// <see cref="DataItem"/>; rebuilt (not mutated) whenever a fresh snapshot arrives,
|
/// <see cref="DataItem"/> (set in the ctor; rebuilt, not mutated, per snapshot). Observable only
|
||||||
/// so it needs no change-notification.
|
/// for <see cref="Trend"/>, which is populated asynchronously after the row is created (history
|
||||||
|
/// query) and drives the per-item sparkline.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class DataItemRowViewModel
|
public sealed partial class DataItemRowViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
|
/// <summary>History item-id key (matches <see cref="DataItem.Id"/> / snapshot item id).</summary>
|
||||||
public string Id { get; }
|
public string Id { get; }
|
||||||
public string Name { get; }
|
public string Name { get; }
|
||||||
public string Value { get; }
|
public string Value { get; }
|
||||||
public string Category { get; }
|
public string Category { get; }
|
||||||
public string Timestamp { get; }
|
public string Timestamp { get; }
|
||||||
|
|
||||||
|
/// <summary>Numeric trend samples (ascending time order) for the sparkline; null when none.</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(HasTrend))]
|
||||||
|
private IReadOnlyList<double>? _trend;
|
||||||
|
|
||||||
|
/// <summary>True when there are enough numeric samples to draw a trend line.</summary>
|
||||||
|
public bool HasTrend => Trend != null && Trend.Count >= 2;
|
||||||
|
|
||||||
public DataItemRowViewModel(DataItem item)
|
public DataItemRowViewModel(DataItem item)
|
||||||
{
|
{
|
||||||
Id = item.Id;
|
Id = item.Id;
|
||||||
|
|
@ -23,5 +36,24 @@ namespace Junction.App.ViewModels
|
||||||
Category = string.IsNullOrWhiteSpace(item.Category) ? "—" : item.Category;
|
Category = string.IsNullOrWhiteSpace(item.Category) ? "—" : item.Category;
|
||||||
Timestamp = item.Timestamp.LocalDateTime.ToString("HH:mm:ss");
|
Timestamp = item.Timestamp.LocalDateTime.ToString("HH:mm:ss");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Culture-invariant numeric parse of a datum value. Trims; returns false for null/blank or
|
||||||
|
/// non-numeric text (e.g. "ON", "AVAILABLE"). Pure and Avalonia-free — unit-testable.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryParseNumeric(string? value, out double result)
|
||||||
|
{
|
||||||
|
result = 0;
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return double.TryParse(
|
||||||
|
value!.Trim(),
|
||||||
|
NumberStyles.Float | NumberStyles.AllowThousands,
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
out result);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
|
|
@ -23,6 +25,12 @@ namespace Junction.App.ViewModels
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed partial class MachineDetailViewModel : ViewModelBase, IDisposable
|
public sealed partial class MachineDetailViewModel : ViewModelBase, IDisposable
|
||||||
{
|
{
|
||||||
|
/// <summary>How far back the per-item trend sparkline looks.</summary>
|
||||||
|
private static readonly TimeSpan TrendWindow = TimeSpan.FromHours(1);
|
||||||
|
|
||||||
|
/// <summary>Cap on samples pulled per item for a sparkline (most-recent kept).</summary>
|
||||||
|
private const int TrendMaxPoints = 60;
|
||||||
|
|
||||||
private readonly IMachineRepository _repository;
|
private readonly IMachineRepository _repository;
|
||||||
private readonly IMachineMonitor _monitor;
|
private readonly IMachineMonitor _monitor;
|
||||||
private readonly ILogger<MachineDetailViewModel> _logger;
|
private readonly ILogger<MachineDetailViewModel> _logger;
|
||||||
|
|
@ -30,6 +38,12 @@ namespace Junction.App.ViewModels
|
||||||
private bool _subscribed;
|
private bool _subscribed;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handle on the most recently kicked (fire-and-forget) trend load. Production ignores it;
|
||||||
|
/// tests await it to observe the populated <see cref="DataItemRowViewModel.Trend"/> values.
|
||||||
|
/// </summary>
|
||||||
|
public Task? TrendLoadTask { get; private set; }
|
||||||
|
|
||||||
/// <summary>Shell reference used by <see cref="BackCommand"/> to return to the dashboard.</summary>
|
/// <summary>Shell reference used by <see cref="BackCommand"/> to return to the dashboard.</summary>
|
||||||
public MainWindowViewModel? Navigator { get; set; }
|
public MainWindowViewModel? Navigator { get; set; }
|
||||||
|
|
||||||
|
|
@ -163,6 +177,7 @@ namespace Junction.App.ViewModels
|
||||||
if (snapshot != null)
|
if (snapshot != null)
|
||||||
{
|
{
|
||||||
ApplySnapshot(snapshot);
|
ApplySnapshot(snapshot);
|
||||||
|
KickTrendLoad();
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("Machine detail opened for {Name} ({MachineId}); {Count} item(s).",
|
_logger.LogInformation("Machine detail opened for {Name} ({MachineId}); {Count} item(s).",
|
||||||
|
|
@ -210,9 +225,93 @@ namespace Junction.App.ViewModels
|
||||||
}
|
}
|
||||||
|
|
||||||
ApplySnapshot(snapshot);
|
ApplySnapshot(snapshot);
|
||||||
|
KickTrendLoad();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fires (fire-and-forget) a per-item history query for every current row and, for items
|
||||||
|
/// whose samples parse as numeric, populates the row's <see cref="DataItemRowViewModel.Trend"/>
|
||||||
|
/// so its sparkline renders. Rebuilt rows carry no stale trend. A per-item failure never
|
||||||
|
/// aborts the others and never propagates.
|
||||||
|
/// </summary>
|
||||||
|
private void KickTrendLoad()
|
||||||
|
{
|
||||||
|
TrendLoadTask = LoadTrendsAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadTrendsAsync()
|
||||||
|
{
|
||||||
|
// Snapshot the rows: the collection may be rebuilt by a later snapshot while we await.
|
||||||
|
var rows = Items.ToArray();
|
||||||
|
var since = DateTimeOffset.UtcNow - TrendWindow;
|
||||||
|
|
||||||
|
for (int r = 0; r < rows.Length; r++)
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var row = rows[r];
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await _repository
|
||||||
|
.GetHistoryAsync(_machineId, row.Id, since, TrendMaxPoints, CancellationToken.None)
|
||||||
|
.ConfigureAwait(true);
|
||||||
|
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.IsSuccess || result.Value == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var history = result.Value;
|
||||||
|
var values = new List<double>(history.Count);
|
||||||
|
for (int i = 0; i < history.Count; i++)
|
||||||
|
{
|
||||||
|
if (DataItemRowViewModel.TryParseNumeric(history[i].Value, out var d))
|
||||||
|
{
|
||||||
|
values.Add(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-numeric / empty history → leave Trend null (sparkline stays blank).
|
||||||
|
if (values.Count > 0)
|
||||||
|
{
|
||||||
|
SetTrendOnUi(row, values.ToArray());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Trend load failed for item {ItemId} on machine {MachineId}.",
|
||||||
|
row.Id, _machineId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetTrendOnUi(DataItemRowViewModel row, IReadOnlyList<double> trend)
|
||||||
|
{
|
||||||
|
if (Dispatcher.UIThread.CheckAccess())
|
||||||
|
{
|
||||||
|
row.Trend = trend;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Dispatcher.UIThread.Post(() =>
|
||||||
|
{
|
||||||
|
if (!_disposed)
|
||||||
|
{
|
||||||
|
row.Trend = trend;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void Back()
|
private void Back()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -8,66 +8,82 @@
|
||||||
x:DataType="vm:DashboardViewModel"
|
x:DataType="vm:DashboardViewModel"
|
||||||
x:CompileBindings="True">
|
x:CompileBindings="True">
|
||||||
|
|
||||||
<DockPanel Margin="16">
|
<DockPanel Margin="20">
|
||||||
<Grid DockPanel.Dock="Top" ColumnDefinitions="*,Auto" Margin="0,0,0,12">
|
<Grid DockPanel.Dock="Top" ColumnDefinitions="*,Auto" Margin="0,0,0,16">
|
||||||
<TextBlock Grid.Column="0"
|
<TextBlock Grid.Column="0"
|
||||||
|
Classes="h1"
|
||||||
Text="{Binding Title}"
|
Text="{Binding Title}"
|
||||||
FontSize="22" FontWeight="SemiBold"
|
|
||||||
VerticalAlignment="Center" />
|
VerticalAlignment="Center" />
|
||||||
<Button Grid.Column="1"
|
<Button Grid.Column="1"
|
||||||
|
Classes="accent"
|
||||||
Content="+ Add machine"
|
Content="+ Add machine"
|
||||||
Command="{Binding AddMachineCommand}"
|
Command="{Binding AddMachineCommand}"
|
||||||
VerticalAlignment="Center" />
|
VerticalAlignment="Center" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<DataGrid ItemsSource="{Binding Machines}"
|
<Border Classes="card" Padding="0">
|
||||||
AutoGenerateColumns="False"
|
<DataGrid ItemsSource="{Binding Machines}"
|
||||||
IsReadOnly="True"
|
AutoGenerateColumns="False"
|
||||||
CanUserReorderColumns="False"
|
IsReadOnly="True"
|
||||||
CanUserResizeColumns="True"
|
CanUserReorderColumns="False"
|
||||||
GridLinesVisibility="Horizontal"
|
CanUserResizeColumns="True"
|
||||||
HeadersVisibility="Column">
|
GridLinesVisibility="Horizontal"
|
||||||
<DataGrid.Columns>
|
HeadersVisibility="Column"
|
||||||
<DataGridTextColumn Header="Machine"
|
Background="Transparent">
|
||||||
Binding="{Binding Name}"
|
<DataGrid.Columns>
|
||||||
Width="2*"
|
<DataGridTemplateColumn Header="Machine" Width="2*">
|
||||||
x:DataType="vm:MachineRowViewModel" />
|
<DataGridTemplateColumn.CellTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:MachineRowViewModel">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10"
|
||||||
|
VerticalAlignment="Center" Margin="12,0">
|
||||||
|
<PathIcon Classes="protocol"
|
||||||
|
Data="{Binding ProtocolId, Converter={StaticResource ProtocolIdToIcon}}"
|
||||||
|
ToolTip.Tip="{Binding ProtocolId}"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" VerticalAlignment="Center" />
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</DataGridTemplateColumn.CellTemplate>
|
||||||
|
</DataGridTemplateColumn>
|
||||||
|
|
||||||
<DataGridTemplateColumn Header="Status" Width="Auto">
|
<DataGridTemplateColumn Header="Status" Width="Auto">
|
||||||
<DataGridTemplateColumn.CellTemplate>
|
<DataGridTemplateColumn.CellTemplate>
|
||||||
<DataTemplate x:DataType="vm:MachineRowViewModel">
|
<DataTemplate x:DataType="vm:MachineRowViewModel">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="6"
|
<StackPanel Orientation="Horizontal" Spacing="6"
|
||||||
VerticalAlignment="Center" Margin="8,0">
|
VerticalAlignment="Center" Margin="12,0">
|
||||||
<Ellipse Width="10" Height="10" VerticalAlignment="Center"
|
<Ellipse Width="10" Height="10" VerticalAlignment="Center"
|
||||||
Fill="{Binding StatusKind, Converter={StaticResource StatusKindToBrush}}" />
|
Fill="{Binding StatusKind, Converter={StaticResource StatusKindToBrush}}" />
|
||||||
<TextBlock Text="{Binding ConnectionState}"
|
<TextBlock Text="{Binding ConnectionState}"
|
||||||
Foreground="{Binding StatusKind, Converter={StaticResource StatusKindToBrush}}"
|
Foreground="{Binding StatusKind, Converter={StaticResource StatusKindToBrush}}"
|
||||||
FontWeight="SemiBold" VerticalAlignment="Center" />
|
FontWeight="SemiBold" VerticalAlignment="Center" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</DataGridTemplateColumn.CellTemplate>
|
</DataGridTemplateColumn.CellTemplate>
|
||||||
</DataGridTemplateColumn>
|
</DataGridTemplateColumn>
|
||||||
|
|
||||||
<DataGridTextColumn Header="Last Datum"
|
<DataGridTextColumn Header="Last Datum"
|
||||||
Binding="{Binding LastDatum}"
|
Binding="{Binding LastDatum}"
|
||||||
Width="2*"
|
Width="2*"
|
||||||
x:DataType="vm:MachineRowViewModel" />
|
x:DataType="vm:MachineRowViewModel" />
|
||||||
|
|
||||||
<DataGridTextColumn Header="Last Seen"
|
<DataGridTextColumn Header="Last Seen"
|
||||||
Binding="{Binding LastSeen}"
|
Binding="{Binding LastSeen}"
|
||||||
Width="Auto"
|
Width="Auto"
|
||||||
x:DataType="vm:MachineRowViewModel" />
|
x:DataType="vm:MachineRowViewModel" />
|
||||||
|
|
||||||
<DataGridTemplateColumn Header="" Width="Auto">
|
<!-- Fixed width: a trailing Auto column collapses against the 2* star columns,
|
||||||
<DataGridTemplateColumn.CellTemplate>
|
clipping the Details button to a sliver on first layout. -->
|
||||||
<DataTemplate x:DataType="vm:MachineRowViewModel">
|
<DataGridTemplateColumn Header="" Width="120">
|
||||||
<Button Content="Details"
|
<DataGridTemplateColumn.CellTemplate>
|
||||||
Command="{Binding OpenDetailCommand}"
|
<DataTemplate x:DataType="vm:MachineRowViewModel">
|
||||||
VerticalAlignment="Center" Margin="8,2" />
|
<Button Content="Details"
|
||||||
</DataTemplate>
|
Command="{Binding OpenDetailCommand}"
|
||||||
</DataGridTemplateColumn.CellTemplate>
|
VerticalAlignment="Center" Margin="12,4" />
|
||||||
</DataGridTemplateColumn>
|
</DataTemplate>
|
||||||
</DataGrid.Columns>
|
</DataGridTemplateColumn.CellTemplate>
|
||||||
</DataGrid>
|
</DataGridTemplateColumn>
|
||||||
|
</DataGrid.Columns>
|
||||||
|
</DataGrid>
|
||||||
|
</Border>
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
</UserControl>
|
</UserControl>
|
||||||
|
|
|
||||||
|
|
@ -8,122 +8,128 @@
|
||||||
x:DataType="vm:MachineConfigViewModel"
|
x:DataType="vm:MachineConfigViewModel"
|
||||||
x:CompileBindings="True">
|
x:CompileBindings="True">
|
||||||
|
|
||||||
<DockPanel Margin="16">
|
<!-- header / body / footer -->
|
||||||
|
<Grid Margin="20" RowDefinitions="Auto,*,Auto">
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<TextBlock DockPanel.Dock="Top"
|
<TextBlock Grid.Row="0" Classes="h1"
|
||||||
Text="{Binding Title}"
|
Text="{Binding Title}"
|
||||||
FontSize="22" FontWeight="SemiBold"
|
|
||||||
Margin="0,0,0,16" />
|
Margin="0,0,0,16" />
|
||||||
|
|
||||||
<!-- Action bar -->
|
<!-- Body: fixed form column + stretching data-items column -->
|
||||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Spacing="12" Margin="0,16,0,0">
|
<Grid Grid.Row="1" ColumnDefinitions="Auto,*">
|
||||||
<Button Content="Save" Command="{Binding SaveCommand}" IsDefault="True" />
|
|
||||||
<Button Content="Cancel" Command="{Binding CancelCommand}" />
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<!-- Inline validation hint -->
|
<!-- Form -->
|
||||||
<TextBlock DockPanel.Dock="Bottom"
|
<Border Grid.Column="0" Classes="card" Width="380" VerticalAlignment="Top">
|
||||||
Text="{Binding ValidationError}"
|
<StackPanel Spacing="12">
|
||||||
IsVisible="{Binding HasValidationError}"
|
|
||||||
Foreground="#C0392B"
|
|
||||||
TextWrapping="Wrap"
|
|
||||||
Margin="0,12,0,0" />
|
|
||||||
|
|
||||||
<!-- Form -->
|
<StackPanel Spacing="4">
|
||||||
<StackPanel DockPanel.Dock="Top" Spacing="12" MaxWidth="480" HorizontalAlignment="Left">
|
<TextBlock Classes="fieldLabel" Text="Name" />
|
||||||
|
<TextBox Text="{Binding Name}" Watermark="Machine name" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
<StackPanel Spacing="4">
|
<StackPanel Spacing="4">
|
||||||
<TextBlock Text="Name" FontWeight="SemiBold" />
|
<TextBlock Classes="fieldLabel" Text="Protocol" />
|
||||||
<TextBox Text="{Binding Name}" Watermark="Machine name" />
|
<ComboBox ItemsSource="{Binding AvailableProtocols}"
|
||||||
</StackPanel>
|
SelectedItem="{Binding SelectedProtocol}"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
PlaceholderText="Select a protocol" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
<StackPanel Spacing="4">
|
<StackPanel Spacing="4">
|
||||||
<TextBlock Text="Protocol" FontWeight="SemiBold" />
|
<TextBlock Classes="fieldLabel" Text="Agent URL" />
|
||||||
<ComboBox ItemsSource="{Binding AvailableProtocols}"
|
<TextBox Text="{Binding AgentUrl}" Watermark="http://host:5000" />
|
||||||
SelectedItem="{Binding SelectedProtocol}"
|
</StackPanel>
|
||||||
HorizontalAlignment="Stretch"
|
|
||||||
PlaceholderText="Select a protocol" />
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<StackPanel Spacing="4">
|
<StackPanel Spacing="4">
|
||||||
<TextBlock Text="Agent URL" FontWeight="SemiBold" />
|
<TextBlock Classes="fieldLabel" Text="Poll Interval (seconds)" />
|
||||||
<TextBox Text="{Binding AgentUrl}" Watermark="http://host:5000" />
|
<TextBox Text="{Binding PollIntervalSeconds}" Width="120" HorizontalAlignment="Left" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<StackPanel Spacing="4">
|
|
||||||
<TextBlock Text="Poll Interval (seconds)" FontWeight="SemiBold" />
|
|
||||||
<TextBox Text="{Binding PollIntervalSeconds}" Width="120" HorizontalAlignment="Left" />
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<!-- Data items to monitor (fills remaining space) -->
|
|
||||||
<Border BorderThickness="1"
|
|
||||||
BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"
|
|
||||||
CornerRadius="4"
|
|
||||||
Padding="12" Margin="0,16,0,0" MaxWidth="640" HorizontalAlignment="Left">
|
|
||||||
<DockPanel>
|
|
||||||
|
|
||||||
<TextBlock DockPanel.Dock="Top" Text="Data items to monitor"
|
|
||||||
FontSize="16" FontWeight="SemiBold" Margin="0,0,0,8" />
|
|
||||||
|
|
||||||
<!-- Controls row -->
|
|
||||||
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8" Margin="0,0,0,8">
|
|
||||||
<Button Content="Load available items"
|
|
||||||
Command="{Binding LoadItemsCommand}" />
|
|
||||||
<Button Content="Select all"
|
|
||||||
Command="{Binding SelectAllCommand}" />
|
|
||||||
<Button Content="Select none"
|
|
||||||
Command="{Binding SelectNoneCommand}" />
|
|
||||||
<ProgressBar IsIndeterminate="True"
|
|
||||||
IsVisible="{Binding IsProbing}"
|
|
||||||
Width="120" VerticalAlignment="Center" />
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<!-- Status message -->
|
<!-- Data items to monitor (fills remaining width + height) -->
|
||||||
<TextBlock DockPanel.Dock="Top"
|
<Border Grid.Column="1" Classes="card" Margin="16,0,0,0" HorizontalAlignment="Stretch">
|
||||||
Text="{Binding ItemsStatus}"
|
<DockPanel>
|
||||||
IsVisible="{Binding HasItemsStatus}"
|
|
||||||
Opacity="0.85" TextWrapping="Wrap" Margin="0,0,0,8" />
|
|
||||||
|
|
||||||
<!-- Column header -->
|
<TextBlock DockPanel.Dock="Top" Classes="h2" Text="Data items to monitor"
|
||||||
<Border DockPanel.Dock="Top"
|
Margin="0,0,0,8" />
|
||||||
BorderThickness="0,0,0,1"
|
|
||||||
BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"
|
|
||||||
Padding="0,0,0,4" Margin="0,0,0,4">
|
|
||||||
<Grid ColumnDefinitions="Auto,2*,1.2*,1.2*,1*" Margin="0,0,8,0">
|
|
||||||
<TextBlock Grid.Column="0" Text="" Width="28" />
|
|
||||||
<TextBlock Grid.Column="1" Text="Name" FontWeight="Bold" />
|
|
||||||
<TextBlock Grid.Column="2" Text="Type" FontWeight="Bold" />
|
|
||||||
<TextBlock Grid.Column="3" Text="Category" FontWeight="Bold" />
|
|
||||||
<TextBlock Grid.Column="4" Text="Units" FontWeight="Bold" />
|
|
||||||
</Grid>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<!-- Scrollable checklist -->
|
<!-- Controls row -->
|
||||||
<ScrollViewer MaxHeight="320">
|
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8" Margin="0,0,0,8">
|
||||||
<ItemsControl ItemsSource="{Binding AvailableItems}">
|
<Button Content="Load available items"
|
||||||
<ItemsControl.ItemTemplate>
|
Command="{Binding LoadItemsCommand}" />
|
||||||
<DataTemplate x:DataType="vm:SelectableDataItemViewModel">
|
<Button Content="Select all"
|
||||||
<Grid ColumnDefinitions="Auto,2*,1.2*,1.2*,1*" Margin="0,3">
|
Command="{Binding SelectAllCommand}" />
|
||||||
<CheckBox Grid.Column="0" Width="28"
|
<Button Content="Select none"
|
||||||
IsChecked="{Binding IsSelected}"
|
Command="{Binding SelectNoneCommand}" />
|
||||||
VerticalAlignment="Center" />
|
<ProgressBar IsIndeterminate="True"
|
||||||
<StackPanel Grid.Column="1" VerticalAlignment="Center">
|
IsVisible="{Binding IsProbing}"
|
||||||
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" TextWrapping="Wrap" />
|
Width="120" VerticalAlignment="Center" />
|
||||||
<TextBlock Text="{Binding Id}" FontSize="11" Opacity="0.6" TextWrapping="Wrap" />
|
</StackPanel>
|
||||||
</StackPanel>
|
|
||||||
<TextBlock Grid.Column="2" Text="{Binding Type}" VerticalAlignment="Center" TextWrapping="Wrap" />
|
|
||||||
<TextBlock Grid.Column="3" Text="{Binding Category}" VerticalAlignment="Center" TextWrapping="Wrap" />
|
|
||||||
<TextBlock Grid.Column="4" Text="{Binding Units}" VerticalAlignment="Center" TextWrapping="Wrap" />
|
|
||||||
</Grid>
|
|
||||||
</DataTemplate>
|
|
||||||
</ItemsControl.ItemTemplate>
|
|
||||||
</ItemsControl>
|
|
||||||
</ScrollViewer>
|
|
||||||
|
|
||||||
</DockPanel>
|
<!-- Status message -->
|
||||||
</Border>
|
<TextBlock DockPanel.Dock="Top" Classes="subtle"
|
||||||
</DockPanel>
|
Text="{Binding ItemsStatus}"
|
||||||
|
IsVisible="{Binding HasItemsStatus}"
|
||||||
|
TextWrapping="Wrap" Margin="0,0,0,8" />
|
||||||
|
|
||||||
|
<!-- Column header -->
|
||||||
|
<Border DockPanel.Dock="Top"
|
||||||
|
BorderThickness="0,0,0,1"
|
||||||
|
BorderBrush="{DynamicResource CardBorderBrush}"
|
||||||
|
Padding="0,0,0,4" Margin="0,0,0,4">
|
||||||
|
<Grid ColumnDefinitions="Auto,2*,1.2*,1.2*,1*" Margin="0,0,8,0">
|
||||||
|
<TextBlock Grid.Column="0" Text="" Width="28" />
|
||||||
|
<TextBlock Grid.Column="1" Classes="fieldLabel" Text="Name" />
|
||||||
|
<TextBlock Grid.Column="2" Classes="fieldLabel" Text="Type" />
|
||||||
|
<TextBlock Grid.Column="3" Classes="fieldLabel" Text="Category" />
|
||||||
|
<TextBlock Grid.Column="4" Classes="fieldLabel" Text="Units" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Scrollable checklist (fills remaining height) -->
|
||||||
|
<ScrollViewer>
|
||||||
|
<ItemsControl ItemsSource="{Binding AvailableItems}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:SelectableDataItemViewModel">
|
||||||
|
<Grid ColumnDefinitions="Auto,2*,1.2*,1.2*,1*" Margin="0,3">
|
||||||
|
<CheckBox Grid.Column="0" Width="28"
|
||||||
|
IsChecked="{Binding IsSelected}"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
<StackPanel Grid.Column="1" VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" TextWrapping="Wrap" />
|
||||||
|
<TextBlock Text="{Binding Id}" Classes="subtle" FontSize="11" TextWrapping="Wrap" />
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Grid.Column="2" Text="{Binding Type}" VerticalAlignment="Center" TextWrapping="Wrap" />
|
||||||
|
<TextBlock Grid.Column="3" Text="{Binding Category}" VerticalAlignment="Center" TextWrapping="Wrap" />
|
||||||
|
<TextBlock Grid.Column="4" Text="{Binding Units}" VerticalAlignment="Center" TextWrapping="Wrap" />
|
||||||
|
</Grid>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
|
</DockPanel>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Footer: validation hint + action bar, full width -->
|
||||||
|
<StackPanel Grid.Row="2" Spacing="0">
|
||||||
|
<!-- Inline validation hint -->
|
||||||
|
<TextBlock Text="{Binding ValidationError}"
|
||||||
|
IsVisible="{Binding HasValidationError}"
|
||||||
|
Foreground="{DynamicResource DangerBrush}"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
Margin="0,12,0,0" />
|
||||||
|
|
||||||
|
<!-- Action bar -->
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="12" Margin="0,16,0,0">
|
||||||
|
<Button Classes="accent" Content="Save" Command="{Binding SaveCommand}" IsDefault="True" />
|
||||||
|
<Button Content="Cancel" Command="{Binding CancelCommand}" />
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
</Grid>
|
||||||
</UserControl>
|
</UserControl>
|
||||||
|
|
|
||||||
|
|
@ -3,77 +3,78 @@
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
xmlns:vm="clr-namespace:Junction.App.ViewModels"
|
xmlns:vm="clr-namespace:Junction.App.ViewModels"
|
||||||
|
xmlns:controls="clr-namespace:Junction.App.Controls"
|
||||||
mc:Ignorable="d"
|
mc:Ignorable="d"
|
||||||
x:Class="Junction.App.Views.MachineDetailView"
|
x:Class="Junction.App.Views.MachineDetailView"
|
||||||
x:DataType="vm:MachineDetailViewModel"
|
x:DataType="vm:MachineDetailViewModel"
|
||||||
x:CompileBindings="True">
|
x:CompileBindings="True">
|
||||||
|
|
||||||
<Grid>
|
<Grid>
|
||||||
<DockPanel Margin="16">
|
<DockPanel Margin="20">
|
||||||
|
|
||||||
<!-- Header bar: Back + machine name + status badge + Edit/Delete -->
|
<!-- Header bar: Back + protocol glyph + machine name + status badge + Edit/Delete -->
|
||||||
<Grid DockPanel.Dock="Top" ColumnDefinitions="Auto,Auto,*,Auto,Auto" Margin="0,0,0,12">
|
<Grid DockPanel.Dock="Top" ColumnDefinitions="Auto,Auto,Auto,*,Auto,Auto" Margin="0,0,0,16">
|
||||||
<Button Grid.Column="0" Content="← Back" Command="{Binding BackCommand}" VerticalAlignment="Center" />
|
<Button Grid.Column="0" Content="← Back" Command="{Binding BackCommand}" VerticalAlignment="Center" />
|
||||||
<TextBlock Grid.Column="1" Text="{Binding MachineName}"
|
<PathIcon Grid.Column="1" Classes="protocol" Width="20" Height="20"
|
||||||
FontSize="22" FontWeight="SemiBold"
|
Data="{Binding ProtocolId, Converter={StaticResource ProtocolIdToIcon}}"
|
||||||
VerticalAlignment="Center" Margin="12,0" />
|
ToolTip.Tip="{Binding ProtocolId}"
|
||||||
<Border Grid.Column="2"
|
VerticalAlignment="Center" Margin="14,0,0,0" />
|
||||||
|
<TextBlock Grid.Column="2" Classes="h1" Text="{Binding MachineName}"
|
||||||
|
VerticalAlignment="Center" Margin="10,0" />
|
||||||
|
<Border Grid.Column="3"
|
||||||
Background="{Binding StatusKind, Converter={StaticResource StatusKindToBrush}}"
|
Background="{Binding StatusKind, Converter={StaticResource StatusKindToBrush}}"
|
||||||
CornerRadius="4" Padding="8,3" HorizontalAlignment="Left" VerticalAlignment="Center">
|
CornerRadius="4" Padding="8,3" HorizontalAlignment="Left" VerticalAlignment="Center">
|
||||||
<TextBlock Text="{Binding ConnectionState}" Foreground="White" FontWeight="SemiBold" FontSize="12" />
|
<TextBlock Text="{Binding ConnectionState}" Foreground="White" FontWeight="SemiBold" FontSize="12" />
|
||||||
</Border>
|
</Border>
|
||||||
<Button Grid.Column="3" Content="Edit" Command="{Binding EditCommand}"
|
<Button Grid.Column="4" Content="Edit" Command="{Binding EditCommand}"
|
||||||
VerticalAlignment="Center" Margin="0,0,8,0" />
|
VerticalAlignment="Center" Margin="0,0,8,0" />
|
||||||
<Button Grid.Column="4" Content="Delete" Command="{Binding RequestDeleteCommand}"
|
<Button Grid.Column="5" Classes="danger" Content="Delete" Command="{Binding RequestDeleteCommand}"
|
||||||
VerticalAlignment="Center" />
|
VerticalAlignment="Center" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<!-- Machine info panel -->
|
<!-- Machine info panel -->
|
||||||
<Border DockPanel.Dock="Top"
|
<Border DockPanel.Dock="Top" Classes="card" Margin="0,0,0,16">
|
||||||
BorderThickness="1"
|
|
||||||
BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"
|
|
||||||
CornerRadius="4"
|
|
||||||
Padding="12" Margin="0,0,0,12">
|
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto,*" RowDefinitions="Auto,Auto,Auto,Auto" >
|
<Grid ColumnDefinitions="Auto,*,Auto,*" RowDefinitions="Auto,Auto,Auto,Auto" >
|
||||||
<TextBlock Grid.Row="0" Grid.Column="0" Text="Protocol" FontWeight="Bold" Margin="0,0,12,4" />
|
<TextBlock Grid.Row="0" Grid.Column="0" Classes="fieldLabel" Text="Protocol" Margin="0,0,12,6" />
|
||||||
<TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding ProtocolId}" Margin="0,0,24,4" />
|
<TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding ProtocolId}" Margin="0,0,24,6" />
|
||||||
<TextBlock Grid.Row="0" Grid.Column="2" Text="Poll Interval" FontWeight="Bold" Margin="0,0,12,4" />
|
<TextBlock Grid.Row="0" Grid.Column="2" Classes="fieldLabel" Text="Poll Interval" Margin="0,0,12,6" />
|
||||||
<TextBlock Grid.Row="0" Grid.Column="3" Text="{Binding PollInterval}" Margin="0,0,0,4" />
|
<TextBlock Grid.Row="0" Grid.Column="3" Text="{Binding PollInterval}" Margin="0,0,0,6" />
|
||||||
|
|
||||||
<TextBlock Grid.Row="1" Grid.Column="0" Text="Connection" FontWeight="Bold" Margin="0,0,12,4" />
|
<TextBlock Grid.Row="1" Grid.Column="0" Classes="fieldLabel" Text="Connection" Margin="0,0,12,6" />
|
||||||
<TextBlock Grid.Row="1" Grid.Column="1" Text="{Binding ConnectionState}"
|
<TextBlock Grid.Row="1" Grid.Column="1" Text="{Binding ConnectionState}"
|
||||||
Foreground="{Binding StatusKind, Converter={StaticResource StatusKindToBrush}}"
|
Foreground="{Binding StatusKind, Converter={StaticResource StatusKindToBrush}}"
|
||||||
FontWeight="SemiBold" Margin="0,0,24,4" />
|
FontWeight="SemiBold" Margin="0,0,24,6" />
|
||||||
<TextBlock Grid.Row="1" Grid.Column="2" Text="Captured At" FontWeight="Bold" Margin="0,0,12,4" />
|
<TextBlock Grid.Row="1" Grid.Column="2" Classes="fieldLabel" Text="Captured At" Margin="0,0,12,6" />
|
||||||
<TextBlock Grid.Row="1" Grid.Column="3" Text="{Binding CapturedAt}" Margin="0,0,0,4" />
|
<TextBlock Grid.Row="1" Grid.Column="3" Text="{Binding CapturedAt}" Margin="0,0,0,6" />
|
||||||
|
|
||||||
<TextBlock Grid.Row="2" Grid.Column="0" Text="Last Seen" FontWeight="Bold" Margin="0,0,12,4" />
|
<TextBlock Grid.Row="2" Grid.Column="0" Classes="fieldLabel" Text="Last Seen" Margin="0,0,12,6" />
|
||||||
<TextBlock Grid.Row="2" Grid.Column="1" Text="{Binding LastSeen}" Margin="0,0,24,4" />
|
<TextBlock Grid.Row="2" Grid.Column="1" Text="{Binding LastSeen}" Margin="0,0,24,6" />
|
||||||
<TextBlock Grid.Row="2" Grid.Column="2" Text="Machine Id" FontWeight="Bold" Margin="0,0,12,4" />
|
<TextBlock Grid.Row="2" Grid.Column="2" Classes="fieldLabel" Text="Machine Id" Margin="0,0,12,6" />
|
||||||
<TextBlock Grid.Row="2" Grid.Column="3" Text="{Binding MachineIdText}" Margin="0,0,0,4"
|
<TextBlock Grid.Row="2" Grid.Column="3" Text="{Binding MachineIdText}" Margin="0,0,0,6"
|
||||||
FontSize="11" Opacity="0.7" TextWrapping="Wrap" />
|
FontSize="11" Opacity="0.7" TextWrapping="Wrap" />
|
||||||
|
|
||||||
<TextBlock Grid.Row="3" Grid.Column="0" Text="Agent URL" FontWeight="Bold" Margin="0,0,12,0" />
|
<TextBlock Grid.Row="3" Grid.Column="0" Classes="fieldLabel" Text="Agent URL" Margin="0,0,12,0" />
|
||||||
<TextBlock Grid.Row="3" Grid.Column="1" Grid.ColumnSpan="3" Text="{Binding AgentUrl}"
|
<TextBlock Grid.Row="3" Grid.Column="1" Grid.ColumnSpan="3" Text="{Binding AgentUrl}"
|
||||||
Margin="0,0,0,0" TextWrapping="Wrap" />
|
Margin="0,0,0,0" TextWrapping="Wrap" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- Data-items header -->
|
<!-- Data-items header -->
|
||||||
<TextBlock DockPanel.Dock="Top"
|
<TextBlock DockPanel.Dock="Top" Classes="h2"
|
||||||
Text="{Binding ItemCount, StringFormat='Data Items ({0})'}"
|
Text="{Binding ItemCount, StringFormat='Data Items ({0})'}"
|
||||||
FontSize="16" FontWeight="SemiBold" Margin="0,0,0,6" />
|
Margin="0,0,0,8" />
|
||||||
|
|
||||||
<Border DockPanel.Dock="Top"
|
<Border DockPanel.Dock="Top"
|
||||||
BorderThickness="0,0,0,1"
|
BorderThickness="0,0,0,1"
|
||||||
BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"
|
BorderBrush="{DynamicResource CardBorderBrush}"
|
||||||
Padding="0,0,0,6" Margin="0,0,0,4"
|
Padding="0,0,0,6" Margin="0,0,0,4"
|
||||||
IsVisible="{Binding HasItems}">
|
IsVisible="{Binding HasItems}">
|
||||||
<Grid ColumnDefinitions="2*,2*,1.2*,1.2*">
|
<Grid ColumnDefinitions="2*,1.6*,1*,1.2*,1.4*">
|
||||||
<TextBlock Grid.Column="0" Text="Name" FontWeight="Bold" />
|
<TextBlock Grid.Column="0" Classes="fieldLabel" Text="Name" />
|
||||||
<TextBlock Grid.Column="1" Text="Value" FontWeight="Bold" />
|
<TextBlock Grid.Column="1" Classes="fieldLabel" Text="Value" />
|
||||||
<TextBlock Grid.Column="2" Text="Category" FontWeight="Bold" />
|
<TextBlock Grid.Column="2" Classes="fieldLabel" Text="Category" />
|
||||||
<TextBlock Grid.Column="3" Text="Timestamp" FontWeight="Bold" />
|
<TextBlock Grid.Column="3" Classes="fieldLabel" Text="Timestamp" />
|
||||||
|
<TextBlock Grid.Column="4" Classes="fieldLabel" Text="Trend" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
|
@ -83,14 +84,17 @@
|
||||||
<ItemsControl ItemsSource="{Binding Items}">
|
<ItemsControl ItemsSource="{Binding Items}">
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:DataItemRowViewModel">
|
<DataTemplate x:DataType="vm:DataItemRowViewModel">
|
||||||
<Grid ColumnDefinitions="2*,2*,1.2*,1.2*" Margin="0,5">
|
<Grid ColumnDefinitions="2*,1.6*,1*,1.2*,1.4*" Margin="0,5">
|
||||||
<StackPanel Grid.Column="0">
|
<StackPanel Grid.Column="0">
|
||||||
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" TextWrapping="Wrap" />
|
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" TextWrapping="Wrap" />
|
||||||
<TextBlock Text="{Binding Id}" FontSize="11" Opacity="0.6" TextWrapping="Wrap" />
|
<TextBlock Text="{Binding Id}" Classes="subtle" FontSize="11" TextWrapping="Wrap" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<TextBlock Grid.Column="1" Text="{Binding Value}" VerticalAlignment="Center" TextWrapping="Wrap" />
|
<TextBlock Grid.Column="1" Text="{Binding Value}" VerticalAlignment="Center" TextWrapping="Wrap" />
|
||||||
<TextBlock Grid.Column="2" Text="{Binding Category}" VerticalAlignment="Center" />
|
<TextBlock Grid.Column="2" Text="{Binding Category}" VerticalAlignment="Center" />
|
||||||
<TextBlock Grid.Column="3" Text="{Binding Timestamp}" VerticalAlignment="Center" />
|
<TextBlock Grid.Column="3" Text="{Binding Timestamp}" VerticalAlignment="Center" />
|
||||||
|
<controls:Sparkline Grid.Column="4" Points="{Binding Trend}" IsVisible="{Binding HasTrend}"
|
||||||
|
Height="24" VerticalAlignment="Center"
|
||||||
|
Stroke="{DynamicResource AccentBrush}" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ItemsControl.ItemTemplate>
|
</ItemsControl.ItemTemplate>
|
||||||
|
|
@ -101,11 +105,10 @@
|
||||||
<StackPanel IsVisible="{Binding HasNoItems}"
|
<StackPanel IsVisible="{Binding HasNoItems}"
|
||||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||||
Spacing="6" MaxWidth="420">
|
Spacing="6" MaxWidth="420">
|
||||||
<TextBlock Text="No monitored items"
|
<TextBlock Classes="h2" Text="No monitored items"
|
||||||
FontSize="16" FontWeight="SemiBold"
|
|
||||||
HorizontalAlignment="Center" />
|
HorizontalAlignment="Center" />
|
||||||
<TextBlock Text="Edit this machine to select which data items to monitor."
|
<TextBlock Classes="subtle" Text="Edit this machine to select which data items to monitor."
|
||||||
Opacity="0.75" TextWrapping="Wrap" TextAlignment="Center" />
|
TextWrapping="Wrap" TextAlignment="Center" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
|
|
@ -113,19 +116,16 @@
|
||||||
<!-- Delete-confirmation overlay: dims the screen, names the machine, requires an explicit choice. -->
|
<!-- Delete-confirmation overlay: dims the screen, names the machine, requires an explicit choice. -->
|
||||||
<Border IsVisible="{Binding IsDeleteConfirmVisible}"
|
<Border IsVisible="{Binding IsDeleteConfirmVisible}"
|
||||||
Background="#99000000">
|
Background="#99000000">
|
||||||
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
|
<Border Classes="card" MaxWidth="420"
|
||||||
BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"
|
|
||||||
BorderThickness="1" CornerRadius="6"
|
|
||||||
Padding="20" MaxWidth="420"
|
|
||||||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||||
<StackPanel Spacing="14">
|
<StackPanel Spacing="14">
|
||||||
<TextBlock Text="{Binding DeleteConfirmPrompt}" FontSize="16" FontWeight="SemiBold" TextWrapping="Wrap" />
|
<TextBlock Classes="h2" Text="{Binding DeleteConfirmPrompt}" TextWrapping="Wrap" />
|
||||||
<TextBlock Text="This permanently removes the machine and stops monitoring it. This cannot be undone."
|
<TextBlock Classes="subtle"
|
||||||
Opacity="0.75" TextWrapping="Wrap" />
|
Text="This permanently removes the machine and stops monitoring it. This cannot be undone."
|
||||||
|
TextWrapping="Wrap" />
|
||||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8">
|
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8">
|
||||||
<Button Content="Cancel" Command="{Binding CancelDeleteCommand}" />
|
<Button Content="Cancel" Command="{Binding CancelDeleteCommand}" />
|
||||||
<Button Content="Delete" Command="{Binding ConfirmDeleteCommand}"
|
<Button Classes="danger" Content="Delete" Command="{Binding ConfirmDeleteCommand}" />
|
||||||
Background="#C62828" Foreground="White" FontWeight="SemiBold" />
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,15 @@ namespace Junction.Core.Monitoring
|
||||||
private readonly ConcurrentDictionary<Guid, MachineSnapshot> _snapshots =
|
private readonly ConcurrentDictionary<Guid, MachineSnapshot> _snapshots =
|
||||||
new ConcurrentDictionary<Guid, MachineSnapshot>();
|
new ConcurrentDictionary<Guid, MachineSnapshot>();
|
||||||
|
|
||||||
|
// History retention policy (const-fixed for this slice; not config-driven yet).
|
||||||
|
// TimeSpan can't be a C# const, so these are static readonly.
|
||||||
|
private static readonly TimeSpan RetentionWindow = TimeSpan.FromDays(7);
|
||||||
|
private static readonly TimeSpan PruneInterval = TimeSpan.FromHours(1);
|
||||||
|
|
||||||
|
// UTC ticks of the last prune claim. Interlocked-guarded because PersistAsync runs
|
||||||
|
// concurrently across per-machine poll loops. Init to MinValue so the first persist prunes.
|
||||||
|
private long _lastPruneTicks = DateTimeOffset.MinValue.UtcTicks;
|
||||||
|
|
||||||
private readonly object _lifecycleLock = new object();
|
private readonly object _lifecycleLock = new object();
|
||||||
private readonly List<RunningLoop> _running = new List<RunningLoop>();
|
private readonly List<RunningLoop> _running = new List<RunningLoop>();
|
||||||
|
|
||||||
|
|
@ -462,6 +471,23 @@ namespace Junction.Core.Monitoring
|
||||||
"Failed to persist snapshot for machine {MachineId}: {Errors}",
|
"Failed to persist snapshot for machine {MachineId}: {Errors}",
|
||||||
snapshot.MachineId, DescribeErrors(result.Errors));
|
snapshot.MachineId, DescribeErrors(result.Errors));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Append time-series history for live reads only. Synthetic Disconnected
|
||||||
|
// snapshots carry no items (AppendHistory no-ops) but gating here avoids the
|
||||||
|
// round-trip and keeps disconnect noise out of the trend. A history failure
|
||||||
|
// must never undo the latest-snapshot save above.
|
||||||
|
if (snapshot.ConnectionState == ConnectionState.Connected)
|
||||||
|
{
|
||||||
|
Result historyResult = await _repository.AppendHistoryAsync(snapshot, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (!historyResult.IsSuccess && !historyResult.WasCancelled)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Failed to append history for machine {MachineId}: {Errors}",
|
||||||
|
snapshot.MachineId, DescribeErrors(historyResult.Errors));
|
||||||
|
}
|
||||||
|
|
||||||
|
await MaybePruneHistoryAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
|
|
@ -473,6 +499,38 @@ namespace Junction.Core.Monitoring
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Prunes history older than <see cref="RetentionWindow"/> at most once per
|
||||||
|
/// <see cref="PruneInterval"/>, regardless of poll frequency or machine count. The
|
||||||
|
/// window is claimed via a single Interlocked CompareExchange so concurrent poll loops
|
||||||
|
/// don't all prune at once; a lost race simply skips (harmless).
|
||||||
|
/// </summary>
|
||||||
|
private async Task MaybePruneHistoryAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||||
|
long lastPrune = Interlocked.Read(ref _lastPruneTicks);
|
||||||
|
|
||||||
|
if (now - new DateTimeOffset(lastPrune, TimeSpan.Zero) < PruneInterval)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claim the interval; only the loop that wins the swap performs the prune.
|
||||||
|
if (Interlocked.CompareExchange(ref _lastPruneTicks, now.UtcTicks, lastPrune) != lastPrune)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTimeOffset cutoff = now - RetentionWindow;
|
||||||
|
Result pruneResult = await _repository.PruneHistoryAsync(cutoff, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (!pruneResult.IsSuccess && !pruneResult.WasCancelled)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Failed to prune history older than {Cutoff}: {Errors}",
|
||||||
|
cutoff, DescribeErrors(pruneResult.Errors));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Maps ProtocolId (case-insensitive) to factory. On duplicate protocol ids the first
|
/// Maps ProtocolId (case-insensitive) to factory. On duplicate protocol ids the first
|
||||||
/// loaded plugin wins; the collision is logged.
|
/// loaded plugin wins; the collision is logged.
|
||||||
|
|
|
||||||
31
src/Junction.Domain/Models/HistoryPoint.cs
Normal file
31
src/Junction.Domain/Models/HistoryPoint.cs
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace Junction.Domain.Models
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// One time-series sample of one data item: the value a datum had at a point in time.
|
||||||
|
/// Immutable, protocol-agnostic. Unlike <see cref="DataItem"/> (only-latest), history points
|
||||||
|
/// accumulate append-only so a trend over time can be reconstructed.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class HistoryPoint
|
||||||
|
{
|
||||||
|
/// <summary>Stable identifier of the datum this sample belongs to (see <see cref="DataItem.Id"/>).</summary>
|
||||||
|
public string ItemId { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sampled value kept as string to stay type-agnostic across protocols;
|
||||||
|
/// callers parse to the concrete type they need.
|
||||||
|
/// </summary>
|
||||||
|
public string Value { get; }
|
||||||
|
|
||||||
|
/// <summary>Instant the value was reported/observed.</summary>
|
||||||
|
public DateTimeOffset Timestamp { get; }
|
||||||
|
|
||||||
|
public HistoryPoint(string itemId, string value, DateTimeOffset timestamp)
|
||||||
|
{
|
||||||
|
ItemId = itemId ?? "";
|
||||||
|
Value = value ?? "";
|
||||||
|
Timestamp = timestamp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -42,5 +42,27 @@ namespace Junction.Domain.Persistence
|
||||||
|
|
||||||
/// <summary>Returns the latest stored snapshot for the machine, or a failed result when none exists.</summary>
|
/// <summary>Returns the latest stored snapshot for the machine, or a failed result when none exists.</summary>
|
||||||
Task<Result<MachineSnapshot>> GetLatestSnapshotAsync(Guid machineId, CancellationToken cancellationToken);
|
Task<Result<MachineSnapshot>> GetLatestSnapshotAsync(Guid machineId, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Appends one time-series history row per item in <paramref name="snapshot"/> (append-only:
|
||||||
|
/// never overwrites an earlier sample). A snapshot with no items is a no-op success.
|
||||||
|
/// This is separate from <see cref="SaveSnapshotAsync"/>, which keeps only the latest set.
|
||||||
|
/// </summary>
|
||||||
|
Task<Result> AppendHistoryAsync(MachineSnapshot snapshot, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns time-series samples for a single item of a machine at or after <paramref name="since"/>,
|
||||||
|
/// ascending by timestamp, capped at <paramref name="maxPoints"/> (keeping the MOST RECENT
|
||||||
|
/// <paramref name="maxPoints"/> when more exist). Empty list on none — a success, not a failure.
|
||||||
|
/// A non-positive <paramref name="maxPoints"/> yields an empty success.
|
||||||
|
/// </summary>
|
||||||
|
Task<Result<IReadOnlyList<HistoryPoint>>> GetHistoryAsync(Guid machineId, string itemId, DateTimeOffset since, int maxPoints, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes all history rows whose timestamp is strictly older than <paramref name="olderThan"/>.
|
||||||
|
/// Idempotent: safe to call when nothing matches. Provides the mechanism only; retention
|
||||||
|
/// policy (deciding the cutoff) lives above this port.
|
||||||
|
/// </summary>
|
||||||
|
Task<Result> PruneHistoryAsync(DateTimeOffset olderThan, CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -189,6 +189,10 @@ namespace Junction.Persistence
|
||||||
{
|
{
|
||||||
var idParam = new { Id = GuidText(id) };
|
var idParam = new { Id = GuidText(id) };
|
||||||
|
|
||||||
|
await connection.ExecuteAsync(new CommandDefinition(
|
||||||
|
"DELETE FROM snapshot_history WHERE MachineId = @Id;",
|
||||||
|
idParam, transaction, cancellationToken: cancellationToken)).ConfigureAwait(false);
|
||||||
|
|
||||||
await connection.ExecuteAsync(new CommandDefinition(
|
await connection.ExecuteAsync(new CommandDefinition(
|
||||||
"DELETE FROM snapshot_items WHERE MachineId = @Id;",
|
"DELETE FROM snapshot_items WHERE MachineId = @Id;",
|
||||||
idParam, transaction, cancellationToken: cancellationToken)).ConfigureAwait(false);
|
idParam, transaction, cancellationToken: cancellationToken)).ConfigureAwait(false);
|
||||||
|
|
@ -354,6 +358,150 @@ namespace Junction.Persistence
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<Result> AppendHistoryAsync(MachineSnapshot snapshot, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (snapshot == null)
|
||||||
|
{
|
||||||
|
return Result.Fail(OperationError.Of(Source, "Snapshot is null."));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return Result.Cancelled();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append-only: nothing to record for an empty snapshot.
|
||||||
|
if (snapshot.Items.Count == 0)
|
||||||
|
{
|
||||||
|
return Result.Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using (var connection = OpenConnection())
|
||||||
|
using (var transaction = connection.BeginTransaction())
|
||||||
|
{
|
||||||
|
var machineIdText = GuidText(snapshot.MachineId);
|
||||||
|
|
||||||
|
var itemParams = new List<object>(snapshot.Items.Count);
|
||||||
|
foreach (var item in snapshot.Items)
|
||||||
|
{
|
||||||
|
itemParams.Add(new
|
||||||
|
{
|
||||||
|
MachineId = machineIdText,
|
||||||
|
ItemId = item.Id,
|
||||||
|
item.Name,
|
||||||
|
item.Value,
|
||||||
|
item.Category,
|
||||||
|
Timestamp = IsoText(item.Timestamp)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await connection.ExecuteAsync(new CommandDefinition(
|
||||||
|
"INSERT INTO snapshot_history (MachineId, ItemId, Name, Value, Category, Timestamp) " +
|
||||||
|
"VALUES (@MachineId, @ItemId, @Name, @Value, @Category, @Timestamp);",
|
||||||
|
itemParams,
|
||||||
|
transaction, cancellationToken: cancellationToken)).ConfigureAwait(false);
|
||||||
|
|
||||||
|
transaction.Commit();
|
||||||
|
|
||||||
|
return Result.Ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return Result.Cancelled();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail(OperationError.Of(Source, "AppendHistory failed: " + ex.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<IReadOnlyList<HistoryPoint>>> GetHistoryAsync(
|
||||||
|
Guid machineId, string itemId, DateTimeOffset since, int maxPoints, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return Result<IReadOnlyList<HistoryPoint>>.Cancelled();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-positive cap => nothing requested; empty success (not a failure).
|
||||||
|
if (maxPoints <= 0)
|
||||||
|
{
|
||||||
|
return Result<IReadOnlyList<HistoryPoint>>.Ok(Array.Empty<HistoryPoint>());
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using (var connection = OpenConnection())
|
||||||
|
{
|
||||||
|
// Take the most-recent N at/after `since` (DESC + LIMIT), then reverse to ascending.
|
||||||
|
var rows = await connection.QueryAsync<HistoryRow>(new CommandDefinition(
|
||||||
|
"SELECT ItemId, Value, Timestamp FROM snapshot_history " +
|
||||||
|
"WHERE MachineId = @MachineId AND ItemId = @ItemId AND Timestamp >= @Since " +
|
||||||
|
"ORDER BY Timestamp DESC LIMIT @MaxPoints;",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
MachineId = GuidText(machineId),
|
||||||
|
ItemId = itemId ?? "",
|
||||||
|
Since = IsoText(since),
|
||||||
|
MaxPoints = maxPoints
|
||||||
|
},
|
||||||
|
cancellationToken: cancellationToken)).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var points = new List<HistoryPoint>();
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
points.Add(new HistoryPoint(row.ItemId, row.Value, ParseIso(row.Timestamp)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query is DESC (newest first); reverse to ascending by timestamp.
|
||||||
|
points.Reverse();
|
||||||
|
|
||||||
|
return Result<IReadOnlyList<HistoryPoint>>.Ok(points);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return Result<IReadOnlyList<HistoryPoint>>.Cancelled();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result<IReadOnlyList<HistoryPoint>>.Fail(
|
||||||
|
OperationError.Of(Source, "GetHistory failed: " + ex.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result> PruneHistoryAsync(DateTimeOffset olderThan, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return Result.Cancelled();
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using (var connection = OpenConnection())
|
||||||
|
{
|
||||||
|
await connection.ExecuteAsync(new CommandDefinition(
|
||||||
|
"DELETE FROM snapshot_history WHERE Timestamp < @OlderThan;",
|
||||||
|
new { OlderThan = IsoText(olderThan) },
|
||||||
|
cancellationToken: cancellationToken)).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return Result.Ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return Result.Cancelled();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail(OperationError.Of(Source, "PruneHistory failed: " + ex.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// -- helpers -------------------------------------------------------------------------
|
// -- helpers -------------------------------------------------------------------------
|
||||||
|
|
||||||
private DbConnection OpenConnection()
|
private DbConnection OpenConnection()
|
||||||
|
|
@ -471,5 +619,12 @@ namespace Junction.Persistence
|
||||||
public string Category { get; set; } = "";
|
public string Category { get; set; } = "";
|
||||||
public string Timestamp { get; set; } = "";
|
public string Timestamp { get; set; } = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class HistoryRow
|
||||||
|
{
|
||||||
|
public string ItemId { get; set; } = "";
|
||||||
|
public string Value { get; set; } = "";
|
||||||
|
public string Timestamp { get; set; } = "";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@ namespace Junction.Persistence
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Owns the SQLite schema (DDL). Idempotent: safe to run on every startup.
|
/// Owns the SQLite schema (DDL). Idempotent: safe to run on every startup.
|
||||||
/// Only-latest policy — one snapshot row and one item-set per machine — but the
|
/// latest_snapshots/snapshot_items keep the only-latest set per machine; snapshot_history
|
||||||
/// shape leaves room to add history tables later without breaking these tables.
|
/// is the append-only time series added alongside them without breaking those tables.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class SqliteSchema
|
public static class SqliteSchema
|
||||||
{
|
{
|
||||||
|
|
@ -42,6 +42,23 @@ namespace Junction.Persistence
|
||||||
PRIMARY KEY (MachineId, ItemId)
|
PRIMARY KEY (MachineId, ItemId)
|
||||||
);";
|
);";
|
||||||
|
|
||||||
|
// snapshot_history: append-only time series. Many rows per (MachineId, ItemId) — one per
|
||||||
|
// appended sample — so NO single-row primary key. Never overwritten; pruned by timestamp.
|
||||||
|
private const string CreateSnapshotHistory =
|
||||||
|
@"CREATE TABLE IF NOT EXISTS snapshot_history (
|
||||||
|
MachineId TEXT NOT NULL,
|
||||||
|
ItemId TEXT NOT NULL,
|
||||||
|
Name TEXT NOT NULL,
|
||||||
|
Value TEXT NOT NULL,
|
||||||
|
Category TEXT NOT NULL,
|
||||||
|
Timestamp TEXT NOT NULL
|
||||||
|
);";
|
||||||
|
|
||||||
|
// Index for the query path (GetHistory: WHERE MachineId=@ AND ItemId=@ AND Timestamp>=@ ORDER BY Timestamp).
|
||||||
|
private const string CreateSnapshotHistoryIndex =
|
||||||
|
@"CREATE INDEX IF NOT EXISTS ix_snapshot_history_machine_item_ts
|
||||||
|
ON snapshot_history (MachineId, ItemId, Timestamp);";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates all tables if they do not already exist. Idempotent.
|
/// Creates all tables if they do not already exist. Idempotent.
|
||||||
/// Returns a failed <see cref="Result"/> instead of throwing on store errors.
|
/// Returns a failed <see cref="Result"/> instead of throwing on store errors.
|
||||||
|
|
@ -63,6 +80,8 @@ namespace Junction.Persistence
|
||||||
Execute(connection, CreateMachines);
|
Execute(connection, CreateMachines);
|
||||||
Execute(connection, CreateLatestSnapshots);
|
Execute(connection, CreateLatestSnapshots);
|
||||||
Execute(connection, CreateSnapshotItems);
|
Execute(connection, CreateSnapshotItems);
|
||||||
|
Execute(connection, CreateSnapshotHistory);
|
||||||
|
Execute(connection, CreateSnapshotHistoryIndex);
|
||||||
|
|
||||||
// Migration for DBs created by an older schema (before MonitoredItemIdsJson existed):
|
// Migration for DBs created by an older schema (before MonitoredItemIdsJson existed):
|
||||||
// CREATE TABLE IF NOT EXISTS never alters an existing table, so add the column here if
|
// CREATE TABLE IF NOT EXISTS never alters an existing table, so add the column here if
|
||||||
|
|
|
||||||
27
src/Junction.Protocols.OpcUa/IUaClient.cs
Normal file
27
src/Junction.Protocols.OpcUa/IUaClient.cs
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Junction.Domain.Protocols;
|
||||||
|
|
||||||
|
namespace Junction.Protocols.OpcUa
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Transport seam over an OPC UA session, mirroring the HttpClient injection the MTConnect
|
||||||
|
/// driver uses. Deliberately keeps all <c>Opc.Ua.*</c> SDK types OUT of its signatures so
|
||||||
|
/// <see cref="OpcuaDriver"/> can be unit-tested against a mock with no live server.
|
||||||
|
/// </summary>
|
||||||
|
public interface IUaClient
|
||||||
|
{
|
||||||
|
/// <summary>Establish the session (connect + authenticate). Idempotent: safe to call before each op.</summary>
|
||||||
|
Task ConnectAsync(CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>Read the Value attribute of each given node id. Order matches the input order.</summary>
|
||||||
|
Task<IReadOnlyList<UaReadResult>> ReadAsync(IReadOnlyList<string> nodeIds, CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Recursively browse the address space from <paramref name="browseRoot"/>, returning one
|
||||||
|
/// descriptor per Variable node found.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<DataItemDescriptor>> BrowseAsync(string browseRoot, CancellationToken ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/Junction.Protocols.OpcUa/Junction.Protocols.OpcUa.csproj
Normal file
27
src/Junction.Protocols.OpcUa/Junction.Protocols.OpcUa.csproj
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<!-- Multi-target: net48 = shipped fleet (Win7/8); net8.0 = Linux dev host must load plugin too.
|
||||||
|
MTConnect is ns2.0 (loads on both), but the OPC UA SDK ships no ns2.0 asset, so target the
|
||||||
|
two host TFMs explicitly. -->
|
||||||
|
<TargetFrameworks>net48;net8.0</TargetFrameworks>
|
||||||
|
<RootNamespace>Junction.Protocols.OpcUa</RootNamespace>
|
||||||
|
<!-- Guarantees the full transitive closure (SDK + its deps) lands in bin per TFM so the App copy
|
||||||
|
target ships everything the plugin needs at runtime. -->
|
||||||
|
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="1.5.378.156" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Junction.Domain\Junction.Domain.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- Plugin manifest: ships beside built assembly. Condition keeps build green if file is absent. -->
|
||||||
|
<ItemGroup>
|
||||||
|
<None Include="plugin.manifest.json" Condition="Exists('plugin.manifest.json')" CopyToOutputDirectory="PreserveNewest" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
182
src/Junction.Protocols.OpcUa/OpcuaDriver.cs
Normal file
182
src/Junction.Protocols.OpcUa/OpcuaDriver.cs
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Junction.Domain;
|
||||||
|
using Junction.Domain.Models;
|
||||||
|
using Junction.Domain.Protocols;
|
||||||
|
|
||||||
|
namespace Junction.Protocols.OpcUa
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Per-machine OPC UA protocol driver. Reads the configured node values and browses the address
|
||||||
|
/// space through an injected <see cref="IUaClient"/> (the transport seam, mirroring the HttpClient
|
||||||
|
/// injection in <c>MtconnectDriver</c>). SDK types stay behind <see cref="IUaClient"/> so this
|
||||||
|
/// driver is unit-tested against a mock with no live server.
|
||||||
|
/// <para>
|
||||||
|
/// Never throws for expected transport failures: maps them to <see cref="Result{T}.Fail(OperationError)"/>
|
||||||
|
/// and honours cancellation via <see cref="Result{T}.Cancelled"/>.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class OpcuaDriver : IProtocolDriver
|
||||||
|
{
|
||||||
|
private const string Source = "OpcuaDriver";
|
||||||
|
|
||||||
|
private readonly IUaClient _client;
|
||||||
|
private readonly Guid _machineId;
|
||||||
|
private readonly IReadOnlyList<string> _nodeIds;
|
||||||
|
private readonly string _browseRoot;
|
||||||
|
private readonly TimeSpan _timeout;
|
||||||
|
|
||||||
|
// Selected data item ids to keep in ReadCurrentAsync snapshots. Empty => opt-in "monitor nothing".
|
||||||
|
private readonly HashSet<string> _monitoredItemIds;
|
||||||
|
|
||||||
|
/// <summary>Protocol identifier this driver serves.</summary>
|
||||||
|
public string ProtocolId => "opcua";
|
||||||
|
|
||||||
|
/// <param name="client">Transport seam; injected for testability, owned by the factory.</param>
|
||||||
|
/// <param name="machineId">Machine this driver reads for; stamped onto the snapshot.</param>
|
||||||
|
/// <param name="nodeIds">Configured node ids to read in <see cref="ReadCurrentAsync"/>.</param>
|
||||||
|
/// <param name="browseRoot">Address-space browse start node for <see cref="ProbeAsync"/>.</param>
|
||||||
|
/// <param name="timeout">Per-operation timeout enforced via a linked <see cref="CancellationTokenSource"/>.</param>
|
||||||
|
/// <param name="monitoredItemIds">
|
||||||
|
/// DataItem ids the user selected to monitor. <see cref="ReadCurrentAsync"/> keeps only these
|
||||||
|
/// (opt-in); empty/null => "monitor nothing".
|
||||||
|
/// </param>
|
||||||
|
public OpcuaDriver(
|
||||||
|
IUaClient client,
|
||||||
|
Guid machineId,
|
||||||
|
IReadOnlyList<string>? nodeIds,
|
||||||
|
string browseRoot,
|
||||||
|
TimeSpan timeout,
|
||||||
|
IReadOnlyCollection<string>? monitoredItemIds = null)
|
||||||
|
{
|
||||||
|
_client = client ?? throw new ArgumentNullException(nameof(client));
|
||||||
|
_machineId = machineId;
|
||||||
|
_nodeIds = nodeIds ?? Array.Empty<string>();
|
||||||
|
_browseRoot = browseRoot ?? "";
|
||||||
|
if (timeout <= TimeSpan.Zero && timeout != Timeout.InfiniteTimeSpan)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(timeout), timeout, "Timeout must be positive or Timeout.InfiniteTimeSpan.");
|
||||||
|
}
|
||||||
|
_timeout = timeout;
|
||||||
|
|
||||||
|
_monitoredItemIds = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
if (monitoredItemIds != null)
|
||||||
|
{
|
||||||
|
foreach (var id in monitoredItemIds)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(id))
|
||||||
|
{
|
||||||
|
_monitoredItemIds.Add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<Result<MachineSnapshot>> ReadCurrentAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return Result<MachineSnapshot>.Cancelled();
|
||||||
|
}
|
||||||
|
|
||||||
|
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
cts.CancelAfter(_timeout);
|
||||||
|
|
||||||
|
IReadOnlyList<UaReadResult> reads;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _client.ConnectAsync(cts.Token).ConfigureAwait(false);
|
||||||
|
reads = await _client.ReadAsync(_nodeIds, cts.Token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return Result<MachineSnapshot>.Cancelled();
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException ex)
|
||||||
|
{
|
||||||
|
return Fail("CURRENT_TIMEOUT", "OPC UA read timed out: " + ex.Message);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Fail("CURRENT_READ_ERROR", "OPC UA read failed: " + ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
var items = new List<DataItem>(reads.Count);
|
||||||
|
foreach (UaReadResult r in reads)
|
||||||
|
{
|
||||||
|
items.Add(new DataItem(r.NodeId, r.NodeId, r.Value, "VARIABLE", r.SourceTimestamp));
|
||||||
|
}
|
||||||
|
|
||||||
|
var snapshot = new MachineSnapshot(_machineId, DateTimeOffset.UtcNow, ConnectionState.Connected, items);
|
||||||
|
return Result<MachineSnapshot>.Ok(FilterToMonitored(snapshot));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<Result<IReadOnlyList<DataItemDescriptor>>> ProbeAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return Result<IReadOnlyList<DataItemDescriptor>>.Cancelled();
|
||||||
|
}
|
||||||
|
|
||||||
|
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
cts.CancelAfter(_timeout);
|
||||||
|
|
||||||
|
IReadOnlyList<DataItemDescriptor> descriptors;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _client.ConnectAsync(cts.Token).ConfigureAwait(false);
|
||||||
|
descriptors = await _client.BrowseAsync(_browseRoot, cts.Token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return Result<IReadOnlyList<DataItemDescriptor>>.Cancelled();
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException ex)
|
||||||
|
{
|
||||||
|
return ProbeFail("PROBE_TIMEOUT", "OPC UA browse timed out: " + ex.Message);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return ProbeFail("PROBE_BROWSE_ERROR", "OPC UA browse failed: " + ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result<IReadOnlyList<DataItemDescriptor>>.Ok(descriptors);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a copy of <paramref name="snapshot"/> keeping only items whose id is in the monitored
|
||||||
|
/// selection. Empty selection yields an empty item set (opt-in). Connection state and capture
|
||||||
|
/// instant are preserved. Exact semantics as MtconnectDriver.
|
||||||
|
/// </summary>
|
||||||
|
private MachineSnapshot FilterToMonitored(MachineSnapshot snapshot)
|
||||||
|
{
|
||||||
|
if (_monitoredItemIds.Count == 0)
|
||||||
|
{
|
||||||
|
return new MachineSnapshot(
|
||||||
|
snapshot.MachineId, snapshot.CapturedAt, snapshot.ConnectionState, Array.Empty<DataItem>());
|
||||||
|
}
|
||||||
|
|
||||||
|
var kept = new List<DataItem>();
|
||||||
|
for (int i = 0; i < snapshot.Items.Count; i++)
|
||||||
|
{
|
||||||
|
DataItem item = snapshot.Items[i];
|
||||||
|
if (_monitoredItemIds.Contains(item.Id))
|
||||||
|
{
|
||||||
|
kept.Add(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new MachineSnapshot(snapshot.MachineId, snapshot.CapturedAt, snapshot.ConnectionState, kept);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Result<MachineSnapshot> Fail(string code, string message) =>
|
||||||
|
Result<MachineSnapshot>.Fail(new OperationError(code, Source, message));
|
||||||
|
|
||||||
|
private static Result<IReadOnlyList<DataItemDescriptor>> ProbeFail(string code, string message) =>
|
||||||
|
Result<IReadOnlyList<DataItemDescriptor>>.Fail(new OperationError(code, Source, message));
|
||||||
|
}
|
||||||
|
}
|
||||||
220
src/Junction.Protocols.OpcUa/OpcuaDriverFactory.cs
Normal file
220
src/Junction.Protocols.OpcUa/OpcuaDriverFactory.cs
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Junction.Domain;
|
||||||
|
using Junction.Domain.Models;
|
||||||
|
using Junction.Domain.Protocols;
|
||||||
|
|
||||||
|
namespace Junction.Protocols.OpcUa
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Plugin entrypoint for the OPC UA protocol. Resolved by the Core plugin loader from
|
||||||
|
/// <c>plugin.manifest.json</c> via <see cref="Activator.CreateInstance(Type)"/>, so it MUST
|
||||||
|
/// keep a public parameterless constructor.
|
||||||
|
/// <para>
|
||||||
|
/// Owns OPC UA config validation, mirroring <c>MtconnectDriverFactory</c> (case-insensitive
|
||||||
|
/// lookup, CONFIG_* fail codes). Builds a real <see cref="UaClient"/> and injects it into the
|
||||||
|
/// per-machine <see cref="OpcuaDriver"/>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>Expected <see cref="Machine.ConnectionConfig"/> keys (case-insensitive):</para>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description><c>EndpointUrl</c> (required) — absolute opc.tcp:// URL of the server.</description></item>
|
||||||
|
/// <item><description><c>SecurityMode</c> (optional) — None|Sign|SignAndEncrypt, default None.</description></item>
|
||||||
|
/// <item><description><c>SecurityPolicy</c> (optional) — default None.</description></item>
|
||||||
|
/// <item><description><c>AuthMode</c> (optional) — Anonymous|UsernamePassword, default Anonymous.</description></item>
|
||||||
|
/// <item><description><c>Username</c>/<c>Password</c> — required iff AuthMode=UsernamePassword.</description></item>
|
||||||
|
/// <item><description><c>NodeIds</c> (optional CSV) — node ids read by ReadCurrentAsync.</description></item>
|
||||||
|
/// <item><description><c>BrowseRoot</c> (optional) — browse start node, default "i=85".</description></item>
|
||||||
|
/// <item><description><c>TimeoutSeconds</c> (optional) — positive int, default 10.</description></item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class OpcuaDriverFactory : IProtocolDriverFactory
|
||||||
|
{
|
||||||
|
private const string Source = "OpcuaDriverFactory";
|
||||||
|
private const string EndpointUrlKey = "EndpointUrl";
|
||||||
|
private const string SecurityModeKey = "SecurityMode";
|
||||||
|
private const string SecurityPolicyKey = "SecurityPolicy";
|
||||||
|
private const string AuthModeKey = "AuthMode";
|
||||||
|
private const string UsernameKey = "Username";
|
||||||
|
private const string PasswordKey = "Password";
|
||||||
|
private const string NodeIdsKey = "NodeIds";
|
||||||
|
private const string BrowseRootKey = "BrowseRoot";
|
||||||
|
private const string TimeoutKey = "TimeoutSeconds";
|
||||||
|
|
||||||
|
private const string DefaultBrowseRoot = "i=85";
|
||||||
|
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(10);
|
||||||
|
|
||||||
|
/// <summary>Protocol identifier this factory produces drivers for.</summary>
|
||||||
|
public string ProtocolId => "opcua";
|
||||||
|
|
||||||
|
/// <summary>Required by the plugin loader (Activator.CreateInstance).</summary>
|
||||||
|
public OpcuaDriverFactory()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Result<IProtocolDriver> Create(Machine machine)
|
||||||
|
{
|
||||||
|
if (machine is null)
|
||||||
|
{
|
||||||
|
return Fail("MACHINE_NULL", "Machine was null.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var config = machine.ConnectionConfig;
|
||||||
|
|
||||||
|
// EndpointUrl: required, absolute, opc.tcp scheme.
|
||||||
|
var endpointUrl = GetValue(config, EndpointUrlKey);
|
||||||
|
if (string.IsNullOrWhiteSpace(endpointUrl))
|
||||||
|
{
|
||||||
|
return Fail("CONFIG_ENDPOINTURL_MISSING",
|
||||||
|
"Required connection config key '" + EndpointUrlKey + "' is missing or empty.");
|
||||||
|
}
|
||||||
|
endpointUrl = endpointUrl!.Trim();
|
||||||
|
if (!Uri.TryCreate(endpointUrl, UriKind.Absolute, out var uri))
|
||||||
|
{
|
||||||
|
return Fail("CONFIG_ENDPOINTURL_INVALID",
|
||||||
|
"Connection config key '" + EndpointUrlKey + "' is not an absolute URI: '" + endpointUrl + "'.");
|
||||||
|
}
|
||||||
|
if (!string.Equals(uri.Scheme, "opc.tcp", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return Fail("CONFIG_ENDPOINTURL_SCHEME",
|
||||||
|
"Connection config key '" + EndpointUrlKey + "' must use the 'opc.tcp' scheme: '" + endpointUrl + "'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecurityMode: optional, one of None|Sign|SignAndEncrypt (default None).
|
||||||
|
var securityMode = GetValue(config, SecurityModeKey);
|
||||||
|
if (string.IsNullOrWhiteSpace(securityMode))
|
||||||
|
{
|
||||||
|
securityMode = "None";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
securityMode = securityMode!.Trim();
|
||||||
|
if (!IsOneOf(securityMode, "None", "Sign", "SignAndEncrypt"))
|
||||||
|
{
|
||||||
|
return Fail("CONFIG_SECURITYMODE_INVALID",
|
||||||
|
"Connection config key '" + SecurityModeKey + "' must be None, Sign or SignAndEncrypt: '" + securityMode + "'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecurityPolicy: optional, default None. Free-form (validated by the server on connect).
|
||||||
|
var securityPolicy = GetValue(config, SecurityPolicyKey);
|
||||||
|
if (string.IsNullOrWhiteSpace(securityPolicy))
|
||||||
|
{
|
||||||
|
securityPolicy = "None";
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthMode: optional, one of Anonymous|UsernamePassword (default Anonymous).
|
||||||
|
var authMode = GetValue(config, AuthModeKey);
|
||||||
|
if (string.IsNullOrWhiteSpace(authMode))
|
||||||
|
{
|
||||||
|
authMode = "Anonymous";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
authMode = authMode!.Trim();
|
||||||
|
if (!IsOneOf(authMode, "Anonymous", "UsernamePassword"))
|
||||||
|
{
|
||||||
|
return Fail("CONFIG_AUTHMODE_INVALID",
|
||||||
|
"Connection config key '" + AuthModeKey + "' must be Anonymous or UsernamePassword: '" + authMode + "'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
string? username = GetValue(config, UsernameKey);
|
||||||
|
string? password = GetValue(config, PasswordKey);
|
||||||
|
if (string.Equals(authMode, "UsernamePassword", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(username))
|
||||||
|
{
|
||||||
|
return Fail("CONFIG_USERNAME_MISSING",
|
||||||
|
"Connection config key '" + UsernameKey + "' is required when AuthMode=UsernamePassword.");
|
||||||
|
}
|
||||||
|
if (string.IsNullOrEmpty(password))
|
||||||
|
{
|
||||||
|
return Fail("CONFIG_PASSWORD_MISSING",
|
||||||
|
"Connection config key '" + PasswordKey + "' is required when AuthMode=UsernamePassword.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BrowseRoot: optional, default i=85.
|
||||||
|
var browseRoot = GetValue(config, BrowseRootKey);
|
||||||
|
if (string.IsNullOrWhiteSpace(browseRoot))
|
||||||
|
{
|
||||||
|
browseRoot = DefaultBrowseRoot;
|
||||||
|
}
|
||||||
|
browseRoot = browseRoot!.Trim();
|
||||||
|
|
||||||
|
// NodeIds: optional CSV of node ids to read.
|
||||||
|
var nodeIds = ParseCsv(GetValue(config, NodeIdsKey));
|
||||||
|
|
||||||
|
// TimeoutSeconds: optional positive int (default 10).
|
||||||
|
var timeout = DefaultTimeout;
|
||||||
|
var timeoutRaw = GetValue(config, TimeoutKey);
|
||||||
|
if (!string.IsNullOrWhiteSpace(timeoutRaw))
|
||||||
|
{
|
||||||
|
if (!int.TryParse(timeoutRaw, out var seconds) || seconds <= 0)
|
||||||
|
{
|
||||||
|
return Fail("CONFIG_TIMEOUT_INVALID",
|
||||||
|
"Connection config key '" + TimeoutKey + "' must be a positive integer (seconds): '" + timeoutRaw + "'.");
|
||||||
|
}
|
||||||
|
timeout = TimeSpan.FromSeconds(seconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
var client = new UaClient(endpointUrl, securityMode!, authMode!, username, password, timeout);
|
||||||
|
var driver = new OpcuaDriver(client, machine.Id, nodeIds, browseRoot, timeout, machine.MonitoredItemIds);
|
||||||
|
return Result<IProtocolDriver>.Ok(driver);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<string> ParseCsv(string? raw)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(raw))
|
||||||
|
{
|
||||||
|
return Array.Empty<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var parts = raw!.Split(',');
|
||||||
|
var list = new List<string>(parts.Length);
|
||||||
|
foreach (var p in parts)
|
||||||
|
{
|
||||||
|
var trimmed = p.Trim();
|
||||||
|
if (trimmed.Length > 0)
|
||||||
|
{
|
||||||
|
list.Add(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsOneOf(string value, params string[] allowed)
|
||||||
|
{
|
||||||
|
foreach (var a in allowed)
|
||||||
|
{
|
||||||
|
if (string.Equals(value, a, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? GetValue(IReadOnlyDictionary<string, string> config, string key)
|
||||||
|
{
|
||||||
|
if (config is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var pair in config)
|
||||||
|
{
|
||||||
|
if (string.Equals(pair.Key, key, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return pair.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Result<IProtocolDriver> Fail(string code, string message) =>
|
||||||
|
Result<IProtocolDriver>.Fail(new OperationError(code, Source, message));
|
||||||
|
}
|
||||||
|
}
|
||||||
332
src/Junction.Protocols.OpcUa/UaClient.cs
Normal file
332
src/Junction.Protocols.OpcUa/UaClient.cs
Normal file
|
|
@ -0,0 +1,332 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Junction.Domain.Protocols;
|
||||||
|
using Opc.Ua;
|
||||||
|
using Opc.Ua.Client;
|
||||||
|
using Opc.Ua.Configuration;
|
||||||
|
|
||||||
|
namespace Junction.Protocols.OpcUa
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Real <see cref="IUaClient"/> over the OPC Foundation .NET Standard SDK. Owns an
|
||||||
|
/// <see cref="ApplicationConfiguration"/> with a Directory PKI store under LocalApplicationData,
|
||||||
|
/// auto-generates the app-instance certificate, opens a <see cref="Session"/> and translates SDK
|
||||||
|
/// calls into the SDK-free DTOs the driver consumes.
|
||||||
|
/// <para>
|
||||||
|
/// This type is NOT unit-tested (it needs a live server); the driver is tested via a mocked
|
||||||
|
/// <see cref="IUaClient"/>. Exceptions raised here are caught by the driver and mapped to
|
||||||
|
/// <see cref="Junction.Domain.Result{T}"/> failures.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Uses the SDK's non-obsolete telemetry-threaded async surface throughout. The pinned SDK
|
||||||
|
/// (1.5.378.156) is mid-migration to an <c>ITelemetryContext</c>/DI-first API; a null telemetry
|
||||||
|
/// context is accepted and the SDK substitutes its default.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UaClient : IUaClient, IDisposable
|
||||||
|
{
|
||||||
|
private const string ApplicationName = "Junction";
|
||||||
|
|
||||||
|
private readonly string _endpointUrl;
|
||||||
|
private readonly string _securityMode; // None | Sign | SignAndEncrypt
|
||||||
|
private readonly string _authMode; // Anonymous | UsernamePassword
|
||||||
|
private readonly string? _username;
|
||||||
|
private readonly string? _password;
|
||||||
|
private readonly TimeSpan _timeout;
|
||||||
|
|
||||||
|
// Intentionally null: the SDK substitutes its default telemetry context. Typed non-nullable so
|
||||||
|
// the non-obsolete telemetry-threaded async overloads bind without nullable-analysis noise.
|
||||||
|
private readonly ITelemetryContext _telemetry = null!;
|
||||||
|
|
||||||
|
private ApplicationConfiguration? _appConfig;
|
||||||
|
private ISession? _session;
|
||||||
|
|
||||||
|
public UaClient(
|
||||||
|
string endpointUrl,
|
||||||
|
string securityMode,
|
||||||
|
string authMode,
|
||||||
|
string? username,
|
||||||
|
string? password,
|
||||||
|
TimeSpan timeout)
|
||||||
|
{
|
||||||
|
_endpointUrl = endpointUrl ?? throw new ArgumentNullException(nameof(endpointUrl));
|
||||||
|
_securityMode = string.IsNullOrWhiteSpace(securityMode) ? "None" : securityMode;
|
||||||
|
_authMode = string.IsNullOrWhiteSpace(authMode) ? "Anonymous" : authMode;
|
||||||
|
_username = username;
|
||||||
|
_password = password;
|
||||||
|
_timeout = timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ConnectAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (_session != null && _session.Connected)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplicationConfiguration config = await BuildConfigurationAsync(ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
bool useSecurity = !string.Equals(_securityMode, "None", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
// Discover + pick the server endpoint matching the requested security.
|
||||||
|
EndpointDescription selected =
|
||||||
|
(await CoreClientUtils.SelectEndpointAsync(config, _endpointUrl, useSecurity, _telemetry, ct).ConfigureAwait(false))!;
|
||||||
|
var endpointConfiguration = EndpointConfiguration.Create(config);
|
||||||
|
var endpoint = new ConfiguredEndpoint(null, selected, endpointConfiguration);
|
||||||
|
|
||||||
|
IUserIdentity identity = BuildIdentity();
|
||||||
|
|
||||||
|
var factory = new DefaultSessionFactory(_telemetry);
|
||||||
|
_session = await factory.CreateAsync(
|
||||||
|
config,
|
||||||
|
endpoint,
|
||||||
|
updateBeforeConnect: false,
|
||||||
|
checkDomain: false,
|
||||||
|
sessionName: ApplicationName,
|
||||||
|
sessionTimeout: (uint)Math.Max(1000, _timeout.TotalMilliseconds),
|
||||||
|
identity: identity,
|
||||||
|
preferredLocales: null,
|
||||||
|
ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<UaReadResult>> ReadAsync(IReadOnlyList<string> nodeIds, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await ConnectAsync(ct).ConfigureAwait(false);
|
||||||
|
ISession session = _session ?? throw new InvalidOperationException("OPC UA session not established.");
|
||||||
|
|
||||||
|
var results = new List<UaReadResult>(nodeIds.Count);
|
||||||
|
if (nodeIds.Count == 0)
|
||||||
|
{
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
var toRead = new ReadValueIdCollection();
|
||||||
|
foreach (var id in nodeIds)
|
||||||
|
{
|
||||||
|
toRead.Add(new ReadValueId { NodeId = new NodeId(id), AttributeId = Attributes.Value });
|
||||||
|
}
|
||||||
|
|
||||||
|
ReadResponse response =
|
||||||
|
await session.ReadAsync(null, 0, TimestampsToReturn.Both, toRead, ct).ConfigureAwait(false);
|
||||||
|
DataValueCollection values = response.Results;
|
||||||
|
|
||||||
|
for (int i = 0; i < nodeIds.Count; i++)
|
||||||
|
{
|
||||||
|
DataValue dv = (values != null && i < values.Count) ? values[i] : new DataValue(StatusCodes.BadUnexpectedError);
|
||||||
|
bool ok = StatusCode.IsGood(dv.StatusCode);
|
||||||
|
string value = dv.Value != null ? dv.Value.ToString() ?? "" : "";
|
||||||
|
DateTimeOffset ts = dv.SourceTimestamp == DateTime.MinValue
|
||||||
|
? DateTimeOffset.UtcNow
|
||||||
|
: new DateTimeOffset(DateTime.SpecifyKind(dv.SourceTimestamp, DateTimeKind.Utc));
|
||||||
|
results.Add(new UaReadResult(nodeIds[i], value, ok, ts));
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<DataItemDescriptor>> BrowseAsync(string browseRoot, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await ConnectAsync(ct).ConfigureAwait(false);
|
||||||
|
ISession session = _session ?? throw new InvalidOperationException("OPC UA session not established.");
|
||||||
|
|
||||||
|
NodeId root = string.IsNullOrWhiteSpace(browseRoot) ? ObjectIds.ObjectsFolder : new NodeId(browseRoot);
|
||||||
|
var descriptors = new List<DataItemDescriptor>();
|
||||||
|
var visited = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
await BrowseRecursiveAsync(session, root, descriptors, visited, ct).ConfigureAwait(false);
|
||||||
|
return descriptors;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task BrowseRecursiveAsync(
|
||||||
|
ISession session,
|
||||||
|
NodeId node,
|
||||||
|
List<DataItemDescriptor> descriptors,
|
||||||
|
HashSet<string> visited,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
if (!visited.Add(node.ToString()))
|
||||||
|
{
|
||||||
|
return; // guard against cycles
|
||||||
|
}
|
||||||
|
|
||||||
|
var nodeToBrowse = new BrowseDescription
|
||||||
|
{
|
||||||
|
NodeId = node,
|
||||||
|
BrowseDirection = BrowseDirection.Forward,
|
||||||
|
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
|
||||||
|
IncludeSubtypes = true,
|
||||||
|
NodeClassMask = (uint)(NodeClass.Object | NodeClass.Variable),
|
||||||
|
ResultMask = (uint)BrowseResultMask.All,
|
||||||
|
};
|
||||||
|
|
||||||
|
BrowseResponse response = await session.BrowseAsync(
|
||||||
|
null, null, 0u, new BrowseDescriptionCollection { nodeToBrowse }, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (response.Results == null || response.Results.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
BrowseResult result = response.Results[0];
|
||||||
|
var references = new ReferenceDescriptionCollection();
|
||||||
|
if (result.References != null)
|
||||||
|
{
|
||||||
|
references.AddRange(result.References);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Follow continuation points to enumerate the full child set.
|
||||||
|
byte[] continuation = result.ContinuationPoint;
|
||||||
|
while (continuation != null && continuation.Length > 0)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
BrowseNextResponse next = await session.BrowseNextAsync(
|
||||||
|
null, false, new ByteStringCollection { continuation }, ct).ConfigureAwait(false);
|
||||||
|
if (next.Results == null || next.Results.Count == 0)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
BrowseResult nr = next.Results[0];
|
||||||
|
if (nr.References != null)
|
||||||
|
{
|
||||||
|
references.AddRange(nr.References);
|
||||||
|
}
|
||||||
|
continuation = nr.ContinuationPoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (ReferenceDescription r in references)
|
||||||
|
{
|
||||||
|
NodeId? childId = ExpandedNodeId.ToNodeId(r.NodeId, session.NamespaceUris);
|
||||||
|
if (childId == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (r.NodeClass == NodeClass.Variable)
|
||||||
|
{
|
||||||
|
string displayName = r.DisplayName != null ? r.DisplayName.Text : "";
|
||||||
|
string dataType = await ReadDataTypeNameAsync(session, childId, ct).ConfigureAwait(false);
|
||||||
|
descriptors.Add(new DataItemDescriptor(childId.ToString(), displayName, dataType, "VARIABLE", ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recurse into both objects and variables (variables can have variable children).
|
||||||
|
await BrowseRecursiveAsync(session, childId, descriptors, visited, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reads a variable's DataType attribute and maps it to a built-in type name; "" on failure.</summary>
|
||||||
|
private static async Task<string> ReadDataTypeNameAsync(ISession session, NodeId variable, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var toRead = new ReadValueIdCollection
|
||||||
|
{
|
||||||
|
new ReadValueId { NodeId = variable, AttributeId = Attributes.DataType },
|
||||||
|
};
|
||||||
|
ReadResponse response =
|
||||||
|
await session.ReadAsync(null, 0, TimestampsToReturn.Neither, toRead, ct).ConfigureAwait(false);
|
||||||
|
DataValueCollection values = response.Results;
|
||||||
|
if (values != null && values.Count > 0 && StatusCode.IsGood(values[0].StatusCode) && values[0].Value is NodeId dtId)
|
||||||
|
{
|
||||||
|
BuiltInType bt = Opc.Ua.TypeInfo.GetBuiltInType(dtId);
|
||||||
|
return bt != BuiltInType.Null ? bt.ToString() : dtId.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Best-effort enrichment; the descriptor is still valid without a data type.
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private IUserIdentity BuildIdentity()
|
||||||
|
{
|
||||||
|
if (string.Equals(_authMode, "UsernamePassword", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return new UserIdentity(_username ?? "", System.Text.Encoding.UTF8.GetBytes(_password ?? ""));
|
||||||
|
}
|
||||||
|
return new UserIdentity();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ApplicationConfiguration> BuildConfigurationAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (_appConfig != null)
|
||||||
|
{
|
||||||
|
return _appConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
string pkiRoot = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
|
ApplicationName,
|
||||||
|
"pki");
|
||||||
|
string own = Path.Combine(pkiRoot, "own");
|
||||||
|
string trusted = Path.Combine(pkiRoot, "trusted");
|
||||||
|
string issuer = Path.Combine(pkiRoot, "issuer");
|
||||||
|
string rejected = Path.Combine(pkiRoot, "rejected");
|
||||||
|
Directory.CreateDirectory(own);
|
||||||
|
Directory.CreateDirectory(trusted);
|
||||||
|
Directory.CreateDirectory(issuer);
|
||||||
|
Directory.CreateDirectory(rejected);
|
||||||
|
|
||||||
|
bool secure = !string.Equals(_securityMode, "None", StringComparison.OrdinalIgnoreCase);
|
||||||
|
int operationTimeoutMs = (int)Math.Max(1000, _timeout.TotalMilliseconds);
|
||||||
|
|
||||||
|
var config = new ApplicationConfiguration
|
||||||
|
{
|
||||||
|
ApplicationName = ApplicationName,
|
||||||
|
ApplicationUri = "urn:localhost:" + ApplicationName,
|
||||||
|
ApplicationType = ApplicationType.Client,
|
||||||
|
SecurityConfiguration = new SecurityConfiguration
|
||||||
|
{
|
||||||
|
ApplicationCertificate = new CertificateIdentifier
|
||||||
|
{
|
||||||
|
StoreType = CertificateStoreType.Directory,
|
||||||
|
StorePath = own,
|
||||||
|
SubjectName = "CN=" + ApplicationName + ", O=Junction",
|
||||||
|
},
|
||||||
|
TrustedPeerCertificates = new CertificateTrustList
|
||||||
|
{
|
||||||
|
StoreType = CertificateStoreType.Directory,
|
||||||
|
StorePath = trusted,
|
||||||
|
},
|
||||||
|
TrustedIssuerCertificates = new CertificateTrustList
|
||||||
|
{
|
||||||
|
StoreType = CertificateStoreType.Directory,
|
||||||
|
StorePath = issuer,
|
||||||
|
},
|
||||||
|
RejectedCertificateStore = new CertificateStoreIdentifier
|
||||||
|
{
|
||||||
|
StoreType = CertificateStoreType.Directory,
|
||||||
|
StorePath = rejected,
|
||||||
|
},
|
||||||
|
// SecurityMode=None has no peer trust to establish, so accept untrusted server certs.
|
||||||
|
AutoAcceptUntrustedCertificates = !secure,
|
||||||
|
AddAppCertToTrustedStore = true,
|
||||||
|
},
|
||||||
|
TransportConfigurations = new TransportConfigurationCollection(),
|
||||||
|
TransportQuotas = new TransportQuotas { OperationTimeout = operationTimeoutMs },
|
||||||
|
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 },
|
||||||
|
TraceConfiguration = new TraceConfiguration(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await config.ValidateAsync(ApplicationType.Client, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var appInstance = new ApplicationInstance(config, _telemetry);
|
||||||
|
// Auto-generate the app-instance certificate if absent (silent, default lifetime).
|
||||||
|
await appInstance.CheckApplicationInstanceCertificatesAsync(true, null, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
_appConfig = config;
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
// Session : IDisposable — disposing closes the channel/session.
|
||||||
|
_session?.Dispose();
|
||||||
|
_session = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/Junction.Protocols.OpcUa/UaReadResult.cs
Normal file
31
src/Junction.Protocols.OpcUa/UaReadResult.cs
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace Junction.Protocols.OpcUa
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Protocol-agnostic result of reading a single OPC UA node's Value attribute. Keeps SDK types
|
||||||
|
/// out of <see cref="IUaClient"/> so the driver is testable without a live server.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UaReadResult
|
||||||
|
{
|
||||||
|
/// <summary>The node id that was read (as configured, e.g. "ns=2;s=Temperature").</summary>
|
||||||
|
public string NodeId { get; }
|
||||||
|
|
||||||
|
/// <summary>Value rendered as string to stay type-agnostic; empty when null/unreadable.</summary>
|
||||||
|
public string Value { get; }
|
||||||
|
|
||||||
|
/// <summary>True when the read StatusCode was Good.</summary>
|
||||||
|
public bool StatusOk { get; }
|
||||||
|
|
||||||
|
/// <summary>Source timestamp reported by the server for the value.</summary>
|
||||||
|
public DateTimeOffset SourceTimestamp { get; }
|
||||||
|
|
||||||
|
public UaReadResult(string nodeId, string value, bool statusOk, DateTimeOffset sourceTimestamp)
|
||||||
|
{
|
||||||
|
NodeId = nodeId ?? "";
|
||||||
|
Value = value ?? "";
|
||||||
|
StatusOk = statusOk;
|
||||||
|
SourceTimestamp = sourceTimestamp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
7
src/Junction.Protocols.OpcUa/plugin.manifest.json
Normal file
7
src/Junction.Protocols.OpcUa/plugin.manifest.json
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"protocolId": "opcua",
|
||||||
|
"displayName": "OPC UA",
|
||||||
|
"assemblyFile": "Junction.Protocols.OpcUa.dll",
|
||||||
|
"entryTypeName": "Junction.Protocols.OpcUa.OpcuaDriverFactory",
|
||||||
|
"apiVersion": "1.0"
|
||||||
|
}
|
||||||
|
|
@ -26,6 +26,9 @@
|
||||||
<ProjectReference Include="..\..\src\Junction.Core\Junction.Core.csproj" />
|
<ProjectReference Include="..\..\src\Junction.Core\Junction.Core.csproj" />
|
||||||
<ProjectReference Include="..\..\src\Junction.Persistence\Junction.Persistence.csproj" />
|
<ProjectReference Include="..\..\src\Junction.Persistence\Junction.Persistence.csproj" />
|
||||||
<ProjectReference Include="..\..\src\Junction.Protocols.MTConnect\Junction.Protocols.MTConnect.csproj" />
|
<ProjectReference Include="..\..\src\Junction.Protocols.MTConnect\Junction.Protocols.MTConnect.csproj" />
|
||||||
|
<ProjectReference Include="..\..\src\Junction.Protocols.OpcUa\Junction.Protocols.OpcUa.csproj" />
|
||||||
|
<!-- App reference resolves its net8.0 target (tests are net8.0-only); covers view-layer converters. -->
|
||||||
|
<ProjectReference Include="..\..\src\Junction.App\Junction.App.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
|
||||||
39
tests/Junction.Tests/Unit/DataItemRowViewModelTests.cs
Normal file
39
tests/Junction.Tests/Unit/DataItemRowViewModelTests.cs
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
using Junction.App.ViewModels;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Junction.Tests.Unit
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Data-level tests for the culture-invariant numeric parse that decides whether a datum can
|
||||||
|
/// feed the trend sparkline. Pure static helper — no Avalonia platform required.
|
||||||
|
/// </summary>
|
||||||
|
public class DataItemRowViewModelTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("12.5", 12.5)]
|
||||||
|
[InlineData(" 3 ", 3)]
|
||||||
|
[InlineData("1000", 1000)]
|
||||||
|
[InlineData("-4.25", -4.25)]
|
||||||
|
public void TryParseNumeric_Numeric_ReturnsTrueWithInvariantValue(string input, double expected)
|
||||||
|
{
|
||||||
|
var ok = DataItemRowViewModel.TryParseNumeric(input, out var result);
|
||||||
|
|
||||||
|
Assert.True(ok);
|
||||||
|
Assert.Equal(expected, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData(" ")]
|
||||||
|
[InlineData("abc")]
|
||||||
|
[InlineData("ON")]
|
||||||
|
[InlineData("AVAILABLE")]
|
||||||
|
[InlineData(null)]
|
||||||
|
public void TryParseNumeric_NonNumeric_ReturnsFalse(string? input)
|
||||||
|
{
|
||||||
|
var ok = DataItemRowViewModel.TryParseNumeric(input, out _);
|
||||||
|
|
||||||
|
Assert.False(ok);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
165
tests/Junction.Tests/Unit/MachineDetailViewModelTests.cs
Normal file
165
tests/Junction.Tests/Unit/MachineDetailViewModelTests.cs
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Junction.App.ViewModels;
|
||||||
|
using Junction.Core.Monitoring;
|
||||||
|
using Junction.Domain;
|
||||||
|
using Junction.Domain.Models;
|
||||||
|
using Junction.Domain.Persistence;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Moq;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Junction.Tests.Unit
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Trend-load behaviour of the detail VM: after a load, each row whose history parses as numeric
|
||||||
|
/// gets a populated <see cref="DataItemRowViewModel.Trend"/> (ascending order) with
|
||||||
|
/// <c>HasTrend</c> true; a row with non-numeric history stays null / <c>HasTrend</c> false.
|
||||||
|
/// Fixture: Moq'd <see cref="IMachineRepository"/> + <see cref="IMachineMonitor"/> + null logger.
|
||||||
|
/// The fire-and-forget trend load is awaited via the VM's internal <c>TrendLoadTask</c>.
|
||||||
|
/// </summary>
|
||||||
|
public class MachineDetailViewModelTests
|
||||||
|
{
|
||||||
|
private const string NumericItemId = "temp";
|
||||||
|
private const string TextItemId = "avail";
|
||||||
|
|
||||||
|
private static readonly Guid MachineId = Guid.NewGuid();
|
||||||
|
|
||||||
|
private static MachineSnapshot SnapshotWithBothItems()
|
||||||
|
{
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var items = new List<DataItem>
|
||||||
|
{
|
||||||
|
new DataItem(NumericItemId, "Temperature", "12", "Sample", now),
|
||||||
|
new DataItem(TextItemId, "Availability", "AVAILABLE", "Event", now),
|
||||||
|
};
|
||||||
|
return new MachineSnapshot(MachineId, now, ConnectionState.Connected, items);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<HistoryPoint> NumericHistory()
|
||||||
|
{
|
||||||
|
var t = DateTimeOffset.UtcNow;
|
||||||
|
return new List<HistoryPoint>
|
||||||
|
{
|
||||||
|
new HistoryPoint(NumericItemId, "10", t.AddSeconds(-30)),
|
||||||
|
new HistoryPoint(NumericItemId, "12.5", t.AddSeconds(-20)),
|
||||||
|
new HistoryPoint(NumericItemId, "11", t.AddSeconds(-10)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<HistoryPoint> TextHistory()
|
||||||
|
{
|
||||||
|
var t = DateTimeOffset.UtcNow;
|
||||||
|
return new List<HistoryPoint>
|
||||||
|
{
|
||||||
|
new HistoryPoint(TextItemId, "AVAILABLE", t.AddSeconds(-20)),
|
||||||
|
new HistoryPoint(TextItemId, "UNAVAILABLE", t.AddSeconds(-10)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MachineDetailViewModel BuildVm(
|
||||||
|
Mock<IMachineRepository> repo,
|
||||||
|
Mock<IMachineMonitor> monitor)
|
||||||
|
{
|
||||||
|
var vm = new MachineDetailViewModel(
|
||||||
|
repo.Object, monitor.Object, NullLogger<MachineDetailViewModel>.Instance);
|
||||||
|
vm.Initialize(MachineId, "VMC Sim (mock)");
|
||||||
|
return vm;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Mock<IMachineMonitor> MonitorWithSnapshot(MachineSnapshot snapshot)
|
||||||
|
{
|
||||||
|
var monitor = new Mock<IMachineMonitor>();
|
||||||
|
var cache = new Dictionary<Guid, MachineSnapshot> { [MachineId] = snapshot };
|
||||||
|
monitor.SetupGet(m => m.LatestSnapshots)
|
||||||
|
.Returns((IReadOnlyDictionary<Guid, MachineSnapshot>)cache);
|
||||||
|
return monitor;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DataItemRowViewModel Row(MachineDetailViewModel vm, string id) =>
|
||||||
|
vm.Items.First(r => r.Id == id);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task LoadAsync_NumericHistory_PopulatesTrendAscending()
|
||||||
|
{
|
||||||
|
var snapshot = SnapshotWithBothItems();
|
||||||
|
var repo = new Mock<IMachineRepository>();
|
||||||
|
repo.Setup(r => r.GetByIdAsync(MachineId, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(Result<Machine>.Ok(new Machine(MachineId, "VMC", "mtconnect", null, TimeSpan.FromSeconds(2))));
|
||||||
|
repo.Setup(r => r.GetHistoryAsync(MachineId, NumericItemId, It.IsAny<DateTimeOffset>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(Result<IReadOnlyList<HistoryPoint>>.Ok(NumericHistory()));
|
||||||
|
repo.Setup(r => r.GetHistoryAsync(MachineId, TextItemId, It.IsAny<DateTimeOffset>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(Result<IReadOnlyList<HistoryPoint>>.Ok(TextHistory()));
|
||||||
|
|
||||||
|
var monitor = MonitorWithSnapshot(snapshot);
|
||||||
|
var vm = BuildVm(repo, monitor);
|
||||||
|
|
||||||
|
await vm.LoadAsync();
|
||||||
|
if (vm.TrendLoadTask != null)
|
||||||
|
{
|
||||||
|
await vm.TrendLoadTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
var numericRow = Row(vm, NumericItemId);
|
||||||
|
Assert.True(numericRow.HasTrend);
|
||||||
|
Assert.NotNull(numericRow.Trend);
|
||||||
|
Assert.Equal(new double[] { 10, 12.5, 11 }, numericRow.Trend!.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task LoadAsync_NonNumericHistory_LeavesTrendNull()
|
||||||
|
{
|
||||||
|
var snapshot = SnapshotWithBothItems();
|
||||||
|
var repo = new Mock<IMachineRepository>();
|
||||||
|
repo.Setup(r => r.GetByIdAsync(MachineId, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(Result<Machine>.Ok(new Machine(MachineId, "VMC", "mtconnect", null, TimeSpan.FromSeconds(2))));
|
||||||
|
repo.Setup(r => r.GetHistoryAsync(MachineId, NumericItemId, It.IsAny<DateTimeOffset>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(Result<IReadOnlyList<HistoryPoint>>.Ok(NumericHistory()));
|
||||||
|
repo.Setup(r => r.GetHistoryAsync(MachineId, TextItemId, It.IsAny<DateTimeOffset>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(Result<IReadOnlyList<HistoryPoint>>.Ok(TextHistory()));
|
||||||
|
|
||||||
|
var monitor = MonitorWithSnapshot(snapshot);
|
||||||
|
var vm = BuildVm(repo, monitor);
|
||||||
|
|
||||||
|
await vm.LoadAsync();
|
||||||
|
if (vm.TrendLoadTask != null)
|
||||||
|
{
|
||||||
|
await vm.TrendLoadTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
var textRow = Row(vm, TextItemId);
|
||||||
|
Assert.False(textRow.HasTrend);
|
||||||
|
Assert.Null(textRow.Trend);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task LoadAsync_HistoryFailureForOneItem_DoesNotThrowOrBlockOthers()
|
||||||
|
{
|
||||||
|
var snapshot = SnapshotWithBothItems();
|
||||||
|
var repo = new Mock<IMachineRepository>();
|
||||||
|
repo.Setup(r => r.GetByIdAsync(MachineId, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(Result<Machine>.Ok(new Machine(MachineId, "VMC", "mtconnect", null, TimeSpan.FromSeconds(2))));
|
||||||
|
// Numeric item succeeds; text item fails hard.
|
||||||
|
repo.Setup(r => r.GetHistoryAsync(MachineId, NumericItemId, It.IsAny<DateTimeOffset>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(Result<IReadOnlyList<HistoryPoint>>.Ok(NumericHistory()));
|
||||||
|
repo.Setup(r => r.GetHistoryAsync(MachineId, TextItemId, It.IsAny<DateTimeOffset>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ThrowsAsync(new InvalidOperationException("boom"));
|
||||||
|
|
||||||
|
var monitor = MonitorWithSnapshot(snapshot);
|
||||||
|
var vm = BuildVm(repo, monitor);
|
||||||
|
|
||||||
|
await vm.LoadAsync();
|
||||||
|
if (vm.TrendLoadTask != null)
|
||||||
|
{
|
||||||
|
await vm.TrendLoadTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The failing item leaves the successful item's trend intact.
|
||||||
|
Assert.True(Row(vm, NumericItemId).HasTrend);
|
||||||
|
Assert.False(Row(vm, TextItemId).HasTrend);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -60,9 +60,19 @@ namespace Junction.Tests.Unit
|
||||||
.ReturnsAsync(Result<IReadOnlyList<Machine>>.Ok(machines));
|
.ReturnsAsync(Result<IReadOnlyList<Machine>>.Ok(machines));
|
||||||
mock.Setup(r => r.SaveSnapshotAsync(It.IsAny<MachineSnapshot>(), It.IsAny<CancellationToken>()))
|
mock.Setup(r => r.SaveSnapshotAsync(It.IsAny<MachineSnapshot>(), It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(Result.Ok());
|
.ReturnsAsync(Result.Ok());
|
||||||
|
mock.Setup(r => r.AppendHistoryAsync(It.IsAny<MachineSnapshot>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(Result.Ok());
|
||||||
|
mock.Setup(r => r.PruneHistoryAsync(It.IsAny<DateTimeOffset>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(Result.Ok());
|
||||||
return mock;
|
return mock;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static MachineSnapshot SnapshotWithItems(Guid machineId) =>
|
||||||
|
new MachineSnapshot(machineId, DateTimeOffset.UtcNow, ConnectionState.Connected, new[]
|
||||||
|
{
|
||||||
|
new DataItem("x1_pos", "X", "12.5", "SAMPLE", DateTimeOffset.UtcNow),
|
||||||
|
});
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task StartAsync_PollsMachine_RaisesEvent_UpdatesCache_Persists()
|
public async Task StartAsync_PollsMachine_RaisesEvent_UpdatesCache_Persists()
|
||||||
{
|
{
|
||||||
|
|
@ -95,6 +105,114 @@ namespace Junction.Tests.Unit
|
||||||
It.IsAny<CancellationToken>()), Times.AtLeastOnce);
|
It.IsAny<CancellationToken>()), Times.AtLeastOnce);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ConnectedSnapshotWithItems_AppendsHistory_AndStillSavesLatest()
|
||||||
|
{
|
||||||
|
var machine = MachineWith("test");
|
||||||
|
var factory = new FakeFactory("test", m => Result<IProtocolDriver>.Ok(
|
||||||
|
new FakeDriver("test", () => Result<MachineSnapshot>.Ok(SnapshotWithItems(machine.Id)))));
|
||||||
|
|
||||||
|
var loader = LoaderReturning(factory);
|
||||||
|
var repo = RepoReturning(machine);
|
||||||
|
|
||||||
|
var appended = new ManualResetEventSlim(false);
|
||||||
|
repo.Setup(r => r.AppendHistoryAsync(
|
||||||
|
It.Is<MachineSnapshot>(s => s.MachineId == machine.Id && s.Items.Count > 0),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(Result.Ok())
|
||||||
|
.Callback(() => appended.Set());
|
||||||
|
|
||||||
|
var monitor = NewMonitor(repo.Object, loader.Object);
|
||||||
|
|
||||||
|
await monitor.StartAsync(PluginsDir, CancellationToken.None);
|
||||||
|
Assert.True(appended.Wait(TimeSpan.FromSeconds(2)), "AppendHistoryAsync was not invoked");
|
||||||
|
await monitor.StopAsync();
|
||||||
|
|
||||||
|
// History appended AND latest-snapshot save still happens (existing contract intact).
|
||||||
|
repo.Verify(r => r.AppendHistoryAsync(
|
||||||
|
It.Is<MachineSnapshot>(s => s.MachineId == machine.Id),
|
||||||
|
It.IsAny<CancellationToken>()), Times.AtLeastOnce);
|
||||||
|
repo.Verify(r => r.SaveSnapshotAsync(
|
||||||
|
It.Is<MachineSnapshot>(s => s.MachineId == machine.Id),
|
||||||
|
It.IsAny<CancellationToken>()), Times.AtLeastOnce);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HistoryAppendFailure_DoesNotThrow_NorStopLatestSave()
|
||||||
|
{
|
||||||
|
var machine = MachineWith("test");
|
||||||
|
var factory = new FakeFactory("test", m => Result<IProtocolDriver>.Ok(
|
||||||
|
new FakeDriver("test", () => Result<MachineSnapshot>.Ok(SnapshotWithItems(machine.Id)))));
|
||||||
|
|
||||||
|
var loader = LoaderReturning(factory);
|
||||||
|
var repo = RepoReturning(machine);
|
||||||
|
|
||||||
|
// Append both fails AND throws on alternating calls to exercise both paths.
|
||||||
|
repo.Setup(r => r.AppendHistoryAsync(It.IsAny<MachineSnapshot>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ThrowsAsync(new InvalidOperationException("history store boom"));
|
||||||
|
|
||||||
|
var monitor = NewMonitor(repo.Object, loader.Object);
|
||||||
|
|
||||||
|
var eventRaised = new ManualResetEventSlim(false);
|
||||||
|
monitor.SnapshotUpdated += (_, snap) =>
|
||||||
|
{
|
||||||
|
if (snap.MachineId == machine.Id) eventRaised.Set();
|
||||||
|
};
|
||||||
|
|
||||||
|
var start = await monitor.StartAsync(PluginsDir, CancellationToken.None);
|
||||||
|
Assert.True(start.IsSuccess);
|
||||||
|
|
||||||
|
// Monitor keeps running: events keep flowing and latest-save keeps being called.
|
||||||
|
Assert.True(eventRaised.Wait(TimeSpan.FromSeconds(2)), "monitor stalled after history failure");
|
||||||
|
await Task.Delay(120);
|
||||||
|
await monitor.StopAsync();
|
||||||
|
|
||||||
|
repo.Verify(r => r.SaveSnapshotAsync(
|
||||||
|
It.Is<MachineSnapshot>(s => s.MachineId == machine.Id),
|
||||||
|
It.IsAny<CancellationToken>()), Times.AtLeastOnce);
|
||||||
|
Assert.True(monitor.LatestSnapshots.ContainsKey(machine.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PruneHistory_Throttled_AtMostOncePerInterval_AcrossManySnapshots()
|
||||||
|
{
|
||||||
|
// PruneInterval is 1h; many rapid snapshots must trigger at most one prune.
|
||||||
|
var machine = MachineWith("test");
|
||||||
|
var factory = new FakeFactory("test", m => Result<IProtocolDriver>.Ok(
|
||||||
|
new FakeDriver("test", () => Result<MachineSnapshot>.Ok(SnapshotWithItems(machine.Id)))));
|
||||||
|
|
||||||
|
var loader = LoaderReturning(factory);
|
||||||
|
var repo = RepoReturning(machine);
|
||||||
|
|
||||||
|
int appendCount = 0;
|
||||||
|
int pruneCount = 0;
|
||||||
|
repo.Setup(r => r.AppendHistoryAsync(It.IsAny<MachineSnapshot>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(Result.Ok())
|
||||||
|
.Callback(() => Interlocked.Increment(ref appendCount));
|
||||||
|
repo.Setup(r => r.PruneHistoryAsync(It.IsAny<DateTimeOffset>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(Result.Ok())
|
||||||
|
.Callback(() => Interlocked.Increment(ref pruneCount));
|
||||||
|
|
||||||
|
var monitor = NewMonitor(repo.Object, loader.Object);
|
||||||
|
|
||||||
|
await monitor.StartAsync(PluginsDir, CancellationToken.None);
|
||||||
|
|
||||||
|
// Let many poll cycles run (FastPoll = 25ms => dozens of appends).
|
||||||
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||||
|
while (Volatile.Read(ref appendCount) < 5 && sw.Elapsed < TimeSpan.FromSeconds(2))
|
||||||
|
{
|
||||||
|
await Task.Delay(20);
|
||||||
|
}
|
||||||
|
await Task.Delay(150);
|
||||||
|
await monitor.StopAsync();
|
||||||
|
|
||||||
|
Assert.True(Volatile.Read(ref appendCount) >= 5, "expected many history appends across poll cycles");
|
||||||
|
// Throttle proof: append fired many times, prune fired at most once in the window.
|
||||||
|
repo.Verify(r => r.PruneHistoryAsync(It.IsAny<DateTimeOffset>(), It.IsAny<CancellationToken>()), Times.AtMostOnce);
|
||||||
|
// And it did fire on the first persist (MinValue-init guarantees the first call prunes).
|
||||||
|
Assert.Equal(1, Volatile.Read(ref pruneCount));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task StartAsync_UnknownProtocol_SkipsMachine_OthersRun_StillOk()
|
public async Task StartAsync_UnknownProtocol_SkipsMachine_OthersRun_StillOk()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
176
tests/Junction.Tests/Unit/OpcuaDriverFactoryTests.cs
Normal file
176
tests/Junction.Tests/Unit/OpcuaDriverFactoryTests.cs
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Junction.Domain.Models;
|
||||||
|
using Junction.Protocols.OpcUa;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Junction.Tests.Unit
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Config-validation tests for <see cref="OpcuaDriverFactory"/>, mirroring
|
||||||
|
/// MtconnectDriverFactory tests: required/absolute/scheme checks, enum checks, the
|
||||||
|
/// UsernamePassword credential rule and case-insensitive key lookup.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class OpcuaDriverFactoryTests
|
||||||
|
{
|
||||||
|
private static readonly Guid MachineId = Guid.Parse("99999999-8888-7777-6666-555555555555");
|
||||||
|
private const string EndpointUrl = "opc.tcp://opcua-server.test:4840";
|
||||||
|
|
||||||
|
private static Machine MachineWithConfig(IReadOnlyDictionary<string, string>? config) =>
|
||||||
|
new Machine(MachineId, "PLC-01", "opcua", config, TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ProtocolId_IsOpcua()
|
||||||
|
{
|
||||||
|
Assert.Equal("opcua", new OpcuaDriverFactory().ProtocolId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MissingEndpointUrl_ReturnsFail()
|
||||||
|
{
|
||||||
|
var result = new OpcuaDriverFactory().Create(MachineWithConfig(new Dictionary<string, string>()));
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.Contains(result.Errors, e => e.Code == "CONFIG_ENDPOINTURL_MISSING");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NonAbsoluteEndpointUrl_ReturnsFail()
|
||||||
|
{
|
||||||
|
var result = new OpcuaDriverFactory().Create(MachineWithConfig(
|
||||||
|
new Dictionary<string, string> { ["EndpointUrl"] = "not a uri" }));
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.Contains(result.Errors, e => e.Code == "CONFIG_ENDPOINTURL_INVALID");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WrongScheme_ReturnsFail()
|
||||||
|
{
|
||||||
|
var result = new OpcuaDriverFactory().Create(MachineWithConfig(
|
||||||
|
new Dictionary<string, string> { ["EndpointUrl"] = "http://opcua-server.test:4840" }));
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.Contains(result.Errors, e => e.Code == "CONFIG_ENDPOINTURL_SCHEME");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidEndpointUrl_ReturnsOkDriverWithOpcuaProtocolId()
|
||||||
|
{
|
||||||
|
var result = new OpcuaDriverFactory().Create(MachineWithConfig(
|
||||||
|
new Dictionary<string, string> { ["EndpointUrl"] = EndpointUrl }));
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.NotNull(result.Value);
|
||||||
|
Assert.Equal("opcua", result.Value.ProtocolId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EndpointUrlKeyIsCaseInsensitive()
|
||||||
|
{
|
||||||
|
var result = new OpcuaDriverFactory().Create(MachineWithConfig(
|
||||||
|
new Dictionary<string, string> { ["endpointurl"] = EndpointUrl }));
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InvalidSecurityMode_ReturnsFail()
|
||||||
|
{
|
||||||
|
var result = new OpcuaDriverFactory().Create(MachineWithConfig(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["EndpointUrl"] = EndpointUrl,
|
||||||
|
["SecurityMode"] = "Bogus",
|
||||||
|
}));
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.Contains(result.Errors, e => e.Code == "CONFIG_SECURITYMODE_INVALID");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InvalidAuthMode_ReturnsFail()
|
||||||
|
{
|
||||||
|
var result = new OpcuaDriverFactory().Create(MachineWithConfig(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["EndpointUrl"] = EndpointUrl,
|
||||||
|
["AuthMode"] = "Kerberos",
|
||||||
|
}));
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.Contains(result.Errors, e => e.Code == "CONFIG_AUTHMODE_INVALID");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UsernamePasswordWithoutUsername_ReturnsFail()
|
||||||
|
{
|
||||||
|
var result = new OpcuaDriverFactory().Create(MachineWithConfig(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["EndpointUrl"] = EndpointUrl,
|
||||||
|
["AuthMode"] = "UsernamePassword",
|
||||||
|
["Password"] = "secret",
|
||||||
|
}));
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.Contains(result.Errors, e => e.Code == "CONFIG_USERNAME_MISSING");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UsernamePasswordWithoutPassword_ReturnsFail()
|
||||||
|
{
|
||||||
|
var result = new OpcuaDriverFactory().Create(MachineWithConfig(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["EndpointUrl"] = EndpointUrl,
|
||||||
|
["AuthMode"] = "UsernamePassword",
|
||||||
|
["Username"] = "admin",
|
||||||
|
}));
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.Contains(result.Errors, e => e.Code == "CONFIG_PASSWORD_MISSING");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UsernamePasswordWithCredentials_ReturnsOk()
|
||||||
|
{
|
||||||
|
var result = new OpcuaDriverFactory().Create(MachineWithConfig(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["EndpointUrl"] = EndpointUrl,
|
||||||
|
["AuthMode"] = "UsernamePassword",
|
||||||
|
["Username"] = "admin",
|
||||||
|
["Password"] = "secret",
|
||||||
|
}));
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InvalidTimeout_ReturnsFail()
|
||||||
|
{
|
||||||
|
var result = new OpcuaDriverFactory().Create(MachineWithConfig(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["EndpointUrl"] = EndpointUrl,
|
||||||
|
["TimeoutSeconds"] = "-5",
|
||||||
|
}));
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.Contains(result.Errors, e => e.Code == "CONFIG_TIMEOUT_INVALID");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FullValidConfig_ReturnsOk()
|
||||||
|
{
|
||||||
|
var result = new OpcuaDriverFactory().Create(MachineWithConfig(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["EndpointUrl"] = EndpointUrl,
|
||||||
|
["SecurityMode"] = "SignAndEncrypt",
|
||||||
|
["SecurityPolicy"] = "Basic256Sha256",
|
||||||
|
["AuthMode"] = "Anonymous",
|
||||||
|
["NodeIds"] = "ns=2;s=Temp, ns=2;s=Speed",
|
||||||
|
["BrowseRoot"] = "i=85",
|
||||||
|
["TimeoutSeconds"] = "15",
|
||||||
|
}));
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Equal("opcua", result.Value.ProtocolId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
218
tests/Junction.Tests/Unit/OpcuaDriverTests.cs
Normal file
218
tests/Junction.Tests/Unit/OpcuaDriverTests.cs
Normal file
|
|
@ -0,0 +1,218 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Junction.Domain.Models;
|
||||||
|
using Junction.Domain.Protocols;
|
||||||
|
using Junction.Protocols.OpcUa;
|
||||||
|
using Moq;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Junction.Tests.Unit
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Drives <see cref="OpcuaDriver"/> against a mocked <see cref="IUaClient"/> (no live server),
|
||||||
|
/// mirroring MtconnectDriverTests. Exercises the read/browse happy paths, the opt-in selection
|
||||||
|
/// filter, transport-failure mapping and cancellation.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class OpcuaDriverTests
|
||||||
|
{
|
||||||
|
private static readonly Guid MachineId = Guid.Parse("11111111-2222-3333-4444-555555555555");
|
||||||
|
private const string BrowseRoot = "i=85";
|
||||||
|
private static readonly TimeSpan GenerousTimeout = TimeSpan.FromSeconds(30);
|
||||||
|
|
||||||
|
// Node ids configured for ReadCurrentAsync.
|
||||||
|
private static readonly string[] ConfiguredNodes = { "ns=2;s=Temp", "ns=2;s=Speed", "ns=2;s=State" };
|
||||||
|
|
||||||
|
private static IReadOnlyList<UaReadResult> FakeReads() => new List<UaReadResult>
|
||||||
|
{
|
||||||
|
new UaReadResult("ns=2;s=Temp", "21.5", true, DateTimeOffset.UtcNow),
|
||||||
|
new UaReadResult("ns=2;s=Speed", "1500", true, DateTimeOffset.UtcNow),
|
||||||
|
new UaReadResult("ns=2;s=State", "RUNNING", true, DateTimeOffset.UtcNow),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static OpcuaDriver DriverWith(
|
||||||
|
IUaClient client,
|
||||||
|
IReadOnlyList<string>? nodeIds = null,
|
||||||
|
IReadOnlyCollection<string>? monitoredItemIds = null,
|
||||||
|
TimeSpan? timeout = null) =>
|
||||||
|
new OpcuaDriver(
|
||||||
|
client,
|
||||||
|
MachineId,
|
||||||
|
nodeIds ?? ConfiguredNodes,
|
||||||
|
BrowseRoot,
|
||||||
|
timeout ?? GenerousTimeout,
|
||||||
|
monitoredItemIds ?? ConfiguredNodes);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ProtocolId_IsOpcua()
|
||||||
|
{
|
||||||
|
var driver = DriverWith(new Mock<IUaClient>().Object);
|
||||||
|
Assert.Equal("opcua", driver.ProtocolId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ReadCurrentAsync: happy path ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadCurrentAsync_ReadsConfiguredNodes_ReturnsConnectedSnapshotStampedWithMachineId()
|
||||||
|
{
|
||||||
|
var mock = new Mock<IUaClient>();
|
||||||
|
mock.Setup(c => c.ConnectAsync(It.IsAny<CancellationToken>())).Returns(Task.CompletedTask);
|
||||||
|
mock.Setup(c => c.ReadAsync(It.IsAny<IReadOnlyList<string>>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(FakeReads());
|
||||||
|
|
||||||
|
var driver = DriverWith(mock.Object);
|
||||||
|
var result = await driver.ReadCurrentAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.False(result.WasCancelled);
|
||||||
|
Assert.Equal(MachineId, result.Value.MachineId);
|
||||||
|
Assert.Equal(ConnectionState.Connected, result.Value.ConnectionState);
|
||||||
|
Assert.Equal(3, result.Value.Items.Count);
|
||||||
|
Assert.Contains(result.Value.Items, i => i.Id == "ns=2;s=Temp" && i.Value == "21.5");
|
||||||
|
mock.Verify(c => c.ReadAsync(It.IsAny<IReadOnlyList<string>>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ReadCurrentAsync: opt-in selection filter ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadCurrentAsync_SelectionSubset_KeepsOnlySelectedItems_PreservesConnectionState()
|
||||||
|
{
|
||||||
|
var mock = new Mock<IUaClient>();
|
||||||
|
mock.Setup(c => c.ReadAsync(It.IsAny<IReadOnlyList<string>>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(FakeReads());
|
||||||
|
|
||||||
|
var selection = new[] { "ns=2;s=Temp", "ns=2;s=State" };
|
||||||
|
var driver = DriverWith(mock.Object, monitoredItemIds: selection);
|
||||||
|
var result = await driver.ReadCurrentAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Equal(2, result.Value.Items.Count);
|
||||||
|
Assert.Contains(result.Value.Items, i => i.Id == "ns=2;s=Temp");
|
||||||
|
Assert.Contains(result.Value.Items, i => i.Id == "ns=2;s=State");
|
||||||
|
Assert.DoesNotContain(result.Value.Items, i => i.Id == "ns=2;s=Speed");
|
||||||
|
Assert.Equal(ConnectionState.Connected, result.Value.ConnectionState);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadCurrentAsync_EmptySelection_ReturnsEmptyItems_PreservesConnectionState()
|
||||||
|
{
|
||||||
|
var mock = new Mock<IUaClient>();
|
||||||
|
mock.Setup(c => c.ReadAsync(It.IsAny<IReadOnlyList<string>>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(FakeReads());
|
||||||
|
|
||||||
|
var driver = DriverWith(mock.Object, monitoredItemIds: Array.Empty<string>());
|
||||||
|
var result = await driver.ReadCurrentAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Empty(result.Value.Items);
|
||||||
|
Assert.Equal(ConnectionState.Connected, result.Value.ConnectionState);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ReadCurrentAsync: failure + cancellation ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadCurrentAsync_ClientThrows_ReturnsFailNoThrow()
|
||||||
|
{
|
||||||
|
var mock = new Mock<IUaClient>();
|
||||||
|
mock.Setup(c => c.ReadAsync(It.IsAny<IReadOnlyList<string>>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ThrowsAsync(new InvalidOperationException("server unreachable"));
|
||||||
|
|
||||||
|
var driver = DriverWith(mock.Object);
|
||||||
|
var result = await driver.ReadCurrentAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.False(result.WasCancelled);
|
||||||
|
Assert.Contains(result.Errors, e => e.Code == "CURRENT_READ_ERROR");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadCurrentAsync_CancelledBeforeCall_ReturnsCancelled()
|
||||||
|
{
|
||||||
|
var mock = new Mock<IUaClient>();
|
||||||
|
var driver = DriverWith(mock.Object);
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
cts.Cancel();
|
||||||
|
|
||||||
|
var result = await driver.ReadCurrentAsync(cts.Token);
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.True(result.WasCancelled);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadCurrentAsync_CancelledDuringCall_ReturnsCancelledNoThrow()
|
||||||
|
{
|
||||||
|
var mock = new Mock<IUaClient>();
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
mock.Setup(c => c.ReadAsync(It.IsAny<IReadOnlyList<string>>(), It.IsAny<CancellationToken>()))
|
||||||
|
.Returns<IReadOnlyList<string>, CancellationToken>(async (_, ct) =>
|
||||||
|
{
|
||||||
|
cts.Cancel();
|
||||||
|
await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false);
|
||||||
|
return FakeReads();
|
||||||
|
});
|
||||||
|
|
||||||
|
var driver = DriverWith(mock.Object);
|
||||||
|
var result = await driver.ReadCurrentAsync(cts.Token);
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.True(result.WasCancelled);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ProbeAsync: catalog ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ProbeAsync_BrowsesFromRoot_ReturnsDescriptors()
|
||||||
|
{
|
||||||
|
var descriptors = new List<DataItemDescriptor>
|
||||||
|
{
|
||||||
|
new DataItemDescriptor("ns=2;s=Temp", "Temperature", "Double", "VARIABLE", ""),
|
||||||
|
new DataItemDescriptor("ns=2;s=Speed", "Speed", "Int32", "VARIABLE", ""),
|
||||||
|
};
|
||||||
|
var mock = new Mock<IUaClient>();
|
||||||
|
mock.Setup(c => c.BrowseAsync(BrowseRoot, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(descriptors);
|
||||||
|
|
||||||
|
var driver = DriverWith(mock.Object);
|
||||||
|
var result = await driver.ProbeAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Equal(2, result.Value.Count);
|
||||||
|
var temp = result.Value.Single(d => d.Id == "ns=2;s=Temp");
|
||||||
|
Assert.Equal("Temperature", temp.Name);
|
||||||
|
Assert.Equal("Double", temp.Type);
|
||||||
|
Assert.Equal("VARIABLE", temp.Category);
|
||||||
|
mock.Verify(c => c.BrowseAsync(BrowseRoot, It.IsAny<CancellationToken>()), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ProbeAsync_ClientThrows_ReturnsFailNoThrow()
|
||||||
|
{
|
||||||
|
var mock = new Mock<IUaClient>();
|
||||||
|
mock.Setup(c => c.BrowseAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ThrowsAsync(new InvalidOperationException("browse failed"));
|
||||||
|
|
||||||
|
var driver = DriverWith(mock.Object);
|
||||||
|
var result = await driver.ProbeAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.Contains(result.Errors, e => e.Code == "PROBE_BROWSE_ERROR");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ProbeAsync_CancelledBeforeCall_ReturnsCancelled()
|
||||||
|
{
|
||||||
|
var mock = new Mock<IUaClient>();
|
||||||
|
var driver = DriverWith(mock.Object);
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
cts.Cancel();
|
||||||
|
|
||||||
|
var result = await driver.ProbeAsync(cts.Token);
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.True(result.WasCancelled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
78
tests/Junction.Tests/Unit/ProtocolIdToIconConverterTests.cs
Normal file
78
tests/Junction.Tests/Unit/ProtocolIdToIconConverterTests.cs
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
using Junction.App.Converters;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Junction.Tests.Unit
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Data-level tests for the protocol → glyph mapping. Targets the pure
|
||||||
|
/// <see cref="ProtocolIdToIconConverter.PathDataFor"/> so no Avalonia platform is required
|
||||||
|
/// (the geometry parse in Convert needs a render backend). Mirrors the intent of the
|
||||||
|
/// StatusKind converter: known ids → distinct non-empty data, unknown → fallback, never throws.
|
||||||
|
/// </summary>
|
||||||
|
public class ProtocolIdToIconConverterTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("mtconnect")]
|
||||||
|
[InlineData("opcua")]
|
||||||
|
[InlineData("fanuc")]
|
||||||
|
public void KnownProtocol_ReturnsNonEmptyData(string protocolId)
|
||||||
|
{
|
||||||
|
var data = ProtocolIdToIconConverter.PathDataFor(protocolId);
|
||||||
|
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void KnownProtocols_AreMutuallyDistinct()
|
||||||
|
{
|
||||||
|
var mtc = ProtocolIdToIconConverter.PathDataFor("mtconnect");
|
||||||
|
var opc = ProtocolIdToIconConverter.PathDataFor("opcua");
|
||||||
|
var fan = ProtocolIdToIconConverter.PathDataFor("fanuc");
|
||||||
|
|
||||||
|
Assert.NotEqual(mtc, opc);
|
||||||
|
Assert.NotEqual(mtc, fan);
|
||||||
|
Assert.NotEqual(opc, fan);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Lookup_IsCaseAndWhitespaceInsensitive()
|
||||||
|
{
|
||||||
|
var canonical = ProtocolIdToIconConverter.PathDataFor("mtconnect");
|
||||||
|
|
||||||
|
Assert.Equal(canonical, ProtocolIdToIconConverter.PathDataFor(" MtConnect "));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("unknown")]
|
||||||
|
[InlineData("modbus")]
|
||||||
|
[InlineData("")]
|
||||||
|
public void UnknownOrEmpty_ReturnsFallback(string protocolId)
|
||||||
|
{
|
||||||
|
var fallback = ProtocolIdToIconConverter.PathDataFor("definitely-not-a-protocol");
|
||||||
|
var data = ProtocolIdToIconConverter.PathDataFor(protocolId);
|
||||||
|
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(data));
|
||||||
|
Assert.Equal(fallback, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Null_ReturnsFallback_DoesNotThrow()
|
||||||
|
{
|
||||||
|
var fallback = ProtocolIdToIconConverter.PathDataFor("definitely-not-a-protocol");
|
||||||
|
var data = ProtocolIdToIconConverter.PathDataFor(null);
|
||||||
|
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(data));
|
||||||
|
Assert.Equal(fallback, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Fallback_DiffersFromEveryKnownProtocol()
|
||||||
|
{
|
||||||
|
var fallback = ProtocolIdToIconConverter.PathDataFor("unknown");
|
||||||
|
|
||||||
|
Assert.NotEqual(fallback, ProtocolIdToIconConverter.PathDataFor("mtconnect"));
|
||||||
|
Assert.NotEqual(fallback, ProtocolIdToIconConverter.PathDataFor("opcua"));
|
||||||
|
Assert.NotEqual(fallback, ProtocolIdToIconConverter.PathDataFor("fanuc"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -350,6 +350,187 @@ namespace Junction.Tests.Unit
|
||||||
Assert.False(result.IsSuccess);
|
Assert.False(result.IsSuccess);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- history (append-only time series) ----------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AppendHistory_ThenGetHistory_ReturnsAppendedPoints_Ascending()
|
||||||
|
{
|
||||||
|
var machineId = Guid.NewGuid();
|
||||||
|
var t0 = new DateTimeOffset(2026, 7, 22, 8, 0, 0, TimeSpan.Zero);
|
||||||
|
|
||||||
|
// Two snapshots of the same item at different times, appended in reverse order.
|
||||||
|
await _repo.AppendHistoryAsync(
|
||||||
|
Snap(machineId, t0.AddSeconds(10), new DataItem("d1", "Speed", "20", "Sample", t0.AddSeconds(10))),
|
||||||
|
CancellationToken.None);
|
||||||
|
Result append = await _repo.AppendHistoryAsync(
|
||||||
|
Snap(machineId, t0, new DataItem("d1", "Speed", "10", "Sample", t0)),
|
||||||
|
CancellationToken.None);
|
||||||
|
Assert.True(append.IsSuccess, Describe(append));
|
||||||
|
|
||||||
|
Result<IReadOnlyList<HistoryPoint>> got =
|
||||||
|
await _repo.GetHistoryAsync(machineId, "d1", t0.AddSeconds(-1), 100, CancellationToken.None);
|
||||||
|
Assert.True(got.IsSuccess, Describe(got));
|
||||||
|
|
||||||
|
// Append is additive (accumulates, not overwrite) and result is ascending by timestamp.
|
||||||
|
Assert.Equal(2, got.Value.Count);
|
||||||
|
Assert.Equal("10", got.Value[0].Value);
|
||||||
|
Assert.Equal(t0, got.Value[0].Timestamp);
|
||||||
|
Assert.Equal("20", got.Value[1].Value);
|
||||||
|
Assert.Equal(t0.AddSeconds(10), got.Value[1].Timestamp);
|
||||||
|
Assert.Equal("d1", got.Value[0].ItemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetHistory_SinceFilter_ExcludesOlderRows()
|
||||||
|
{
|
||||||
|
var machineId = Guid.NewGuid();
|
||||||
|
var t0 = new DateTimeOffset(2026, 7, 22, 8, 0, 0, TimeSpan.Zero);
|
||||||
|
|
||||||
|
for (int i = 0; i < 5; i++)
|
||||||
|
{
|
||||||
|
await _repo.AppendHistoryAsync(
|
||||||
|
Snap(machineId, t0.AddMinutes(i), new DataItem("d1", "Speed", i.ToString(), "Sample", t0.AddMinutes(i))),
|
||||||
|
CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// since = t0 + 3min => only minute 3 and 4 remain.
|
||||||
|
Result<IReadOnlyList<HistoryPoint>> got =
|
||||||
|
await _repo.GetHistoryAsync(machineId, "d1", t0.AddMinutes(3), 100, CancellationToken.None);
|
||||||
|
Assert.True(got.IsSuccess, Describe(got));
|
||||||
|
|
||||||
|
Assert.Equal(2, got.Value.Count);
|
||||||
|
Assert.Equal("3", got.Value[0].Value);
|
||||||
|
Assert.Equal("4", got.Value[1].Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetHistory_MaxPoints_KeepsMostRecentN_Ascending()
|
||||||
|
{
|
||||||
|
var machineId = Guid.NewGuid();
|
||||||
|
var t0 = new DateTimeOffset(2026, 7, 22, 8, 0, 0, TimeSpan.Zero);
|
||||||
|
|
||||||
|
for (int i = 0; i < 5; i++)
|
||||||
|
{
|
||||||
|
await _repo.AppendHistoryAsync(
|
||||||
|
Snap(machineId, t0.AddMinutes(i), new DataItem("d1", "Speed", i.ToString(), "Sample", t0.AddMinutes(i))),
|
||||||
|
CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5 rows exist, ask for at most 2 => most-recent two (3,4), returned ascending.
|
||||||
|
Result<IReadOnlyList<HistoryPoint>> got =
|
||||||
|
await _repo.GetHistoryAsync(machineId, "d1", t0.AddMinutes(-1), 2, CancellationToken.None);
|
||||||
|
Assert.True(got.IsSuccess, Describe(got));
|
||||||
|
|
||||||
|
Assert.Equal(2, got.Value.Count);
|
||||||
|
Assert.Equal("3", got.Value[0].Value);
|
||||||
|
Assert.Equal("4", got.Value[1].Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetHistory_UnknownItemOrMachine_ReturnsEmptyOk()
|
||||||
|
{
|
||||||
|
var machineId = Guid.NewGuid();
|
||||||
|
var t0 = new DateTimeOffset(2026, 7, 22, 8, 0, 0, TimeSpan.Zero);
|
||||||
|
await _repo.AppendHistoryAsync(
|
||||||
|
Snap(machineId, t0, new DataItem("d1", "Speed", "10", "Sample", t0)),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Result<IReadOnlyList<HistoryPoint>> unknownItem =
|
||||||
|
await _repo.GetHistoryAsync(machineId, "nope", t0.AddSeconds(-1), 100, CancellationToken.None);
|
||||||
|
Assert.True(unknownItem.IsSuccess, Describe(unknownItem));
|
||||||
|
Assert.Empty(unknownItem.Value);
|
||||||
|
|
||||||
|
Result<IReadOnlyList<HistoryPoint>> unknownMachine =
|
||||||
|
await _repo.GetHistoryAsync(Guid.NewGuid(), "d1", t0.AddSeconds(-1), 100, CancellationToken.None);
|
||||||
|
Assert.True(unknownMachine.IsSuccess, Describe(unknownMachine));
|
||||||
|
Assert.Empty(unknownMachine.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetHistory_NonPositiveMaxPoints_ReturnsEmptyOk()
|
||||||
|
{
|
||||||
|
var machineId = Guid.NewGuid();
|
||||||
|
var t0 = new DateTimeOffset(2026, 7, 22, 8, 0, 0, TimeSpan.Zero);
|
||||||
|
await _repo.AppendHistoryAsync(
|
||||||
|
Snap(machineId, t0, new DataItem("d1", "Speed", "10", "Sample", t0)),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Result<IReadOnlyList<HistoryPoint>> got =
|
||||||
|
await _repo.GetHistoryAsync(machineId, "d1", t0.AddSeconds(-1), 0, CancellationToken.None);
|
||||||
|
Assert.True(got.IsSuccess, Describe(got));
|
||||||
|
Assert.Empty(got.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AppendHistory_EmptySnapshot_IsNoOpSuccess()
|
||||||
|
{
|
||||||
|
var machineId = Guid.NewGuid();
|
||||||
|
var t0 = new DateTimeOffset(2026, 7, 22, 8, 0, 0, TimeSpan.Zero);
|
||||||
|
|
||||||
|
Result append = await _repo.AppendHistoryAsync(
|
||||||
|
new MachineSnapshot(machineId, t0, ConnectionState.Connected, null),
|
||||||
|
CancellationToken.None);
|
||||||
|
Assert.True(append.IsSuccess, Describe(append));
|
||||||
|
|
||||||
|
Result<IReadOnlyList<HistoryPoint>> got =
|
||||||
|
await _repo.GetHistoryAsync(machineId, "d1", t0.AddSeconds(-1), 100, CancellationToken.None);
|
||||||
|
Assert.True(got.IsSuccess, Describe(got));
|
||||||
|
Assert.Empty(got.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PruneHistory_RemovesOlderThanCutoff_KeepsNewer()
|
||||||
|
{
|
||||||
|
var machineId = Guid.NewGuid();
|
||||||
|
var t0 = new DateTimeOffset(2026, 7, 22, 8, 0, 0, TimeSpan.Zero);
|
||||||
|
|
||||||
|
for (int i = 0; i < 5; i++)
|
||||||
|
{
|
||||||
|
await _repo.AppendHistoryAsync(
|
||||||
|
Snap(machineId, t0.AddMinutes(i), new DataItem("d1", "Speed", i.ToString(), "Sample", t0.AddMinutes(i))),
|
||||||
|
CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cutoff at minute 3: rows < t0+3min (minutes 0,1,2) removed, 3 and 4 kept.
|
||||||
|
Result prune = await _repo.PruneHistoryAsync(t0.AddMinutes(3), CancellationToken.None);
|
||||||
|
Assert.True(prune.IsSuccess, Describe(prune));
|
||||||
|
|
||||||
|
Result<IReadOnlyList<HistoryPoint>> got =
|
||||||
|
await _repo.GetHistoryAsync(machineId, "d1", DateTimeOffset.MinValue, 100, CancellationToken.None);
|
||||||
|
Assert.True(got.IsSuccess, Describe(got));
|
||||||
|
Assert.Equal(2, got.Value.Count);
|
||||||
|
Assert.Equal("3", got.Value[0].Value);
|
||||||
|
Assert.Equal("4", got.Value[1].Value);
|
||||||
|
|
||||||
|
// Idempotent: pruning again with a cutoff below everything removed changes nothing.
|
||||||
|
Result prune2 = await _repo.PruneHistoryAsync(t0.AddMinutes(3), CancellationToken.None);
|
||||||
|
Assert.True(prune2.IsSuccess, Describe(prune2));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Delete_AlsoClearsMachineHistory()
|
||||||
|
{
|
||||||
|
var machineId = Guid.NewGuid();
|
||||||
|
var t0 = new DateTimeOffset(2026, 7, 22, 8, 0, 0, TimeSpan.Zero);
|
||||||
|
|
||||||
|
await _repo.UpsertAsync(
|
||||||
|
new Machine(machineId, "M", "mtconnect", null, TimeSpan.FromSeconds(1)), CancellationToken.None);
|
||||||
|
await _repo.AppendHistoryAsync(
|
||||||
|
Snap(machineId, t0, new DataItem("d1", "Speed", "10", "Sample", t0)),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Result delete = await _repo.DeleteAsync(machineId, CancellationToken.None);
|
||||||
|
Assert.True(delete.IsSuccess, Describe(delete));
|
||||||
|
|
||||||
|
Result<IReadOnlyList<HistoryPoint>> got =
|
||||||
|
await _repo.GetHistoryAsync(machineId, "d1", DateTimeOffset.MinValue, 100, CancellationToken.None);
|
||||||
|
Assert.True(got.IsSuccess, Describe(got));
|
||||||
|
Assert.Empty(got.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MachineSnapshot Snap(Guid machineId, DateTimeOffset capturedAt, params DataItem[] items) =>
|
||||||
|
new MachineSnapshot(machineId, capturedAt, ConnectionState.Connected, items);
|
||||||
|
|
||||||
private static string Describe<T>(Result<T> result) =>
|
private static string Describe<T>(Result<T> result) =>
|
||||||
result.IsSuccess ? "" : string.Join("; ", result.Errors.Select(e => e.ToString()));
|
result.IsSuccess ? "" : string.Join("; ", result.Errors.Select(e => e.ToString()));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,17 @@ namespace Junction.Tests.Unit
|
||||||
Assert.Contains("snapshot_items", tables);
|
Assert.Contains("snapshot_items", tables);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EnsureCreated_CreatesSnapshotHistoryTable()
|
||||||
|
{
|
||||||
|
var factory = new SqliteConnectionFactory(_connectionString);
|
||||||
|
|
||||||
|
Result result = SqliteSchema.EnsureCreated(factory);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess, DescribeErrors(result));
|
||||||
|
Assert.Contains("snapshot_history", QueryTableNames());
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void EnsureCreated_IsIdempotent()
|
public void EnsureCreated_IsIdempotent()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue