mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-17 05:11:13 +08:00
b3c1e60f46
# Description Extract the AMB layer from #4462 onto the client/server predecessor #4802, then address the four review findings in `6428686ae45724510bd6bd00619f4992f3d36e46`. The initial extraction (`95b0caa1ac30d1fdacaf7a780f19c5f97e6fd02d`) contained exactly 19 paths: 12 AMB client files, four AMB test files, two sample entry points and `docs/AMB.md`. Its code/tests matched the frozen source `5b03e9afa492cdb36049ee52ae844f630f89d8db...390c4a7a51b71da40caec2d22ef74518bbe670ce`; only the two agreed UaLens-only documentation passages were omitted. The review follow-up intentionally changes that extracted implementation and adds the related factory/registration tests and package documentation. No UaLens, Machinery, Pumps, Positioning, native-rendering, build or CI changes are introduced by this layer. The AMB client provides opt-in bounded traversal, strict response/type validation and original `AssetReadValue` evidence while preserving tolerant defaults. The samples expose validated host, port, PKI root, certificate auto-accept and bounded-runtime options, with unchanged standalone defaults. The review corrections are: - Accept header-validated empty BrowseNext release responses required by Part 4 §5.9.3.2, as well as the stack's single-result response. Invalid cleanup results still preserve the original failure. - Keep event observation tolerant of denied/limited maintenance-class discovery without caching incomplete classes. Subsequent strict reads retry discovery and still report errors. - Accept typed-null ExtensionObjects only in the nullable structure-field checks, preserving original evidence without weakening scalar validation. - Pass optional immutable limits through `AddAssetManagementClient`, the registered factory/delegate and directly constructed factories. Preserve existing overloads and tolerant behavior when options are omitted; reject invalid limits before opening a session. ### Review regression evidence | Requirement | Evidence | | --- | --- | | Accept empty cleanup responses without masking cancellation or limits | `OneBeyondReferenceLimitReleasesAcquiredContinuationAsync` and `CancellationWhileAwaitingNextReleasesTheSubmittedContinuationAsync`, each with empty and single-result responses; malformed responses remain covered | | Preserve observation fallback and do not cache incomplete classes | `ObservationFallsBackWithoutCachingIncompleteClassesAsync`: both observation APIs, denied browsing and exhausted budget, correct filter/event evidence, and a subsequent strict rediscovery | | Preserve typed-null structure evidence without weakening scalar checks | `ContextAndEntryReadsKeepSourceAndNullStructureEvidenceAsync`, `MaintenanceTypedNullStructuresKeepOriginalEvidenceAsync`, `ScalarHealthRejectsTypedNullStructuresAsync`, plus wrong-non-null-structure rejection tests | | Apply configured hosted limits while retaining tolerant defaults | `HostedClientOptionsReachClientsAsync`: registered factory, registered delegate and direct factory, singleton and builder registration, actual one-reference limit and read evidence; `InvalidHostedLimitsAreRejectedBeforeOpeningASession` | ### Validation All commands below ran in this layer's worktree with the existing pinned .NET SDK 10.0.401. No SDK installation, global.json change, registry/path-policy change, test suppression or analyzer suppression was made. The short owned NUnit directory `D:\a61r` and its generated certificates/state were removed afterward. **Latest full AMB suite: Passed: 285, Failed: 0, Skipped: 0 on each framework.** Both builds emitted no compiler/analyzer diagnostics. TRX files confirm the regression cases executed on both TFMs. ```powershell & 'D:\git\UA-.NETStandard_master\TestResults\ualens-528-deep-repro\sdk\dotnet.exe' test 'tests\Opc.Ua.AMB.Tests\Opc.Ua.AMB.Tests.csproj' --no-restore --configuration Release --framework net10.0 -p:CustomTestTarget=net10.0 --nologo --verbosity minimal --logger 'trx;LogFileName=amb-feedback-net10.trx' --results-directory 'C:\Users\mschier\.copilot\session-state\61d7ed73-c89e-406a-a049-1d8ba9681308\files\TestResults\feedback-net10' -- 'NUnit.WorkDirectory=D:\a61r' & 'D:\git\UA-.NETStandard_master\TestResults\ualens-528-deep-repro\sdk\dotnet.exe' test 'tests\Opc.Ua.AMB.Tests\Opc.Ua.AMB.Tests.csproj' --configuration Release --framework net48 -p:CustomTestTarget=net48 --nologo --verbosity minimal --logger 'trx;LogFileName=amb-feedback-net48.trx' --results-directory 'C:\Users\mschier\.copilot\session-state\61d7ed73-c89e-406a-a049-1d8ba9681308\files\TestResults\feedback-net48' -- 'NUnit.WorkDirectory=D:\a61r' ``` **Focused regression run: Passed: 127, Failed: 0, Skipped: 0.** Before the production fixes, the focused repro executed 112 cases with 16 failures reproducing all four reported defects. Additional registration/validation cases were included in the final run. ```powershell & 'D:\git\UA-.NETStandard_master\TestResults\ualens-528-deep-repro\sdk\dotnet.exe' test 'tests\Opc.Ua.AMB.Tests\Opc.Ua.AMB.Tests.csproj' --no-restore --configuration Release --framework net10.0 -p:CustomTestTarget=net10.0 --nologo --verbosity minimal --filter 'FullyQualifiedName~AmbBoundedClientTests|FullyQualifiedName~AmbClientTests|FullyQualifiedName~HostedClientOptionsReachClientsAsync' --logger 'trx;LogFileName=amb-feedback-focused.trx' --results-directory 'C:\Users\mschier\.copilot\session-state\61d7ed73-c89e-406a-a049-1d8ba9681308\files\TestResults\feedback-focused' -- 'NUnit.WorkDirectory=D:\a61r' ``` **Whitespace and newly introduced style findings: passed.** `git diff --check` and the following commands exited 0: ```powershell & 'D:\git\UA-.NETStandard_master\TestResults\ualens-528-deep-repro\sdk\dotnet.exe' format whitespace --folder --verify-no-changes --verbosity quiet --include 'src\Opc.Ua.AMB.Client\AmbClient.Bounds.cs' 'src\Opc.Ua.AMB.Client\AmbClient.Health.cs' 'src\Opc.Ua.AMB.Client\AmbClient.Structure.cs' 'src\Opc.Ua.AMB.Client\AmbClient.Values.cs' 'src\Opc.Ua.AMB.Client\AmbClientFactory.cs' 'src\Opc.Ua.AMB.Client\Hosting\OpcUaAmbClientBuilderExtensions.cs' 'tests\Opc.Ua.AMB.Tests\AmbBoundedClientTests.cs' 'tests\Opc.Ua.AMB.Tests\AmbBoundedClientTests.Traversal.cs' 'tests\Opc.Ua.AMB.Tests\AmbBoundedClientTests.Values.cs' 'tests\Opc.Ua.AMB.Tests\AmbClientServerE2eTests.cs' 'tests\Opc.Ua.AMB.Tests\AmbClientTests.cs' $env:CustomTestTarget = 'net10.0' $env:Configuration = 'Release' & 'D:\git\UA-.NETStandard_master\TestResults\ualens-528-deep-repro\sdk\dotnet.exe' format style 'tests\Opc.Ua.AMB.Tests\Opc.Ua.AMB.Tests.csproj' --no-restore --verify-no-changes --severity info --diagnostics IDE0039 --verbosity quiet --include 'tests\Opc.Ua.AMB.Tests\AmbClientServerE2eTests.cs' 'tests\Opc.Ua.AMB.Tests\AmbClientTests.cs' ``` The broader formatter check was **not clean**. It reported pre-existing informational suggestions and design-time `NUnit1003` diagnostics for generated browse-name constants, including pre-existing test cases. Those diagnostics do not occur in the actual builds, which compile and execute those cases successfully. Two new local-function suggestions were corrected and rechecked above; no unrelated baseline cleanup or suppression was included. ```powershell $env:CustomTestTarget = 'net10.0' & 'D:\git\UA-.NETStandard_master\TestResults\ualens-528-deep-repro\sdk\dotnet.exe' format 'tests\Opc.Ua.AMB.Tests\Opc.Ua.AMB.Tests.csproj' --no-restore --verify-no-changes --severity info --verbosity quiet --include 'src\Opc.Ua.AMB.Client\AmbClient.Bounds.cs' 'src\Opc.Ua.AMB.Client\AmbClient.Health.cs' 'src\Opc.Ua.AMB.Client\AmbClient.Structure.cs' 'src\Opc.Ua.AMB.Client\AmbClient.Values.cs' 'src\Opc.Ua.AMB.Client\AmbClientFactory.cs' 'src\Opc.Ua.AMB.Client\Hosting\OpcUaAmbClientBuilderExtensions.cs' 'tests\Opc.Ua.AMB.Tests\AmbBoundedClientTests.cs' 'tests\Opc.Ua.AMB.Tests\AmbBoundedClientTests.Traversal.cs' 'tests\Opc.Ua.AMB.Tests\AmbBoundedClientTests.Values.cs' 'tests\Opc.Ua.AMB.Tests\AmbClientServerE2eTests.cs' 'tests\Opc.Ua.AMB.Tests\AmbClientTests.cs' ``` <details> <summary>Initial extraction validation and environment history</summary> The original extraction passed all 263 AMB tests on both TFMs. Its initial net48 run had one unchanged maintenance-test server-start timeout; shortening only NUnit.WorkDirectory resolved it, consistent with certificate-store path lengths. This follow-up used a short directory from the start and all 285 cases passed on both frameworks. A no-restore test invocation during the follow-up initially executed no tests; it was not counted as passing evidence. A no-restore build identified `NETSDK1005` (missing net10.0 target assets after switching TFMs). The normal test invocation restored the target assets. Initial regression-test compilation issues were corrected before capturing the 16 failing behavioral cases. Both samples were built during extraction on both TFMs; each command below passed with 0 warnings and 0 errors. They were not rebuilt during this review follow-up, which does not modify the samples. ```powershell & 'D:\git\UA-.NETStandard_master\TestResults\ualens-528-deep-repro\sdk\dotnet.exe' build 'samples\AMB\AmbClient\AmbClient.csproj' --configuration Release --framework net10.0 -p:CustomTestTarget=net10.0 --nologo --verbosity quiet & 'D:\git\UA-.NETStandard_master\TestResults\ualens-528-deep-repro\sdk\dotnet.exe' build 'samples\AMB\AmbServer\AmbServer.csproj' --configuration Release --framework net10.0 -p:CustomTestTarget=net10.0 --nologo --verbosity quiet & 'D:\git\UA-.NETStandard_master\TestResults\ualens-528-deep-repro\sdk\dotnet.exe' build 'samples\AMB\AmbClient\AmbClient.csproj' --configuration Release --framework net48 -p:CustomTestTarget=net48 --nologo --verbosity quiet & 'D:\git\UA-.NETStandard_master\TestResults\ualens-528-deep-repro\sdk\dotnet.exe' build 'samples\AMB\AmbServer\AmbServer.csproj' --configuration Release --framework net48 -p:CustomTestTarget=net48 --nologo --verbosity quiet ``` </details> This remains a draft: the complete `UA.slnx` net48/net10.0 matrix, coverage, CI/CodeQL and CLA status are not established by this focused validation. No coverage increase or complete-solution pass is claimed. The feedback commit is based on the original AMB extraction; integration of later lower-layer updates is coordinated separately, without rewriting this branch or native stack 4807. ## Related Issues - References #4461, the existing tracking issue; this PR does not close it. - Extracted from #4462 at frozen head `390c4a7a51b71da40caec2d22ef74518bbe670ce`; the source PR is not closed by this PR. - Immediate predecessor: #4802 (`marcschier-extract-client-and-server`), above core/types #4801. The original extraction parent was `86ff10d3994f132f7343a929abdae209bc2e69b7`. This is an isolated extraction and review correction of the existing tracked implementation, not a claim of new architectural sign-off. ## Checklist _Put an `x` in the boxes that apply. You can complete these step by step after opening the PR._ - [ ] I have signed the [CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf) and read the [CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md) doc. - [ ] I have added tests that prove my fix is effective or that my feature works and increased code coverage. - [x] I have added all necessary documentation. - [x] I have verified that my changes do not introduce (new) build or analyzer warnings. - [ ] I ran **all** tests locally using the **UA.slnx** solution against at least .net **framework** and .net **10**, and all passed. - [ ] I fixed **all** failing and flaky tests in the CI pipelines and **all** CodeQL warnings. - [x] I have addressed **all** PR feedback received.
265 lines
10 KiB
C#
265 lines
10 KiB
C#
/* ========================================================================
|
|
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
|
|
*
|
|
* OPC Foundation MIT License 1.00
|
|
*
|
|
* Permission is hereby granted, free of charge, to any person
|
|
* obtaining a copy of this software and associated documentation
|
|
* files (the "Software"), to deal in the Software without
|
|
* restriction, including without limitation the rights to use,
|
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
* copies of the Software, and to permit persons to whom the
|
|
* Software is furnished to do so, subject to the following
|
|
* conditions:
|
|
*
|
|
* The above copyright notice and this permission notice shall be
|
|
* included in all copies or substantial portions of the Software.
|
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
|
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
|
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
|
* OTHER DEALINGS IN THE SOFTWARE.
|
|
*
|
|
* The complete license agreement can be found here:
|
|
* http://opcfoundation.org/License/MIT/1.00/
|
|
* ======================================================================*/
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using System.Runtime.CompilerServices;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using AmbSample.Client;
|
|
using Microsoft.Extensions.Logging;
|
|
using Opc.Ua;
|
|
using Opc.Ua.AMB;
|
|
using Opc.Ua.AMB.Client;
|
|
using Opc.Ua.Client;
|
|
using AmbClientOptions = AmbSample.Client.AmbClientOptions;
|
|
|
|
AmbClientOptions options = AmbClientOptions.Parse(args);
|
|
|
|
ITelemetryContext telemetry = DefaultTelemetry.Create(
|
|
logging => logging.AddConsole().SetMinimumLevel(LogLevel.Warning));
|
|
|
|
using var cancellation = new CancellationTokenSource();
|
|
Console.CancelKeyPress += (_, eventArgs) =>
|
|
{
|
|
eventArgs.Cancel = true;
|
|
cancellation.Cancel();
|
|
};
|
|
CancellationToken ct = cancellation.Token;
|
|
|
|
SampleSession connection = await SampleSession.ConnectAsync(options, telemetry, ct).ConfigureAwait(false);
|
|
await using ConfiguredAsyncDisposable _ = connection.ConfigureAwait(false);
|
|
|
|
// session.AssetManagement(...) also teaches the session the AMB structures,
|
|
// so root causes and maintenance details decode into their generated types.
|
|
AmbClient amb = connection.Session.AssetManagement(telemetry);
|
|
if (!amb.IsSupported)
|
|
{
|
|
Console.Error.WriteLine("The server does not publish the OPC 10000-110 AMB namespace.");
|
|
return 1;
|
|
}
|
|
|
|
// Every asset of the server, once, from the AMB alias categories.
|
|
ArrayOf<NodeId> assets = await amb.DiscoverAssetsAsync(ct).ConfigureAwait(false);
|
|
Console.WriteLine($"{assets.Count} manageable assets");
|
|
var snapshots = new List<AssetSnapshot>();
|
|
foreach (NodeId asset in assets.ToList())
|
|
{
|
|
AssetSnapshot snapshot = await amb.ReadAssetAsync(asset, ct).ConfigureAwait(false);
|
|
snapshots.Add(snapshot);
|
|
Print(snapshot);
|
|
}
|
|
|
|
Console.WriteLine();
|
|
foreach (AssetLocationKind kind in new[] { AssetLocationKind.Hierarchical, AssetLocationKind.Operational })
|
|
{
|
|
Console.WriteLine($"{kind} locations:");
|
|
foreach (AssetLocationNode location in (await amb.BrowseLocationsAsync(kind, ct).ConfigureAwait(false)).ToList())
|
|
{
|
|
Console.WriteLine($" {location.Path} ({location.Assets.Count} assets)");
|
|
}
|
|
}
|
|
|
|
// Add a link of our own to the first asset that has the AddIn, read it
|
|
// back and remove it again.
|
|
AssetSnapshot? withLinks = snapshots.Find(snapshot => snapshot.DocumentationLinks.Count > 0);
|
|
if (withLinks != null)
|
|
{
|
|
Console.WriteLine();
|
|
await TryAsync("AddLink / RemoveLink", async () =>
|
|
{
|
|
NodeId link = await amb.AddDocumentationLinkAsync(
|
|
withLinks.Asset,
|
|
"https://plant.example/wiring/line2",
|
|
new QualifiedName("WiringDiagram", withLinks.Asset.NamespaceIndex),
|
|
new LocalizedText("en", "Wiring diagram of line 2"),
|
|
cancellationToken: ct).ConfigureAwait(false);
|
|
Console.WriteLine($" added {link}");
|
|
foreach (DocumentationLinkRecord record in
|
|
(await amb.ReadDocumentationLinksAsync(withLinks.Asset, ct).ConfigureAwait(false)).ToList())
|
|
{
|
|
string editable = record.IsWritable ? " (editable)" : string.Empty;
|
|
Console.WriteLine($" {record.BrowseName.Name,-20} {UriOf(record)}{editable}");
|
|
}
|
|
await amb.RemoveDocumentationLinkAsync(withLinks.Asset, link, ct).ConfigureAwait(false);
|
|
Console.WriteLine(" removed it again");
|
|
}).ConfigureAwait(false);
|
|
}
|
|
|
|
// Write an AssetId; the server moves the asset in AssetsByAssetId.
|
|
AssetSnapshot? configurable = snapshots.Find(snapshot => snapshot.Identification.AssetId != null);
|
|
if (options.AssetId != null && configurable != null)
|
|
{
|
|
Console.WriteLine();
|
|
await TryAsync($"WriteAssetId {options.AssetId}", async () =>
|
|
{
|
|
await amb.WriteAssetIdAsync(configurable.Asset, options.AssetId, ct).ConfigureAwait(false);
|
|
ArrayOf<AssetAlias> found = await amb
|
|
.FindAssetsAsync(options.AssetId, AssetAliasCategory.ByAssetId, ct)
|
|
.ConfigureAwait(false);
|
|
Console.WriteLine($" found {found.Count} asset(s) by the new AssetId");
|
|
}).ConfigureAwait(false);
|
|
}
|
|
|
|
if (options.WatchSeconds > 0)
|
|
{
|
|
Console.WriteLine();
|
|
Console.WriteLine($"Streaming health alarms and maintenance for {options.WatchSeconds} s ...");
|
|
using var watch = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
|
watch.CancelAfter(TimeSpan.FromSeconds(options.WatchSeconds));
|
|
Task alarms = WatchAlarmsAsync(amb, watch.Token);
|
|
Task maintenance = WatchMaintenanceAsync(amb, watch.Token);
|
|
await Task.WhenAll(alarms, maintenance).ConfigureAwait(false);
|
|
}
|
|
return 0;
|
|
|
|
static void Print(AssetSnapshot snapshot)
|
|
{
|
|
AssetIdentificationRecord id = snapshot.Identification;
|
|
Console.WriteLine();
|
|
Console.WriteLine($"{id.ProductInstanceUri} ({snapshot.Asset})");
|
|
Console.WriteLine(
|
|
$" {id.Manufacturer.Text} {id.Model.Text}, serial {id.SerialNumber}, AssetId {id.AssetId ?? "-"}");
|
|
if (id.HardwareRevision != null || id.SoftwareRevision != null)
|
|
{
|
|
Console.WriteLine(
|
|
$" hardware {id.HardwareRevision ?? "-"}, software {id.SoftwareRevision ?? "-"}, " +
|
|
$"revision counter {id.RevisionCounter}");
|
|
}
|
|
if (snapshot.DeviceHealth != null)
|
|
{
|
|
Console.WriteLine($" DeviceHealth {snapshot.DeviceHealth}");
|
|
}
|
|
foreach (AssetAlarmRecord alarm in snapshot.HealthAlarms.ToList())
|
|
{
|
|
string state = alarm.IsActive ? "active" : "inactive";
|
|
Console.WriteLine($" alarm {alarm.ConditionName}: {state}, {alarm.ConditionClass?.Name}");
|
|
}
|
|
foreach (MaintenanceActivityRecord activity in snapshot.MaintenanceActivities.ToList())
|
|
{
|
|
Console.WriteLine(
|
|
$" maintenance {activity.ConditionName}: {activity.State}, planned {Format(activity.PlannedDate)}");
|
|
}
|
|
foreach (DocumentationLinkRecord link in snapshot.DocumentationLinks.ToList())
|
|
{
|
|
Console.WriteLine($" link {link.BrowseName.Name}: {UriOf(link)}");
|
|
}
|
|
AssetContextRecord context = snapshot.Context;
|
|
if (context.HierarchicalLocation != null || context.OperationalLocation != null)
|
|
{
|
|
Console.WriteLine($" located at {context.HierarchicalLocation ?? context.OperationalLocation}");
|
|
}
|
|
if (context.LocalTime != null)
|
|
{
|
|
Console.WriteLine($" local time UTC{context.LocalTime.Offset / 60.0:+0.#;-0.#}");
|
|
}
|
|
foreach (ExpandedNodeId entry in context.Classifications.ToList())
|
|
{
|
|
Console.WriteLine($" classified as {entry}");
|
|
}
|
|
}
|
|
|
|
static async Task WatchAlarmsAsync(AmbClient amb, CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
await foreach (AssetAlarmRecord alarm in amb
|
|
.ObserveHealthAlarmsAsync(cancellationToken: ct)
|
|
.ConfigureAwait(false))
|
|
{
|
|
string causes = string.Join(
|
|
", ",
|
|
alarm.PotentialRootCauses.ToList().ConvertAll(cause => cause.RootCause.Text));
|
|
string acknowledged = alarm.IsAcknowledged ? "acknowledged" : "unacknowledged";
|
|
string comment = alarm.Comment.IsNullOrEmpty ? string.Empty : $" - comment: {alarm.Comment.Text}";
|
|
Console.WriteLine(
|
|
$" {alarm.SourceName}/{alarm.ConditionName}: {(alarm.IsActive ? "ACTIVE" : "cleared")}, " +
|
|
$"{acknowledged}, severity {alarm.Severity} ({alarm.FaultCategory?.ToString() ?? "inactive"}) " +
|
|
alarm.Message.Text +
|
|
(causes.Length > 0 ? $" - root causes: {causes}" : string.Empty) +
|
|
comment);
|
|
|
|
// Acknowledge an alarm once it is cleared, so the server stops
|
|
// retaining it.
|
|
if (!alarm.IsActive && !alarm.IsAcknowledged && alarm.Retain)
|
|
{
|
|
await amb.AcknowledgeAsync(alarm.ConditionId, alarm.EventId, new LocalizedText("en", "seen"), ct)
|
|
.ConfigureAwait(false);
|
|
}
|
|
}
|
|
}
|
|
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
|
{
|
|
// The watch ended.
|
|
}
|
|
}
|
|
|
|
static async Task WatchMaintenanceAsync(AmbClient amb, CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
await foreach (MaintenanceActivityRecord activity in amb
|
|
.ObserveMaintenanceAsync(cancellationToken: ct)
|
|
.ConfigureAwait(false))
|
|
{
|
|
Console.WriteLine($" maintenance {activity.ConditionName}: {activity.State} - {activity.Message.Text}");
|
|
}
|
|
}
|
|
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
|
{
|
|
// The watch ended.
|
|
}
|
|
}
|
|
|
|
static string UriOf(DocumentationLinkRecord link)
|
|
{
|
|
return string.IsNullOrEmpty(link.Uri) ? "(not set)" : link.Uri;
|
|
}
|
|
|
|
static string Format(DateTimeUtc value)
|
|
{
|
|
return value.IsNull
|
|
? "-"
|
|
: ((DateTime)value).ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture);
|
|
}
|
|
|
|
static async Task TryAsync(string name, Func<Task> action)
|
|
{
|
|
Console.WriteLine($"{name}:");
|
|
try
|
|
{
|
|
await action().ConfigureAwait(false);
|
|
}
|
|
catch (ServiceResultException ex)
|
|
{
|
|
Console.WriteLine($" refused: {ex.StatusCode}");
|
|
}
|
|
}
|