feat(overview): self-observability, config and the container image

Closes Phase 3 of the overview-dashboard plan (tasks 3.7-3.9).

Self-observability
- OverviewMetrics: an observable gauge read from the published snapshot at scrape
  time, so the metric and the page cannot disagree about what the dashboard
  believes, plus a sweep-duration histogram. Instrument names follow the family
  METRIC-CONVENTIONS spec (overview.instance.status / overview.poll.duration,
  unit s) rather than the plan's literal zb_ names; Prometheus renders them as
  overview_instance_status and overview_poll_duration_seconds. Labels are
  application/node/group - "node", not "instance", because Prometheus stamps its
  own instance label on every scraped series and a colliding metric label is
  silently renamed exported_instance. Instances never probed emit nothing: the
  enum's zero ordinal is Up, so a placeholder would publish the single most
  reassuring answer about a node nobody has heard from.
- Two readiness checks make the dashboard's own /health/ready mean something:
  registry-loaded (reports the counts it bound) and last-poll-cycle-completed,
  which catches the failure where Kestrel is fine, the page still renders, and
  every card on it is quietly frozen. The heartbeat is stamped by the sweep, not
  derived from the snapshot's timestamp - the poller publishes the registry
  before it probes anything, so a stuck poller would otherwise look healthy.
  No cycle yet is Degraded inside the grace window and Unhealthy past it, so a
  restart does not fail readiness for its first seconds and a poller that never
  started cannot hide behind "still starting".

Configuration
- appsettings.json ships tuning defaults with an EMPTY Applications list, so a
  deployment that supplies no registry fails at ConfigPreflight naming the key
  instead of booting into a dashboard of nothing.
- Three registries, because reachability genuinely differs: Development is the
  host-reachable subset (4 instances), Docker is the full 16-instance fleet by
  container DNS name. The rigs publish almost no per-node HTTP port to the host
  - OtOpcUa publishes none (only Traefik :9200, load-balanced across the central
  pair, so it cannot identify a node) and ScadaBridge keeps site :8084
  container-internal. Ports were established by probing the running rigs, not
  read off the compose files, because the two disagree: OtOpcUa site nodes serve
  health on :8080, not the :9000 their compose anchor implies.
- Real Serilog sinks, not the family's empty "Serilog": {}. AddZbSerilog drives
  sinks entirely from configuration and adds none of its own, so the empty
  section produces an app that logs NOWHERE - verified against the running
  HistorianGateway container, whose log stream is completely empty for exactly
  this reason. The HttpClient level override matters too: 16 instances on a 10s
  cadence emit several Information lines a second of pure probe noise.

Container
- HistorianGateway-pattern runtime-only image; the compose stack joins the three
  rig networks as external, so bringing the dashboard up or down cannot disturb
  a rig. The Dockerfile clears ASPNETCORE_HTTP_PORTS as well as ASPNETCORE_URLS:
  on .NET 8+ the base image declares its binding via the former, and clearing
  only the latter leaves Kestrel logging "Overriding address(es)" every boot.
  No healthcheck - aspnet:10.0 has neither curl nor wget, and adding a
  --healthcheck entry point to production code to satisfy Compose is not a
  trade worth making.

Tests
- BootTests boot the real Program.cs: every endpoint anonymous, the page
  rendering from cache while nothing it watches is reachable, the Meters
  allowlist proven by an end-to-end /metrics scrape, and both refuse-to-boot
  paths (missing registry, stale window inside the poll interval).
  The registry is supplied via environment variables, not
  ConfigureAppConfiguration, because ConfigPreflight runs before Build() - when
  the factory's callbacks are applied - and would not see them.

170 tests, 0 warnings. Verified live against the running rigs: 16 cards, 16
gauge series, page and gauge agreeing exactly.
This commit is contained in:
Joseph Doherty
2026-07-24 07:35:32 -04:00
parent 5fe7135e2c
commit 92997b40dc
21 changed files with 1428 additions and 5 deletions
@@ -0,0 +1,37 @@
using ZB.MOM.WW.Health;
namespace ZB.MOM.WW.Overview.Observability;
/// <summary>Registers the dashboard's own health checks.</summary>
public static class ObservabilityServiceCollectionExtensions
{
/// <summary>Check name for the registry-loaded probe.</summary>
public const string RegistryLoadedCheckName = "registry-loaded";
/// <summary>Check name for the poll-cycle liveness probe.</summary>
public const string PollCycleCheckName = "last-poll-cycle-completed";
/// <summary>
/// Adds the two readiness checks that make the dashboard's own <c>/health/ready</c> mean
/// something: that it bound a registry, and that it is still polling it.
/// </summary>
/// <param name="services">The service collection.</param>
/// <returns>The same collection, for chaining.</returns>
/// <remarks>
/// Both are tagged <see cref="ZbHealthTags.Ready"/> only. The dashboard is a single stateless
/// process with no active/standby concept, so it registers nothing on the active tier — its own
/// <c>/health/active</c> is deliberately an empty, always-200 report.
/// </remarks>
public static IServiceCollection AddOverviewHealthChecks(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
services.AddHealthChecks()
.AddTypeActivatedCheck<RegistryLoadedHealthCheck>(
RegistryLoadedCheckName, failureStatus: null, tags: [ZbHealthTags.Ready])
.AddTypeActivatedCheck<PollCycleHealthCheck>(
PollCycleCheckName, failureStatus: null, tags: [ZbHealthTags.Ready]);
return services;
}
}
@@ -0,0 +1,90 @@
using System.Diagnostics.Metrics;
using ZB.MOM.WW.Overview.Polling;
namespace ZB.MOM.WW.Overview.Observability;
/// <summary>
/// The dashboard's own Meter: what it currently believes about every registered instance, and how
/// long its sweeps take.
/// </summary>
/// <remarks>
/// <para>
/// The instance gauge is <em>observable</em> — it is read from the published snapshot at scrape
/// time rather than written on every poll. The snapshot is already the single authority for what
/// the dashboard believes, so deriving the gauge from it makes a disagreement between the page and
/// the metric impossible by construction.
/// </para>
/// <para>
/// The instrument names follow the family convention in
/// <c>components/observability/spec/METRIC-CONVENTIONS.md</c> §2 — lower-case, dot-separated
/// <c>&lt;app&gt;.&lt;subsystem&gt;.&lt;event&gt;</c>, durations in seconds — so the Prometheus
/// exporter renders them as <c>overview_instance_status</c> and
/// <c>overview_poll_duration_seconds</c>.
/// </para>
/// </remarks>
public sealed class OverviewMetrics : IDisposable
{
/// <summary>The app's single Meter name, per the family one-meter-per-app convention.</summary>
public const string MeterName = "ZB.MOM.WW.Overview";
/// <summary>Per-instance status gauge; the value is the <see cref="InstanceStatus"/> ordinal.</summary>
public const string InstanceStatusInstrument = "overview.instance.status";
/// <summary>Sweep-duration histogram, in seconds.</summary>
public const string PollDurationInstrument = "overview.poll.duration";
private readonly Meter _meter;
private readonly Histogram<double> _pollDuration;
private readonly OverviewSnapshotStore _store;
/// <summary>Initializes the meter and its instruments.</summary>
/// <param name="meterFactory">Factory that scopes the meter's lifetime to the container.</param>
/// <param name="store">The snapshot the observable gauge reads at scrape time.</param>
public OverviewMetrics(IMeterFactory meterFactory, OverviewSnapshotStore store)
{
ArgumentNullException.ThrowIfNull(meterFactory);
_store = store ?? throw new ArgumentNullException(nameof(store));
_meter = meterFactory.Create(MeterName);
_meter.CreateObservableGauge(
InstanceStatusInstrument,
ObserveInstanceStatus,
unit: "1",
description: "Derived status of each registered instance (0=Up, 1=Degraded, 2=Down, 3=Unreachable).");
_pollDuration = _meter.CreateHistogram<double>(
PollDurationInstrument,
unit: "s",
description: "Wall-clock duration of one poll sweep across every due instance.");
}
/// <summary>Records the duration of one completed sweep.</summary>
/// <param name="duration">Wall-clock time the sweep took.</param>
public void RecordSweep(TimeSpan duration) => _pollDuration.Record(duration.TotalSeconds);
/// <inheritdoc/>
public void Dispose() => _meter.Dispose();
private IEnumerable<Measurement<int>> ObserveInstanceStatus()
{
foreach (var instance in _store.Current.AllInstances)
{
// Instances that have never been probed are skipped rather than reported as anything.
// Emitting a value for them would publish a status the dashboard does not yet hold —
// and the enum's zero ordinal is Up, so the fabrication would be the reassuring one.
if (instance.Status is not { } status)
continue;
yield return new Measurement<int>(
(int)status,
new KeyValuePair<string, object?>("application", instance.Application),
// Deliberately NOT named "instance": Prometheus attaches its own `instance` label to
// every scraped series, and a colliding metric label is silently renamed to
// `exported_instance`, which no dashboard query would be written against.
new KeyValuePair<string, object?>("node", instance.Instance),
new KeyValuePair<string, object?>("group", instance.Group ?? string.Empty));
}
}
}
@@ -0,0 +1,110 @@
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Overview.Registry;
namespace ZB.MOM.WW.Overview.Observability;
/// <summary>
/// Fails when the poller has stopped sweeping — the one failure mode that would otherwise leave the
/// dashboard looking perfectly healthy while every card it serves quietly went stale.
/// </summary>
/// <remarks>
/// This is what makes the dashboard's own <c>/health/ready</c> honest. Without it the process
/// answers 200 for as long as Kestrel is up, which says nothing about whether the data behind the
/// page is still being refreshed.
/// </remarks>
public sealed class PollCycleHealthCheck : IHealthCheck
{
private readonly PollCycleHeartbeat _heartbeat;
private readonly TimeProvider _timeProvider;
private readonly TimeSpan _grace;
/// <summary>Initializes a new <see cref="PollCycleHealthCheck"/>.</summary>
/// <param name="heartbeat">The poller's completed-sweep heartbeat.</param>
/// <param name="options">The registry, used to derive the tolerated gap.</param>
/// <param name="timeProvider">Clock.</param>
public PollCycleHealthCheck(
PollCycleHeartbeat heartbeat,
IOptions<OverviewOptions> options,
TimeProvider timeProvider)
{
ArgumentNullException.ThrowIfNull(options);
_heartbeat = heartbeat ?? throw new ArgumentNullException(nameof(heartbeat));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_grace = GraceFor(options.Value);
}
/// <summary>
/// The longest gap between completed sweeps that is still considered healthy.
/// </summary>
/// <param name="options">The registry.</param>
/// <returns>The tolerated gap.</returns>
/// <remarks>
/// A sweep completes on every timer tick, and the timer ticks at the fastest configured
/// interval — so the expected gap is one interval plus however long the probes took, which is
/// bounded by the slowest configured timeout. Twice that is comfortably past "slow" and
/// unambiguously "stopped".
/// </remarks>
internal static TimeSpan GraceFor(OverviewOptions options)
{
ArgumentNullException.ThrowIfNull(options);
var timings = options.Applications
.SelectMany(app => app.Instances.Select(instance =>
EffectiveTimings.Resolve(options, app, instance)))
.ToList();
// An empty registry cannot poll at all; RegistryLoadedHealthCheck is the check that reports
// that, so fall back to the global defaults rather than dividing by an empty set here.
var interval = timings.Count > 0
? timings.Min(t => t.PollInterval)
: TimeSpan.FromSeconds(options.PollIntervalSeconds);
var timeout = timings.Count > 0
? timings.Max(t => t.Timeout)
: TimeSpan.FromSeconds(options.TimeoutSeconds);
return 2 * (interval + timeout);
}
/// <inheritdoc/>
public Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
var now = _timeProvider.GetUtcNow();
var last = _heartbeat.LastCompletedUtc;
var data = new Dictionary<string, object>(StringComparer.Ordinal)
{
["graceSeconds"] = Math.Round(_grace.TotalSeconds, 3),
};
if (last is null)
{
// Nothing has swept yet. Within the grace window that is ordinary startup, so report
// Degraded (still a 200) rather than failing readiness for the first few seconds of
// every deployment. Past it, the poller never started and that IS a failure.
var waiting = now - _heartbeat.StartedUtc;
data["waitingSeconds"] = Math.Round(waiting.TotalSeconds, 3);
return Task.FromResult(waiting <= _grace
? HealthCheckResult.Degraded("No poll cycle has completed yet; the poller is still starting.", data: data)
: HealthCheckResult.Unhealthy(
$"No poll cycle has completed in {waiting.TotalSeconds:0.#}s since startup.",
exception: null,
data));
}
var age = now - last.Value;
data["lastCycleUtc"] = last.Value.ToString("O", System.Globalization.CultureInfo.InvariantCulture);
data["ageSeconds"] = Math.Round(age.TotalSeconds, 3);
return Task.FromResult(age <= _grace
? HealthCheckResult.Healthy($"Last poll cycle completed {age.TotalSeconds:0.#}s ago.", data)
: HealthCheckResult.Unhealthy(
$"No poll cycle has completed in {age.TotalSeconds:0.#}s; the displayed data is stale.",
exception: null,
data));
}
}
@@ -0,0 +1,46 @@
namespace ZB.MOM.WW.Overview.Observability;
/// <summary>
/// When the poller last finished a full sweep. Written by the poller, read by
/// <see cref="PollCycleHealthCheck"/>.
/// </summary>
/// <remarks>
/// Deliberately not derived from the published snapshot's <c>GeneratedUtc</c>: the poller publishes
/// the registry once before it probes anything, so a snapshot exists from the moment the host
/// starts. A stuck poller would therefore look like a working one. This records only sweeps that
/// actually completed.
/// </remarks>
public sealed class PollCycleHeartbeat
{
private long _lastCompletedTicks;
/// <summary>Initializes the heartbeat and stamps the host start time.</summary>
/// <param name="timeProvider">Clock.</param>
public PollCycleHeartbeat(TimeProvider timeProvider)
{
ArgumentNullException.ThrowIfNull(timeProvider);
StartedUtc = timeProvider.GetUtcNow();
}
/// <summary>When this heartbeat was constructed — effectively when the host started.</summary>
/// <remarks>
/// Lets the health check distinguish "started a moment ago and has not swept yet" from "has
/// not swept in minutes", which are the same null reading but opposite conclusions.
/// </remarks>
public DateTimeOffset StartedUtc { get; }
/// <summary>When the last full sweep completed, or null if none has.</summary>
public DateTimeOffset? LastCompletedUtc
{
get
{
var ticks = Interlocked.Read(ref _lastCompletedTicks);
return ticks == 0 ? null : new DateTimeOffset(ticks, TimeSpan.Zero);
}
}
/// <summary>Records that a sweep completed.</summary>
/// <param name="at">When it completed.</param>
public void RecordCycleCompleted(DateTimeOffset at) =>
Interlocked.Exchange(ref _lastCompletedTicks, at.UtcTicks);
}
@@ -0,0 +1,52 @@
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Overview.Registry;
namespace ZB.MOM.WW.Overview.Observability;
/// <summary>
/// Reports how much registry the dashboard actually bound, and fails if it bound none.
/// </summary>
/// <remarks>
/// The pre-host <c>ConfigPreflight</c> and the options validator already refuse to start on an
/// empty registry, so the unhealthy branch is a backstop rather than an expected state. The check
/// earns its place on the reported counts: an operator looking at <c>/health/ready</c> after a
/// config change can see how many instances this process is watching without reading its
/// configuration files.
/// </remarks>
public sealed class RegistryLoadedHealthCheck : IHealthCheck
{
private readonly OverviewOptions _options;
/// <summary>Initializes a new <see cref="RegistryLoadedHealthCheck"/>.</summary>
/// <param name="options">The bound registry.</param>
public RegistryLoadedHealthCheck(IOptions<OverviewOptions> options)
{
ArgumentNullException.ThrowIfNull(options);
_options = options.Value;
}
/// <inheritdoc/>
public Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
var applications = _options.Applications.Count;
var instances = _options.Applications.Sum(a => a.Instances.Count);
var data = new Dictionary<string, object>(StringComparer.Ordinal)
{
["applications"] = applications,
["instances"] = instances,
};
return Task.FromResult(instances > 0
? HealthCheckResult.Healthy(
$"Watching {instances} instance(s) across {applications} application(s).",
data)
: HealthCheckResult.Unhealthy(
"The registry contains no instances; the dashboard has nothing to watch.",
exception: null,
data));
}
}
@@ -1,4 +1,5 @@
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Overview.Observability;
using ZB.MOM.WW.Overview.Registry;
namespace ZB.MOM.WW.Overview.Polling;
@@ -29,6 +30,8 @@ public sealed class OverviewPollerService : BackgroundService
private readonly OverviewSnapshotStore _store;
private readonly TimeProvider _timeProvider;
private readonly ILogger<OverviewPollerService> _logger;
private readonly OverviewMetrics _metrics;
private readonly PollCycleHeartbeat _heartbeat;
private readonly List<PollTarget> _targets;
/// <summary>Initializes a new <see cref="OverviewPollerService"/>.</summary>
@@ -36,12 +39,16 @@ public sealed class OverviewPollerService : BackgroundService
/// <param name="httpClientFactory">Factory for the pooled probe client.</param>
/// <param name="store">The snapshot store this service writes to.</param>
/// <param name="timeProvider">Clock and timer source.</param>
/// <param name="metrics">The app's own instruments.</param>
/// <param name="heartbeat">Records that a sweep completed, for the self health check.</param>
/// <param name="logger">Logger.</param>
public OverviewPollerService(
IOptions<OverviewOptions> options,
IHttpClientFactory httpClientFactory,
OverviewSnapshotStore store,
TimeProvider timeProvider,
OverviewMetrics metrics,
PollCycleHeartbeat heartbeat,
ILogger<OverviewPollerService> logger)
{
ArgumentNullException.ThrowIfNull(options);
@@ -49,6 +56,8 @@ public sealed class OverviewPollerService : BackgroundService
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
_store = store ?? throw new ArgumentNullException(nameof(store));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
_heartbeat = heartbeat ?? throw new ArgumentNullException(nameof(heartbeat));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_targets = BuildTargets(options.Value);
}
@@ -97,6 +106,7 @@ public sealed class OverviewPollerService : BackgroundService
// its final state.
cancellationToken.ThrowIfCancellationRequested();
var startedAt = _timeProvider.GetTimestamp();
var now = _timeProvider.GetUtcNow();
var due = _targets.Where(t => IsDue(t, now)).ToList();
@@ -123,6 +133,12 @@ public sealed class OverviewPollerService : BackgroundService
var snapshot = BuildSnapshot(now);
_store.Publish(snapshot);
_metrics.RecordSweep(_timeProvider.GetElapsedTime(startedAt));
// Stamped after the publish, so the heartbeat can never claim a cycle the store did not
// receive — the health check's whole job is to notice exactly that gap.
_heartbeat.RecordCycleCompleted(_timeProvider.GetUtcNow());
return snapshot;
}
@@ -1,4 +1,5 @@
using Microsoft.Extensions.DependencyInjection.Extensions;
using ZB.MOM.WW.Overview.Observability;
namespace ZB.MOM.WW.Overview.Polling;
@@ -15,6 +16,12 @@ public static class PollingServiceCollectionExtensions
services.TryAddSingleton(TimeProvider.System);
services.TryAddSingleton<OverviewSnapshotStore>();
// IMeterFactory comes from AddMetrics, which the default web host already registers; the
// call is idempotent and makes the dependency explicit for any non-web host.
services.AddMetrics();
services.TryAddSingleton<OverviewMetrics>();
services.TryAddSingleton<PollCycleHeartbeat>();
services.AddHttpClient(OverviewPollerService.HttpClientName, client =>
{
// The per-request CancellationTokenSource in ZbHealthReportClient is the only timeout
@@ -1,7 +1,11 @@
using ZB.MOM.WW.Configuration;
using ZB.MOM.WW.Health;
using ZB.MOM.WW.Overview.Components;
using ZB.MOM.WW.Overview.Observability;
using ZB.MOM.WW.Overview.Polling;
using ZB.MOM.WW.Overview.Registry;
using ZB.MOM.WW.Telemetry;
using ZB.MOM.WW.Telemetry.Serilog;
var builder = WebApplication.CreateBuilder(args);
@@ -17,7 +21,20 @@ ConfigPreflight.For(builder.Configuration)
builder.Services.AddValidatedOptions<OverviewOptions, OverviewOptionsValidator>(
builder.Configuration, OverviewOptions.SectionName);
builder.AddZbSerilog(o => o.ServiceName = "overview");
builder.AddZbTelemetry(o =>
{
o.ServiceName = "overview";
// Meters is an ALLOWLIST, not a filter: a Meter absent from this array is never registered with
// the MeterProvider, and its instruments vanish from /metrics with no warning anywhere. The
// dashboard's own gauge is the only thing here that would go missing silently.
o.Meters = [OverviewMetrics.MeterName];
});
builder.Services.AddOverviewPolling();
builder.Services.AddOverviewHealthChecks();
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
@@ -41,6 +58,12 @@ app.UseAntiforgery();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
// The dashboard's own three-tier health surface and Prometheus endpoint. Both are anonymous, like
// every /health/* endpoint this app polls — which also means one Overview instance can be pointed
// at another and would render it correctly.
app.MapZbHealth();
app.MapZbMetrics();
await app.RunAsync();
/// <summary>
@@ -0,0 +1,89 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
// Kestrel endpoint config, NOT ASPNETCORE_URLS. Setting both puts Kestrel into
// explicit-endpoints mode and silently discards one of them — the family's recurring
// URLs-override trap.
"Kestrel": {
"Endpoints": {
"Http": {
"Url": "http://localhost:5320"
}
}
},
// Development registry for `dotnet run` ON THE MAC HOST. This is deliberately a SUBSET of the
// fleet: the dev rigs publish only some node ports to the host, so these are the instances a
// host-run dashboard can genuinely reach. Verified against the running containers 2026-07-24.
//
// ScadaBridge central pair localhost:9001 / :9002 (container :5000 published)
// ScadaBridge site nodes NOT published — their health port (8084) is container-internal
// OtOpcUa (all six nodes) NOT published — only Traefik's load-balanced :9200, which
// round-robins the central pair and so cannot identify a node
// HistorianGateway localhost:5220
// MxAccessGateway windev 10.100.0.48:5130 (needs the VPN up)
//
// Run the containerised dashboard (docker/, environment "Docker") to see the whole fleet: it
// joins the rig networks and addresses every node by container DNS name.
"Overview": {
"PollIntervalSeconds": 10,
"TimeoutSeconds": 3,
"StaleAfterSeconds": 45,
"Applications": [
{
"Name": "ScadaBridge",
"ManagementLabel": "CentralUI",
"Instances": [
{
"Name": "central-a",
"Group": "central",
"BaseUrl": "http://localhost:9001",
"ManagementUrl": "http://localhost:9001/",
"HasActiveRole": true
},
{
"Name": "central-b",
"Group": "central",
"BaseUrl": "http://localhost:9002",
"ManagementUrl": "http://localhost:9002/",
"HasActiveRole": true
}
]
},
{
"Name": "HistorianGateway",
"ManagementLabel": "Dashboard",
"Instances": [
{
"Name": "histgw",
"BaseUrl": "http://localhost:5220",
"ManagementUrl": "http://localhost:5220/",
"HasActiveRole": false,
// Its readiness probe reaches the real historian over the VPN; when that is down the
// probe hangs rather than refusing, so it needs more room than the 3s default before
// it is honestly called unreachable.
"TimeoutSeconds": 10
}
]
},
{
"Name": "MxAccessGateway",
"ManagementLabel": "Dashboard",
"Instances": [
{
"Name": "windev",
"BaseUrl": "http://10.100.0.48:5130",
"ManagementUrl": "http://10.100.0.48:5130/",
"HasActiveRole": false
}
]
}
]
}
}
@@ -0,0 +1,163 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Kestrel": {
"Endpoints": {
"Http": {
"Url": "http://+:5320"
}
}
},
// Full-fleet registry for the CONTAINERISED dashboard, which joins the rig networks
// (otopcua-dev_default, scadabridge-net, zb-historiangw_default) and therefore reaches every
// node by container DNS name — including the ones whose ports are never published to the host.
//
// Ports below were verified by probing the running rigs on 2026-07-24, not read off the compose
// files, because the two disagree:
// OtOpcUa central-1/2 :9000 (ASPNETCORE_URLS)
// OtOpcUa site-* :8080 (NOT 9000 — the site nodes' explicit Kestrel listeners for
// LocalDb sync re-bind the primary HTTP port)
// ScadaBridge central :5000
// ScadaBridge site :8084 (MetricsPort; MapZbHealth lands here from the site-health work)
//
// ManagementUrl values stay host-relative (localhost:*) on purpose: they are followed by the
// OPERATOR'S BROWSER, not by this container, so container DNS names would be dead links.
"Overview": {
"PollIntervalSeconds": 10,
"TimeoutSeconds": 3,
"StaleAfterSeconds": 45,
"Applications": [
{
"Name": "ScadaBridge",
"ManagementLabel": "CentralUI",
"Instances": [
{
"Name": "central-a",
"Group": "central",
"BaseUrl": "http://scadabridge-central-a:5000",
"ManagementUrl": "http://localhost:9001/",
"HasActiveRole": true
},
{
"Name": "central-b",
"Group": "central",
"BaseUrl": "http://scadabridge-central-b:5000",
"ManagementUrl": "http://localhost:9002/",
"HasActiveRole": true
},
{
"Name": "site-a-a",
"Group": "site-a",
"BaseUrl": "http://scadabridge-site-a-a:8084",
"HasActiveRole": true
},
{
"Name": "site-a-b",
"Group": "site-a",
"BaseUrl": "http://scadabridge-site-a-b:8084",
"HasActiveRole": true
},
{
"Name": "site-b-a",
"Group": "site-b",
"BaseUrl": "http://scadabridge-site-b-a:8084",
"HasActiveRole": true
},
{
"Name": "site-b-b",
"Group": "site-b",
"BaseUrl": "http://scadabridge-site-b-b:8084",
"HasActiveRole": true
},
{
"Name": "site-c-a",
"Group": "site-c",
"BaseUrl": "http://scadabridge-site-c-a:8084",
"HasActiveRole": true
},
{
"Name": "site-c-b",
"Group": "site-c",
"BaseUrl": "http://scadabridge-site-c-b:8084",
"HasActiveRole": true
}
]
},
{
"Name": "OtOpcUa",
"ManagementLabel": "AdminUI",
"Instances": [
{
"Name": "central-1",
"Group": "MAIN",
"BaseUrl": "http://central-1:9000",
"ManagementUrl": "http://localhost:9200/",
"HasActiveRole": true
},
{
"Name": "central-2",
"Group": "MAIN",
"BaseUrl": "http://central-2:9000",
"ManagementUrl": "http://localhost:9200/",
"HasActiveRole": true
},
{
"Name": "site-a-1",
"Group": "SITE-A",
"BaseUrl": "http://site-a-1:8080",
"HasActiveRole": true
},
{
"Name": "site-a-2",
"Group": "SITE-A",
"BaseUrl": "http://site-a-2:8080",
"HasActiveRole": true
},
{
"Name": "site-b-1",
"Group": "SITE-B",
"BaseUrl": "http://site-b-1:8080",
"HasActiveRole": true
},
{
"Name": "site-b-2",
"Group": "SITE-B",
"BaseUrl": "http://site-b-2:8080",
"HasActiveRole": true
}
]
},
{
"Name": "HistorianGateway",
"ManagementLabel": "Dashboard",
"Instances": [
{
"Name": "histgw",
"BaseUrl": "http://zb-historiangw:5220",
"ManagementUrl": "http://localhost:5220/",
"HasActiveRole": false,
"TimeoutSeconds": 10
}
]
},
{
"Name": "MxAccessGateway",
"ManagementLabel": "Dashboard",
"Instances": [
{
"Name": "windev",
"BaseUrl": "http://10.100.0.48:5130",
"ManagementUrl": "http://10.100.0.48:5130/",
"HasActiveRole": false
}
]
}
]
}
}
@@ -0,0 +1,54 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
// AddZbSerilog drives its sinks entirely from this section (ReadFrom.Configuration) and adds
// none of its own. An empty "Serilog": {} therefore produces an app that logs NOWHERE — verified
// against the running HistorianGateway container, whose log stream is completely empty for
// exactly this reason. Declaring the sinks here is what keeps this app diagnosable.
"Serilog": {
// Every probe emits four Information-level HttpClient lines. At 16 instances on a 10s cadence
// that is several lines a second of pure noise from an app whose entire job is polling — it
// would bury anything worth reading. Note this override, not Logging:LogLevel, is what governs
// once Serilog takes over.
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft.AspNetCore": "Warning",
"System.Net.Http.HttpClient": "Warning"
}
},
"Using": [
"Serilog.Sinks.Console",
"Serilog.Sinks.File"
],
"WriteTo": [
{ "Name": "Console" },
{
"Name": "File",
"Args": {
"path": "logs/overview-.log",
"rollingInterval": "Day"
}
}
]
},
"Telemetry": {},
// Tuning defaults only. `Applications` is deliberately EMPTY here: the registry is a
// per-deployment fact, and shipping a placeholder fleet would let a misconfigured deployment
// boot and render instances nobody asked about. With no registry supplied, ConfigPreflight
// fails startup and names the missing key. See docker/README.md for the full shape.
"Overview": {
"PollIntervalSeconds": 10,
"TimeoutSeconds": 3,
"StaleAfterSeconds": 45,
"Applications": []
}
}