feat(hosts): driver diagnostics channel on /hosts, S7 negotiated PDU size first (#640)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -189,6 +189,17 @@ else
|
||||
@* Own line — inline after the name the two identifiers ran together. *@
|
||||
<div class="mono small text-muted">@d.DriverInstanceId</div>
|
||||
}
|
||||
@if (d.DiagnosticItems.Count > 0)
|
||||
{
|
||||
@* Driver-reported diagnostics (Gitea #640), e.g. S7 negotiated PDU size.
|
||||
Renders nothing for a driver with none. *@
|
||||
<div class="small text-muted" data-testid="driver-diagnostics">
|
||||
@foreach (var kv in d.DiagnosticItems)
|
||||
{
|
||||
<div class="mono">@kv.Key: @kv.Value</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
<td>@(d.DriverType ?? "—")</td>
|
||||
<td><span class="chip @DriverChipClass(d.State)">@d.State</span></td>
|
||||
|
||||
@@ -52,6 +52,8 @@ public sealed record HostsDriverInstanceInfo(
|
||||
/// is no answer — the driver cannot report skips, or it faulted before building a tag table. Lets
|
||||
/// "ignoring 20 of 22 tags" show even while every other column on the row — state, last read, error
|
||||
/// count, last error — reads perfectly healthy (Gitea #541).</param>
|
||||
/// <param name="Diagnostics">Driver-reported diagnostics (e.g. S7 negotiated PDU size); null or empty when
|
||||
/// the driver has none to show (Gitea #640).</param>
|
||||
/// <param name="Enabled">Whether the matching configured instance is authored enabled; <see langword="true"/>
|
||||
/// when no instance matched. Fed to the row's expandable <c>DriverStatusPanel</c> (Gitea #546). An
|
||||
/// unmatched row defaults to enabled on purpose: the row only exists because a live snapshot arrived,
|
||||
@@ -62,8 +64,16 @@ public sealed record HostsDriverRow(
|
||||
DateTime? RediscoveryNeededUtc = null, string? RediscoveryReason = null,
|
||||
IReadOnlyList<HostConnectivityStatus>? HostStatuses = null,
|
||||
SkippedTagReport? SkippedTags = null,
|
||||
bool Enabled = true)
|
||||
bool Enabled = true,
|
||||
IReadOnlyDictionary<string, string>? Diagnostics = null)
|
||||
{
|
||||
/// <summary>Diagnostics as ordered key/value pairs for rendering; empty when there are none, in which
|
||||
/// case the page renders nothing.</summary>
|
||||
public IReadOnlyList<KeyValuePair<string, string>> DiagnosticItems { get; } =
|
||||
(Diagnostics ?? new Dictionary<string, string>())
|
||||
.OrderBy(k => k.Key, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
/// <summary>Whether this driver is ignoring at least one authored tag — the condition the whole
|
||||
/// column exists to make visible.</summary>
|
||||
public bool HasSkippedTags => SkippedTags is { SkippedCount: > 0 };
|
||||
@@ -177,7 +187,8 @@ public static class HostsDriverView
|
||||
s.RediscoveryReason,
|
||||
s.HostStatuses,
|
||||
s.SkippedTags,
|
||||
instance?.Enabled ?? true);
|
||||
instance?.Enabled ?? true,
|
||||
s.Diagnostics);
|
||||
})
|
||||
.OrderBy(d => d.Name ?? d.DriverInstanceId, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(d => d.DriverInstanceId, StringComparer.OrdinalIgnoreCase)
|
||||
|
||||
@@ -130,7 +130,8 @@ public static class TelemetryProtoMapCentral
|
||||
RediscoveryNeededUtc: msg.RediscoveryNeededUtc?.ToDateTime(),
|
||||
RediscoveryReason: msg.HasRediscoveryReason ? msg.RediscoveryReason : null,
|
||||
HostStatuses: ToHostStatuses(msg),
|
||||
SkippedTags: ToSkippedTags(msg));
|
||||
SkippedTags: ToSkippedTags(msg),
|
||||
Diagnostics: msg.HasDiagnostics ? new Dictionary<string, string>(msg.Diagnostics) : null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -164,6 +164,14 @@ public static class TelemetryProtoMapNode
|
||||
msg.SkippedTags = payload;
|
||||
}
|
||||
|
||||
// Gitea #640. Presence flag first - an empty map cannot say whether the driver has the capability.
|
||||
if (e.Diagnostics is not null)
|
||||
{
|
||||
msg.HasDiagnostics = true;
|
||||
foreach (var kv in e.Diagnostics)
|
||||
msg.Diagnostics[kv.Key ?? ""] = kv.Value ?? "";
|
||||
}
|
||||
|
||||
return msg;
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,8 @@ public sealed class AkkaDriverHealthPublisher : IDriverHealthPublisher
|
||||
DateTime? rediscoveryNeededUtc = null,
|
||||
string? rediscoveryReason = null,
|
||||
IReadOnlyList<HostConnectivityStatus>? hostStatuses = null,
|
||||
SkippedTagReport? skippedTags = null)
|
||||
SkippedTagReport? skippedTags = null,
|
||||
IReadOnlyDictionary<string, string>? diagnostics = null)
|
||||
{
|
||||
var msg = new DriverHealthChanged(
|
||||
clusterId,
|
||||
@@ -51,7 +52,8 @@ public sealed class AkkaDriverHealthPublisher : IDriverHealthPublisher
|
||||
rediscoveryNeededUtc,
|
||||
rediscoveryReason,
|
||||
hostStatuses,
|
||||
skippedTags);
|
||||
skippedTags,
|
||||
diagnostics);
|
||||
DistributedPubSub.Get(_system).Mediator.Tell(new Publish(TopicName, msg));
|
||||
// Phase 5: fan the same snapshot into the node-local live-telemetry hub (no-op until a gRPC
|
||||
// client subscribes). The DPS publish above is unchanged — the hub is a strictly additive tap.
|
||||
|
||||
@@ -1153,6 +1153,28 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Bounded driver diagnostics, or null when the driver is not an <see cref="IDriverDiagnostics"/>
|
||||
/// (Gitea #640). Pulled fresh and called UNGUARDED for the same reasons as
|
||||
/// <see cref="CurrentSkippedTags"/>; a throwing implementation loses only the diagnostics.</summary>
|
||||
private IReadOnlyDictionary<string, string>? CurrentDiagnostics()
|
||||
{
|
||||
if (_driver is not IDriverDiagnostics diag) return null;
|
||||
try
|
||||
{
|
||||
return DriverDiagnostics.Bound(diag.GetDiagnostics());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Warning(ex, "DriverInstance {Id}: GetDiagnostics threw during health publish; omitting diagnostics", _driverInstanceId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Value digest of the diagnostics bag for the publish fingerprint. Null in, null out. The bag is
|
||||
/// already in ordinal key order (<see cref="DriverDiagnostics.Bound"/>), so equal facts digest equally.</summary>
|
||||
private static string? DiagnosticsDigest(IReadOnlyDictionary<string, string>? d) =>
|
||||
d is null ? null : string.Join('|', d.Select(kv => $"{kv.Key}={kv.Value}"));
|
||||
|
||||
/// <summary>Records the driver's rediscovery raise and re-publishes health so the signal reaches the
|
||||
/// AdminUI promptly rather than waiting for the next 30 s heartbeat.
|
||||
/// <para><b>Advisory only.</b> The served address space is deliberately NOT rebuilt: v3 authors raw tags
|
||||
@@ -1357,6 +1379,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
var errorCount = ErrorCount5Min();
|
||||
var hostStatuses = CurrentHostStatuses();
|
||||
var skippedTags = CurrentSkippedTags();
|
||||
var diagnostics = CurrentDiagnostics();
|
||||
// _rediscoveryNeededUtc is PART OF THE FINGERPRINT on purpose. A rediscovery raise on an
|
||||
// otherwise-unchanged Healthy driver leaves (state, lastSuccess, lastError, errorCount)
|
||||
// identical, so without it the dedup below would swallow the very publish that carries the
|
||||
@@ -1375,14 +1398,20 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
// is identical to a clean driver's and the publish that carries the signal is exactly the one
|
||||
// dedup would swallow. Flattened to a STRING for the same reason as the host digest — the
|
||||
// report holds an IReadOnlyList, which a tuple compares by reference.
|
||||
//
|
||||
// The diagnostics digest (Gitea #640) is the fourth instance: a diagnostic fact (S7's PDU size
|
||||
// changing across a reconnect) moves none of the other components, so without it the publish
|
||||
// that carries it is deduped away. Flattened to a STRING - the bag is a dictionary, which a
|
||||
// tuple compares by reference.
|
||||
var fingerprint = (health.State, health.LastSuccessfulRead, health.LastError, errorCount,
|
||||
_rediscoveryNeededUtc, HostStatusDigest(hostStatuses), SkippedTagDigest(skippedTags));
|
||||
_rediscoveryNeededUtc, HostStatusDigest(hostStatuses), SkippedTagDigest(skippedTags),
|
||||
DiagnosticsDigest(diagnostics));
|
||||
if (_lastPublishedFingerprint is { } prev && prev.Equals(fingerprint))
|
||||
return observed;
|
||||
_lastPublishedFingerprint = fingerprint;
|
||||
_healthPublisher.Publish(
|
||||
_clusterId, _driverInstanceId, health, errorCount, _rediscoveryNeededUtc, _rediscoveryReason,
|
||||
hostStatuses, skippedTags);
|
||||
hostStatuses, skippedTags, diagnostics);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -1418,7 +1447,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
|
||||
/// <summary>Fingerprint of the last <see cref="PublishHealthSnapshot"/> call; null until first publish.</summary>
|
||||
private (DriverState State, DateTime? LastSuccess, string? LastError, int ErrorCount,
|
||||
DateTime? RediscoveryNeededUtc, string? HostStatusDigest, string? SkippedTagDigest)? _lastPublishedFingerprint;
|
||||
DateTime? RediscoveryNeededUtc, string? HostStatusDigest, string? SkippedTagDigest,
|
||||
string? DiagnosticsDigest)? _lastPublishedFingerprint;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void PostStop()
|
||||
|
||||
Reference in New Issue
Block a user