fix(adminui): lane D-b review r0 fixes (I1, M1, M2, M5-M9, N1-N3, N5, H3)
I1: /hosts eviction keyed off the AUTHORED DriverInstances, so a driver deleted in /raw but not yet deployed - still running, still publishing - vanished every tick. The stores' sweep is now EvictUnconfiguredStale(set, olderThanUtc): an entry goes only when unconfigured AND its PublishedUtc is older than 60 s. Tests cover unconfigured+fresh kept (positive control), unconfigured+stale evicted, configured+stale kept, and the deploy hand-off. M1: Test Connect falls back to the driver's documented ProbeTimeoutSeconds default (Galaxy 30, OpcUaClient 15, ...) when the key is absent. M2: the budget never undercuts the config's root timeoutMs (+1 s). M5: RawBrowseModal compares the open generation captured before the await, so a close-then-reopen mid-open closes the stale session. M6: /alarms-historian keeps a 30 s re-poll on 'not registered' (a driver node may still be starting) instead of stopping for good. M7: /scripts source fetch failure shows in the dialog, not the error boundary. M8: the export modal maps only its preview rows; the endpoint writes via Results.Text (no second byte[] copy). M9: /hosts config queries are ordered, so SequenceEqual change detection holds. N1 tooltip, N2 CRLF-safe regex, N3 throttled Warning for subscriber faults, N5 equipment-import help text now describes RFC 4180 quoting. H3: MonacoEditor documents that themes are page-global. Refs #745 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
+3
-3
@@ -5,7 +5,7 @@
|
||||
|
||||
<div class="d-inline-flex align-items-center gap-2">
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" disabled="@_busy" @onclick="OnClickAsync"
|
||||
title="@($"Probe budget: {_lastBudgetSeconds?.ToString() ?? "the driver's configured probe timeout"} s")">
|
||||
title="@(_lastBudgetSeconds is { } b ? $"Probe budget: {b} s" : "Probe budget: the driver's configured probe timeout")">
|
||||
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
Test Connect
|
||||
</button>
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
/// <summary>
|
||||
/// Fallback probe budget, used only when the config JSON carries no positive
|
||||
/// <c>ProbeTimeoutSeconds</c>. The configured value wins (it is every driver's documented "Test Connect
|
||||
/// <c>ProbeTimeoutSeconds</c> and the driver has no documented default (<c>TestConnectBudget.DriverDefaults</c>). The configured value wins (it is every driver's documented "Test Connect
|
||||
/// probe timeout"), so it can lengthen the budget as well as shorten it. Clamped to [1, 60] s.
|
||||
/// </summary>
|
||||
[Parameter] public int TimeoutSeconds { get; set; } = TestConnectBudget.DefaultSeconds;
|
||||
@@ -62,7 +62,7 @@
|
||||
try
|
||||
{
|
||||
var json = GetConfigJson() ?? "{}";
|
||||
var budget = TestConnectBudget.Resolve(json, TimeoutSeconds);
|
||||
var budget = TestConnectBudget.Resolve(json, TimeoutSeconds, DriverType);
|
||||
_lastBudgetSeconds = budget;
|
||||
_result = await Probe.TestAsync(DriverType, json, budget);
|
||||
}
|
||||
|
||||
+63
-16
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers;
|
||||
|
||||
@@ -24,34 +25,80 @@ public static class TestConnectBudget
|
||||
/// <summary>The config property that carries the budget (matched case-insensitively at the JSON root).</summary>
|
||||
public const string PropertyName = "ProbeTimeoutSeconds";
|
||||
|
||||
/// <summary>Returns the configured budget, or <paramref name="fallbackSeconds"/> when there is none.</summary>
|
||||
/// <summary>The root per-request timeout some probes (Modbus) use as their own deadline, in ms.</summary>
|
||||
public const string RequestTimeoutPropertyName = "timeoutMs";
|
||||
|
||||
/// <summary>
|
||||
/// Each driver's documented <c>ProbeTimeoutSeconds</c> default (its options type's initializer), used
|
||||
/// when the config carries no value — legacy rows and raw-JSON/seeded configs omit the key, and the
|
||||
/// form hints promise these defaults (review r0 M1). Drivers not listed fall back to the caller's value.
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyDictionary<string, int> DriverDefaults =
|
||||
new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[DriverTypeNames.Galaxy] = 30,
|
||||
[DriverTypeNames.OpcUaClient] = 15,
|
||||
[DriverTypeNames.TwinCAT] = 10,
|
||||
[DriverTypeNames.FOCAS] = 10,
|
||||
[DriverTypeNames.Modbus] = 5,
|
||||
[DriverTypeNames.AbCip] = 5,
|
||||
[DriverTypeNames.AbLegacy] = 5,
|
||||
[DriverTypeNames.S7] = 5,
|
||||
};
|
||||
|
||||
/// <summary>Returns the configured budget, else the driver's documented default, else <paramref name="fallbackSeconds"/>.</summary>
|
||||
/// <param name="configJson">The (merged) config JSON the probe will run against.</param>
|
||||
/// <param name="fallbackSeconds">The budget when the config has no positive <c>ProbeTimeoutSeconds</c>.</param>
|
||||
/// <param name="fallbackSeconds">The budget when neither the config nor <see cref="DriverDefaults"/> supplies one.</param>
|
||||
/// <param name="driverType">The driver type, selecting its documented default; null skips that step.</param>
|
||||
/// <returns>The budget in seconds, within [<see cref="MinSeconds"/>, <see cref="MaxSeconds"/>].</returns>
|
||||
public static int Resolve(string? configJson, int fallbackSeconds = DefaultSeconds)
|
||||
/// <remarks>
|
||||
/// A probe that runs under the config's own <c>timeoutMs</c> (Modbus, factory parity) is hard-cancelled
|
||||
/// at this budget by the probe actor, so the budget is never set below <c>ceil(timeoutMs / 1000) + 1</c>
|
||||
/// (review r0 M2): a slow gateway that the deployed driver reaches within its 8 s request timeout must
|
||||
/// not fail Test Connect at a 5 s budget.
|
||||
/// </remarks>
|
||||
public static int Resolve(string? configJson, int fallbackSeconds = DefaultSeconds, string? driverType = null)
|
||||
{
|
||||
var configured = TryReadConfigured(configJson);
|
||||
var seconds = configured is > 0 ? configured.Value : fallbackSeconds;
|
||||
var (configured, requestTimeoutMs) = TryRead(configJson);
|
||||
var seconds = configured is > 0
|
||||
? configured.Value
|
||||
: driverType is not null && DriverDefaults.TryGetValue(driverType, out var d) ? d : fallbackSeconds;
|
||||
|
||||
if (requestTimeoutMs is > 0)
|
||||
{
|
||||
var floor = (int)Math.Min(Math.Ceiling(requestTimeoutMs.Value / 1000.0) + 1, int.MaxValue);
|
||||
seconds = Math.Max(seconds, floor);
|
||||
}
|
||||
|
||||
return Math.Clamp(seconds, MinSeconds, MaxSeconds);
|
||||
}
|
||||
|
||||
private static int? TryReadConfigured(string? configJson)
|
||||
private static (int? ProbeTimeoutSeconds, double? RequestTimeoutMs) TryRead(string? configJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(configJson)) return null;
|
||||
if (string.IsNullOrWhiteSpace(configJson)) return (null, null);
|
||||
int? probe = null;
|
||||
double? request = null;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(configJson);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object) return (null, null);
|
||||
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
if (!string.Equals(prop.Name, PropertyName, StringComparison.OrdinalIgnoreCase)) continue;
|
||||
return prop.Value.ValueKind switch
|
||||
if (string.Equals(prop.Name, PropertyName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
JsonValueKind.Number when prop.Value.TryGetInt32(out var i) => i,
|
||||
JsonValueKind.Number when prop.Value.TryGetDouble(out var d) => (int)Math.Min(Math.Ceiling(d), int.MaxValue),
|
||||
JsonValueKind.String when int.TryParse(prop.Value.GetString(), out var s) => s,
|
||||
_ => null,
|
||||
};
|
||||
probe = prop.Value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Number when prop.Value.TryGetInt32(out var i) => i,
|
||||
JsonValueKind.Number when prop.Value.TryGetDouble(out var dd) => (int)Math.Min(Math.Ceiling(dd), int.MaxValue),
|
||||
JsonValueKind.String when int.TryParse(prop.Value.GetString(), out var s) => s,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
else if (string.Equals(prop.Name, RequestTimeoutPropertyName, StringComparison.OrdinalIgnoreCase)
|
||||
&& prop.Value.ValueKind == JsonValueKind.Number && prop.Value.TryGetDouble(out var ms))
|
||||
{
|
||||
request = ms;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
@@ -59,6 +106,6 @@ public static class TestConnectBudget
|
||||
// An unparseable config is the probe's problem to report; the budget just falls back.
|
||||
}
|
||||
|
||||
return null;
|
||||
return (probe, request);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,6 +225,10 @@
|
||||
await SafeInvokeAsync("MonacoBlazor.setEditorOption", "toggle minimap", _id, "minimap", new { enabled = _minimap });
|
||||
}
|
||||
|
||||
// R04 H3 (accepted, cosmetic): Monaco themes are page-global — the JS side calls
|
||||
// monaco.editor.setTheme, which re-themes EVERY editor on the page (e.g. a VirtualTagModal open over
|
||||
// ScriptEdit), not just this one, and the other instance's _dark flag then no longer matches what it shows.
|
||||
// Monaco has no per-editor theme; a fix would need a page-level theme service shared by all instances.
|
||||
private async Task ToggleTheme()
|
||||
{
|
||||
_dark = !_dark;
|
||||
|
||||
@@ -457,12 +457,15 @@
|
||||
{
|
||||
_opening = true;
|
||||
_openError = null;
|
||||
// Captured BEFORE the await: a close-then-reopen while this open is in flight leaves Visible true
|
||||
// again, so only the generation tells this open's result apart from the new modal's (review r0 M5).
|
||||
var generation = _openGeneration;
|
||||
StateHasChanged();
|
||||
try
|
||||
{
|
||||
var result = await BrowserService.OpenAsync(_driverType, _mergedConfig, default);
|
||||
// Host disposed or modal closed mid-open: close the session nobody will (R04 L4).
|
||||
if (BrowseOpenGuard.CloseIfOrphaned(result, _disposed || !Visible, BrowserService)) { return; }
|
||||
// Host disposed, modal closed, or modal re-opened mid-open: close the session nobody will (R04 L4).
|
||||
if (BrowseOpenGuard.CloseIfOrphaned(result, _disposed || IsStale(generation), BrowserService)) { return; }
|
||||
if (result.Ok)
|
||||
{
|
||||
_token = result.Token;
|
||||
|
||||
@@ -116,8 +116,10 @@
|
||||
try
|
||||
{
|
||||
_groupFilter = null;
|
||||
// The same builder the download endpoint runs, so the preview matches the file.
|
||||
var export = await RawTagCsvExport.BuildAsync(ExportReader, DeviceId, TagGroupId, DriverType);
|
||||
// The same builder the download endpoint runs, so the preview matches the file — but mapping only
|
||||
// the preview's rows (the header takes the first line), never the whole export (review r0 M8).
|
||||
var export = await RawTagCsvExport.BuildAsync(ExportReader, DeviceId, TagGroupId, DriverType,
|
||||
maxRows: RawTagCsvExport.PreviewLines - 1);
|
||||
if (IsStale(generation)) { return; }
|
||||
if (export is null)
|
||||
{
|
||||
@@ -129,7 +131,8 @@
|
||||
_rowCount = export.RowCount;
|
||||
_fileName = export.FileName;
|
||||
// Only the bounded preview is kept on the component; the full CSV is not held by the circuit.
|
||||
(_preview, _truncated) = RawTagCsvExport.Preview(export.Csv);
|
||||
(_preview, var cut) = RawTagCsvExport.Preview(export.Csv);
|
||||
_truncated = cut || export.RowCount > RawTagCsvExport.PreviewLines - 1;
|
||||
_downloadUrl = RawTagCsvExport.DownloadUrl(DeviceId, TagGroupId, DriverType);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@
|
||||
<textarea class="form-control form-control-sm mono" rows="12"
|
||||
@bind="_csv" @bind:event="oninput" disabled="@_busy"
|
||||
placeholder="Name,MachineCode,UnsLineId,ZTag,SAPID,Manufacturer,Model mixer-01,MX_001,LINE-1,ZT-12345,SAP-9876,Siemens,SIMATIC-1500"></textarea>
|
||||
<div class="form-text">Simple comma-separated values only — fields containing commas are not supported.</div>
|
||||
<div class="form-text">RFC 4180 CSV: wrap a field containing commas, quotes or line breaks in double quotes (a quote inside is doubled: <span class="mono">""</span>). The opening quote must come straight after the comma — <span class="mono">a, "b"</span> is rejected.</div>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(_parseError))
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user