From 073c6df10e475b6bb65d4d1cac60777485c18e01 Mon Sep 17 00:00:00 2001 From: Matthew Boulton Date: Tue, 5 May 2026 16:30:47 -0700 Subject: [PATCH 01/22] inital commit for a railengine-based status page --- .gitignore | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index f6c2a54..e204c20 100644 --- a/.gitignore +++ b/.gitignore @@ -85,12 +85,21 @@ dmypy.json **/package-lock.json **/bun.lock -**/.vs/* -**/packages/* +**/.vs/ +**/packages/ +[Bb]in/ [Oo]bj/ [Dd]ebug/ [Rr]elease/ +**/TestResults/ +*.user +*.suo +*.rsuser +*.nupkg +*.snupkg +**/publish/ **/Secrets.cs **/secrets.py +/CSharp/Examples/RailenginePoweredStatusPage/appsettings.Development.json From d597109859622428fc939976a01ffb560058cdfb Mon Sep 17 00:00:00 2001 From: Matthew Boulton Date: Tue, 5 May 2026 16:31:16 -0700 Subject: [PATCH 02/22] added page and api for displaying a chart of three metrics --- .../Controllers/MetricsController.cs | 26 +++++ .../Models/MetricRecord.cs | 8 ++ .../RailenginePoweredStatusPage/Program.cs | 22 ++++ .../Properties/launchSettings.json | 38 +++++++ .../RailenginePoweredStatusPage.csproj | 13 +++ .../RailenginePoweredStatusPage.sln | 25 +++++ .../appsettings.Development.sample.json | 12 +++ .../appsettings.json | 13 +++ .../wwwroot/index.html | 101 ++++++++++++++++++ .../wwwroot/styles.css | 49 +++++++++ 10 files changed, 307 insertions(+) create mode 100644 CSharp/Examples/RailenginePoweredStatusPage/Controllers/MetricsController.cs create mode 100644 CSharp/Examples/RailenginePoweredStatusPage/Models/MetricRecord.cs create mode 100644 CSharp/Examples/RailenginePoweredStatusPage/Program.cs create mode 100644 CSharp/Examples/RailenginePoweredStatusPage/Properties/launchSettings.json create mode 100644 CSharp/Examples/RailenginePoweredStatusPage/RailenginePoweredStatusPage.csproj create mode 100644 CSharp/Examples/RailenginePoweredStatusPage/RailenginePoweredStatusPage.sln create mode 100644 CSharp/Examples/RailenginePoweredStatusPage/appsettings.Development.sample.json create mode 100644 CSharp/Examples/RailenginePoweredStatusPage/appsettings.json create mode 100644 CSharp/Examples/RailenginePoweredStatusPage/wwwroot/index.html create mode 100644 CSharp/Examples/RailenginePoweredStatusPage/wwwroot/styles.css diff --git a/CSharp/Examples/RailenginePoweredStatusPage/Controllers/MetricsController.cs b/CSharp/Examples/RailenginePoweredStatusPage/Controllers/MetricsController.cs new file mode 100644 index 0000000..f2ce553 --- /dev/null +++ b/CSharp/Examples/RailenginePoweredStatusPage/Controllers/MetricsController.cs @@ -0,0 +1,26 @@ +using Microsoft.AspNetCore.Mvc; +using Railengine; +using RailenginePoweredStatusPage.Models; + +namespace RailenginePoweredStatusPage.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class MetricsController : ControllerBase +{ + private readonly RailengineClient railengineClient; + private readonly Guid engineId; + + public MetricsController(RailengineClient railengineClient, IConfiguration configuration) + { + this.railengineClient = railengineClient; + engineId = Guid.Parse(configuration["RailEngine:EngineId"]!); + } + + [HttpGet] + public async Task>> GetMetrics() + { + // TODO: query railengineClient with engineId + return Ok(new List()); + } +} diff --git a/CSharp/Examples/RailenginePoweredStatusPage/Models/MetricRecord.cs b/CSharp/Examples/RailenginePoweredStatusPage/Models/MetricRecord.cs new file mode 100644 index 0000000..295b684 --- /dev/null +++ b/CSharp/Examples/RailenginePoweredStatusPage/Models/MetricRecord.cs @@ -0,0 +1,8 @@ +namespace RailenginePoweredStatusPage.Models; + +public class MetricRecord +{ + public string Metric { get; set; } = string.Empty; + public long Timestamp { get; set; } + public double Value { get; set; } +} diff --git a/CSharp/Examples/RailenginePoweredStatusPage/Program.cs b/CSharp/Examples/RailenginePoweredStatusPage/Program.cs new file mode 100644 index 0000000..60ae884 --- /dev/null +++ b/CSharp/Examples/RailenginePoweredStatusPage/Program.cs @@ -0,0 +1,22 @@ +using Railengine; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddControllers(); +builder.Services.AddHttpClient(); +builder.Services.AddSingleton(sp => +{ + var config = sp.GetRequiredService(); + var httpClient = sp.GetRequiredService().CreateClient(); + var pat = config["RailEngine:PAT"]!; + return new RailengineClient(httpClient, pat); +}); + +var app = builder.Build(); + +app.UseDefaultFiles(); +app.UseStaticFiles(); + +app.MapControllers(); + +app.Run(); diff --git a/CSharp/Examples/RailenginePoweredStatusPage/Properties/launchSettings.json b/CSharp/Examples/RailenginePoweredStatusPage/Properties/launchSettings.json new file mode 100644 index 0000000..f6fe80d --- /dev/null +++ b/CSharp/Examples/RailenginePoweredStatusPage/Properties/launchSettings.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:32971", + "sslPort": 44321 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5142", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7042;http://localhost:5142", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/CSharp/Examples/RailenginePoweredStatusPage/RailenginePoweredStatusPage.csproj b/CSharp/Examples/RailenginePoweredStatusPage/RailenginePoweredStatusPage.csproj new file mode 100644 index 0000000..4c79571 --- /dev/null +++ b/CSharp/Examples/RailenginePoweredStatusPage/RailenginePoweredStatusPage.csproj @@ -0,0 +1,13 @@ + + + + net8.0 + enable + enable + + + + + + + diff --git a/CSharp/Examples/RailenginePoweredStatusPage/RailenginePoweredStatusPage.sln b/CSharp/Examples/RailenginePoweredStatusPage/RailenginePoweredStatusPage.sln new file mode 100644 index 0000000..ab2bb57 --- /dev/null +++ b/CSharp/Examples/RailenginePoweredStatusPage/RailenginePoweredStatusPage.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.10.35122.118 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RailenginePoweredStatusPage", "RailenginePoweredStatusPage.csproj", "{B8DD1C94-F2B5-4183-9FED-9ABC883CFA50}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {B8DD1C94-F2B5-4183-9FED-9ABC883CFA50}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B8DD1C94-F2B5-4183-9FED-9ABC883CFA50}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B8DD1C94-F2B5-4183-9FED-9ABC883CFA50}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B8DD1C94-F2B5-4183-9FED-9ABC883CFA50}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {63C4A898-DC79-4258-9B09-FF9ED957ADD6} + EndGlobalSection +EndGlobal diff --git a/CSharp/Examples/RailenginePoweredStatusPage/appsettings.Development.sample.json b/CSharp/Examples/RailenginePoweredStatusPage/appsettings.Development.sample.json new file mode 100644 index 0000000..9b823ed --- /dev/null +++ b/CSharp/Examples/RailenginePoweredStatusPage/appsettings.Development.sample.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "RailEngine": { + "PAT": "[your-pat-token-here]", + "EngineId": "[your-engine-id-here]" + } +} diff --git a/CSharp/Examples/RailenginePoweredStatusPage/appsettings.json b/CSharp/Examples/RailenginePoweredStatusPage/appsettings.json new file mode 100644 index 0000000..61ff680 --- /dev/null +++ b/CSharp/Examples/RailenginePoweredStatusPage/appsettings.json @@ -0,0 +1,13 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "RailEngine": { + "PAT": "[your-pat-token-here]", + "EngineId": "[your-engine-id-here]" + } +} diff --git a/CSharp/Examples/RailenginePoweredStatusPage/wwwroot/index.html b/CSharp/Examples/RailenginePoweredStatusPage/wwwroot/index.html new file mode 100644 index 0000000..e78892a --- /dev/null +++ b/CSharp/Examples/RailenginePoweredStatusPage/wwwroot/index.html @@ -0,0 +1,101 @@ + + + + + + Status Page + + + + +

System Status

+ +
+
+

Engine Load

+
+
+
+

Team Members

+
+
+
+

Logs

+
+
+
+ + + + + + diff --git a/CSharp/Examples/RailenginePoweredStatusPage/wwwroot/styles.css b/CSharp/Examples/RailenginePoweredStatusPage/wwwroot/styles.css new file mode 100644 index 0000000..e793f21 --- /dev/null +++ b/CSharp/Examples/RailenginePoweredStatusPage/wwwroot/styles.css @@ -0,0 +1,49 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: system-ui, sans-serif; + background: #0f1117; + color: #e2e8f0; + min-height: 100vh; + padding: 2rem; +} + +h1 { + font-size: 1.5rem; + font-weight: 600; + margin-bottom: 2rem; + color: #f8fafc; +} + +.charts { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); + gap: 1.5rem; +} + +.card { + background: #1e2130; + border: 1px solid #2d3248; + border-radius: 12px; + padding: 1.5rem; +} + +.card h2 { + font-size: 0.85rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #94a3b8; + margin-bottom: 1rem; +} + +.chart-wrap { + position: relative; + height: 220px; +} + +.error { + color: #f87171; + font-size: 0.875rem; + margin-top: 1rem; +} From 4441d8ac72ad891e55be06c0a30424f3839504f2 Mon Sep 17 00:00:00 2001 From: Matthew Boulton Date: Tue, 5 May 2026 16:34:32 -0700 Subject: [PATCH 03/22] added readme file for status page example --- .../RailenginePoweredStatusPage/README.md | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 CSharp/Examples/RailenginePoweredStatusPage/README.md diff --git a/CSharp/Examples/RailenginePoweredStatusPage/README.md b/CSharp/Examples/RailenginePoweredStatusPage/README.md new file mode 100644 index 0000000..412ba3c --- /dev/null +++ b/CSharp/Examples/RailenginePoweredStatusPage/README.md @@ -0,0 +1,31 @@ +# Railengine Powered Status Page + +A minimal ASP.NET Core web app that demonstrates using the [Railengine](https://railtown.ai) SDK to retrieve metrics and display them as live charts on a status page. + +## What it does + +The app exposes a `/api/metrics` endpoint that queries a Railengine engine for metric records, then serves a status page that renders the results as three Chart.js line charts — one per metric. The page auto-refreshes every 5 minutes. + +## Expected metric shape + +The engine is expected to return records in the following format: + +```json +{ "metric": "engine-load", "timestamp": 1778023020646, "value": 64.2 } +{ "metric": "team-members", "timestamp": 1778023021948, "value": 1240 } +{ "metric": "logs", "timestamp": 1778023023341, "value": 0.133333 } +``` + +Each record has a `metric` name, a Unix millisecond `timestamp`, and a numeric `value`. Multiple records per metric are supported and will be plotted as a time series. + +## Getting started + +1. Copy `appsettings.Development.sample.json` to `appsettings.Development.json` +2. Fill in your Railengine PAT and engine ID +3. Run the app: + +```bash +dotnet run +``` + +The status page is served at `https://localhost:/`. From 3b4ff2ee8497d375bd234bb03e8800aed02a13bd Mon Sep 17 00:00:00 2001 From: Matthew Boulton Date: Tue, 5 May 2026 16:39:47 -0700 Subject: [PATCH 04/22] added allowed IPs setting so the status page can be hidden from unauthorized users --- .../Middleware/IPAllowlistMiddleware.cs | 29 +++++++++++++++++++ .../RailenginePoweredStatusPage/Program.cs | 3 ++ .../RailenginePoweredStatusPage/README.md | 17 ++++++++++- .../appsettings.Development.sample.json | 3 +- .../appsettings.json | 3 +- .../wwwroot/index.html | 4 +++ 6 files changed, 56 insertions(+), 3 deletions(-) create mode 100644 CSharp/Examples/RailenginePoweredStatusPage/Middleware/IPAllowlistMiddleware.cs diff --git a/CSharp/Examples/RailenginePoweredStatusPage/Middleware/IPAllowlistMiddleware.cs b/CSharp/Examples/RailenginePoweredStatusPage/Middleware/IPAllowlistMiddleware.cs new file mode 100644 index 0000000..d5f43b4 --- /dev/null +++ b/CSharp/Examples/RailenginePoweredStatusPage/Middleware/IPAllowlistMiddleware.cs @@ -0,0 +1,29 @@ +using System.Net; + +namespace RailenginePoweredStatusPage.Middleware; + +public class IPAllowlistMiddleware +{ + private readonly RequestDelegate next; + private readonly HashSet allowedIPs; + + public IPAllowlistMiddleware(RequestDelegate next, IConfiguration configuration) + { + this.next = next; + var entries = configuration.GetSection("AllowedIPs").Get() ?? []; + allowedIPs = entries.Select(IPAddress.Parse).ToHashSet(); + } + + public async Task InvokeAsync(HttpContext context) + { + var remoteIP = context.Connection.RemoteIpAddress; + + if (remoteIP is null || !allowedIPs.Contains(remoteIP.MapToIPv4()) && !allowedIPs.Contains(remoteIP.MapToIPv6())) + { + context.Response.StatusCode = StatusCodes.Status403Forbidden; + return; + } + + await next(context); + } +} diff --git a/CSharp/Examples/RailenginePoweredStatusPage/Program.cs b/CSharp/Examples/RailenginePoweredStatusPage/Program.cs index 60ae884..4c15d70 100644 --- a/CSharp/Examples/RailenginePoweredStatusPage/Program.cs +++ b/CSharp/Examples/RailenginePoweredStatusPage/Program.cs @@ -1,4 +1,5 @@ using Railengine; +using RailenginePoweredStatusPage.Middleware; var builder = WebApplication.CreateBuilder(args); @@ -14,6 +15,8 @@ var app = builder.Build(); +app.UseMiddleware(); + app.UseDefaultFiles(); app.UseStaticFiles(); diff --git a/CSharp/Examples/RailenginePoweredStatusPage/README.md b/CSharp/Examples/RailenginePoweredStatusPage/README.md index 412ba3c..bae9367 100644 --- a/CSharp/Examples/RailenginePoweredStatusPage/README.md +++ b/CSharp/Examples/RailenginePoweredStatusPage/README.md @@ -18,11 +18,26 @@ The engine is expected to return records in the following format: Each record has a `metric` name, a Unix millisecond `timestamp`, and a numeric `value`. Multiple records per metric are supported and will be plotted as a time series. +## Configuration + +### IP allowlist + +Access to the site is restricted by IP address. Any request from an IP not on the allowlist receives a `403 Forbidden` response. + +The allowed IPs are configured via the `AllowedIPs` array in `appsettings.json`: + +```json +"AllowedIPs": [ "127.0.0.1", "::1" ] +``` + +The default permits localhost only. Add the IP addresses of any machines that should be able to view the status page. + ## Getting started 1. Copy `appsettings.Development.sample.json` to `appsettings.Development.json` 2. Fill in your Railengine PAT and engine ID -3. Run the app: +3. Add any additional IP addresses to `AllowedIPs` as needed +4. Run the app: ```bash dotnet run diff --git a/CSharp/Examples/RailenginePoweredStatusPage/appsettings.Development.sample.json b/CSharp/Examples/RailenginePoweredStatusPage/appsettings.Development.sample.json index 9b823ed..d95b2f7 100644 --- a/CSharp/Examples/RailenginePoweredStatusPage/appsettings.Development.sample.json +++ b/CSharp/Examples/RailenginePoweredStatusPage/appsettings.Development.sample.json @@ -8,5 +8,6 @@ "RailEngine": { "PAT": "[your-pat-token-here]", "EngineId": "[your-engine-id-here]" - } + }, + "AllowedIPs": [ "127.0.0.1", "::1" ] } diff --git a/CSharp/Examples/RailenginePoweredStatusPage/appsettings.json b/CSharp/Examples/RailenginePoweredStatusPage/appsettings.json index 61ff680..ffccb6f 100644 --- a/CSharp/Examples/RailenginePoweredStatusPage/appsettings.json +++ b/CSharp/Examples/RailenginePoweredStatusPage/appsettings.json @@ -9,5 +9,6 @@ "RailEngine": { "PAT": "[your-pat-token-here]", "EngineId": "[your-engine-id-here]" - } + }, + "AllowedIPs": [ "127.0.0.1", "::1" ] } diff --git a/CSharp/Examples/RailenginePoweredStatusPage/wwwroot/index.html b/CSharp/Examples/RailenginePoweredStatusPage/wwwroot/index.html index e78892a..9ec5388 100644 --- a/CSharp/Examples/RailenginePoweredStatusPage/wwwroot/index.html +++ b/CSharp/Examples/RailenginePoweredStatusPage/wwwroot/index.html @@ -77,6 +77,9 @@

Logs

try { const records = await loadMetrics(); + document.getElementById('error').hidden = true; + document.querySelector('.charts').hidden = false; + for (const { key, canvasId, color } of METRICS) { const subset = records .filter(r => r.metric === key) @@ -88,6 +91,7 @@

Logs

buildChart(canvasId, labels, values, color); } } catch (err) { + document.querySelector('.charts').hidden = true; const el = document.getElementById('error'); el.textContent = `Failed to load metrics: ${err.message}`; el.hidden = false; From c4d2437975f2feed049db2641fe6ce63bca72e85 Mon Sep 17 00:00:00 2001 From: Matthew Boulton Date: Tue, 5 May 2026 16:45:35 -0700 Subject: [PATCH 05/22] added call to list storage documents --- .../Controllers/MetricsController.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/CSharp/Examples/RailenginePoweredStatusPage/Controllers/MetricsController.cs b/CSharp/Examples/RailenginePoweredStatusPage/Controllers/MetricsController.cs index f2ce553..174cca8 100644 --- a/CSharp/Examples/RailenginePoweredStatusPage/Controllers/MetricsController.cs +++ b/CSharp/Examples/RailenginePoweredStatusPage/Controllers/MetricsController.cs @@ -20,7 +20,12 @@ public MetricsController(RailengineClient railengineClient, IConfiguration confi [HttpGet] public async Task>> GetMetrics() { - // TODO: query railengineClient with engineId - return Ok(new List()); + var results = await railengineClient.ListStorageDocuments( + engineId, + pageNumber: 1, + pageSize: 100 + ); + + return Ok(results); } } From 1b2d62f185c17e7f73f5dcf92beaaa8b28cff359 Mon Sep 17 00:00:00 2001 From: Matthew Boulton Date: Wed, 6 May 2026 10:36:16 -0700 Subject: [PATCH 06/22] fixed charts and json casing issue, stop hard refresh and just refresh charts, added footer --- .../Controllers/MetricsController.cs | 2 +- .../Models/MetricRecord.cs | 7 +++ ...ilengine_logo_dark-1-scaled-1-2048x585.png | Bin 0 -> 20008 bytes .../wwwroot/index.html | 49 ++++++++++++------ .../wwwroot/styles.css | 35 +++++++++++++ 5 files changed, 77 insertions(+), 16 deletions(-) create mode 100644 CSharp/Examples/RailenginePoweredStatusPage/wwwroot/cropped-Railengine_logo_dark-1-scaled-1-2048x585.png diff --git a/CSharp/Examples/RailenginePoweredStatusPage/Controllers/MetricsController.cs b/CSharp/Examples/RailenginePoweredStatusPage/Controllers/MetricsController.cs index 174cca8..3217060 100644 --- a/CSharp/Examples/RailenginePoweredStatusPage/Controllers/MetricsController.cs +++ b/CSharp/Examples/RailenginePoweredStatusPage/Controllers/MetricsController.cs @@ -26,6 +26,6 @@ public async Task>> GetMetrics() pageSize: 100 ); - return Ok(results); + return Ok(results.Items.Select(i => i.Document).ToList()); } } diff --git a/CSharp/Examples/RailenginePoweredStatusPage/Models/MetricRecord.cs b/CSharp/Examples/RailenginePoweredStatusPage/Models/MetricRecord.cs index 295b684..3c5bf83 100644 --- a/CSharp/Examples/RailenginePoweredStatusPage/Models/MetricRecord.cs +++ b/CSharp/Examples/RailenginePoweredStatusPage/Models/MetricRecord.cs @@ -1,8 +1,15 @@ +using System.Text.Json.Serialization; + namespace RailenginePoweredStatusPage.Models; public class MetricRecord { + [JsonPropertyName("metric")] public string Metric { get; set; } = string.Empty; + + [JsonPropertyName("timestamp")] public long Timestamp { get; set; } + + [JsonPropertyName("value")] public double Value { get; set; } } diff --git a/CSharp/Examples/RailenginePoweredStatusPage/wwwroot/cropped-Railengine_logo_dark-1-scaled-1-2048x585.png b/CSharp/Examples/RailenginePoweredStatusPage/wwwroot/cropped-Railengine_logo_dark-1-scaled-1-2048x585.png new file mode 100644 index 0000000000000000000000000000000000000000..7cf5aa995433e2e210ab871dc632761cd6433550 GIT binary patch literal 20008 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 wSelwynwlFB2$lo_WqC - Status Page + railtown.ai system metrics -

System Status

- +

railtown.ai system metrics

-

Engine Load

+

Railengine

-

Team Members

+

Users

-

Logs

+

Conductr Logs

+ + diff --git a/CSharp/Examples/RailenginePoweredStatusPage/wwwroot/styles.css b/CSharp/Examples/RailenginePoweredStatusPage/wwwroot/styles.css index e793f21..e65bc42 100644 --- a/CSharp/Examples/RailenginePoweredStatusPage/wwwroot/styles.css +++ b/CSharp/Examples/RailenginePoweredStatusPage/wwwroot/styles.css @@ -21,6 +21,10 @@ h1 { gap: 1.5rem; } +.charts[hidden] { + display: none; +} + .card { background: #1e2130; border: 1px solid #2d3248; @@ -29,6 +33,10 @@ h1 { } .card h2 { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.5rem; font-size: 0.85rem; font-weight: 500; text-transform: uppercase; @@ -37,6 +45,13 @@ h1 { margin-bottom: 1rem; } +.card h2 .latest { + font-size: 1.25rem; + font-weight: 600; + letter-spacing: 0; + color: #f8fafc; +} + .chart-wrap { position: relative; height: 220px; @@ -47,3 +62,23 @@ h1 { font-size: 0.875rem; margin-top: 1rem; } + +.powered-by { + display: flex; + align-items: center; + justify-content: center; + gap: 0.6rem; + margin-top: 3rem; + color: #64748b; + font-size: 0.8rem; + letter-spacing: 0.05em; +} + +.powered-by img { + height: 2.8rem; + width: auto; + display: block; + background: #e2e8f0; + padding: 0.6rem 1.2rem; + border-radius: 6px; +} From 944aab375930c5559cde765ff76d0f16ff84b6b8 Mon Sep 17 00:00:00 2001 From: Matthew Boulton Date: Wed, 6 May 2026 10:47:50 -0700 Subject: [PATCH 07/22] created the METRICS config so any number of metrics can be added, implemented hideRepeats which removes repetitive data points from the chart. --- .../RailenginePoweredStatusPage/README.md | 26 ++++- .../wwwroot/index.html | 96 ++++++++++++------- 2 files changed, 88 insertions(+), 34 deletions(-) diff --git a/CSharp/Examples/RailenginePoweredStatusPage/README.md b/CSharp/Examples/RailenginePoweredStatusPage/README.md index bae9367..789132f 100644 --- a/CSharp/Examples/RailenginePoweredStatusPage/README.md +++ b/CSharp/Examples/RailenginePoweredStatusPage/README.md @@ -4,7 +4,7 @@ A minimal ASP.NET Core web app that demonstrates using the [Railengine](https:// ## What it does -The app exposes a `/api/metrics` endpoint that queries a Railengine engine for metric records, then serves a status page that renders the results as three Chart.js line charts — one per metric. The page auto-refreshes every 5 minutes. +The app exposes a `/api/metrics` endpoint that queries a Railengine engine for metric records, then serves a status page that renders the results as one Chart.js line chart per configured metric. Each card header shows the most recent value with its unit, rounded to 2 decimal places. The page fetches updated data every 5 minutes (configurable via `REFRESH_INTERVAL_MS` in `wwwroot/index.html`) and updates the charts in place — no full page reload. ## Expected metric shape @@ -18,6 +18,30 @@ The engine is expected to return records in the following format: Each record has a `metric` name, a Unix millisecond `timestamp`, and a numeric `value`. Multiple records per metric are supported and will be plotted as a time series. +The C# `MetricRecord` model deserializes from camelCase JSON via `[JsonPropertyName]` attributes. If you copy the model into another project and use PascalCase JSON content, all values will silently deserialize to their type defaults (zeros, empty strings) — adjust the attributes (or your stored JSON) to match. + +## Customizing metrics + +To adapt the page for a different set of metrics, edit only the `METRICS` array near the top of the script in `wwwroot/index.html`: + +```js +const METRICS = [ + { key: 'engine-load', label: 'Railengine', unit: 'bytes/min', color: '#6366f1' }, + { key: 'team-members', label: 'Users', unit: 'users', color: '#22d3ee', hideRepeats: true }, + { key: 'logs', label: 'Conductr Logs', unit: 'logs/min', color: '#34d399' }, +]; +``` + +Each entry: + +- `key` — must match the `metric` field on records returned by `/api/metrics` +- `label` — shown as the card header +- `unit` — appended after the latest reading (e.g. `5.23 bytes/min`) +- `color` — the line and fill color (any CSS color) +- `hideRepeats` — optional, defaults to `false`. When `true`, point markers are suppressed for samples whose value matches the previous one — useful for metrics that report constantly when you only care about changes (e.g. user counts). The line itself stays continuous; only the dots are hidden. + +Cards are generated from this list at page load, so adding, removing, renaming, or recoloring a metric is a one-line change. The page title and `

` heading are still hardcoded in `wwwroot/index.html` if you want to rebrand those too. + ## Configuration ### IP allowlist diff --git a/CSharp/Examples/RailenginePoweredStatusPage/wwwroot/index.html b/CSharp/Examples/RailenginePoweredStatusPage/wwwroot/index.html index 3248143..dd2207f 100644 --- a/CSharp/Examples/RailenginePoweredStatusPage/wwwroot/index.html +++ b/CSharp/Examples/RailenginePoweredStatusPage/wwwroot/index.html @@ -9,20 +9,7 @@

railtown.ai system metrics

-
-
-

Railengine

-
-
-
-

Users

-
-
-
-

Conductr Logs

-
-
-
+
@@ -34,19 +21,65 @@

Conductr Logs

From abfde6de40b1925001ba9cf0a5da37fcb0ee9856 Mon Sep 17 00:00:00 2001 From: Matthew Boulton Date: Wed, 6 May 2026 10:53:23 -0700 Subject: [PATCH 08/22] made the readme examples more general --- .../Examples/RailenginePoweredStatusPage/README.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/CSharp/Examples/RailenginePoweredStatusPage/README.md b/CSharp/Examples/RailenginePoweredStatusPage/README.md index 789132f..dcb2295 100644 --- a/CSharp/Examples/RailenginePoweredStatusPage/README.md +++ b/CSharp/Examples/RailenginePoweredStatusPage/README.md @@ -11,9 +11,10 @@ The app exposes a `/api/metrics` endpoint that queries a Railengine engine for m The engine is expected to return records in the following format: ```json -{ "metric": "engine-load", "timestamp": 1778023020646, "value": 64.2 } -{ "metric": "team-members", "timestamp": 1778023021948, "value": 1240 } -{ "metric": "logs", "timestamp": 1778023023341, "value": 0.133333 } +{ "metric": "latency-p95", "timestamp": 1778023020646, "value": 87.3 } +{ "metric": "error-rate", "timestamp": 1778023021948, "value": 2.4 } +{ "metric": "active-users", "timestamp": 1778023023341, "value": 1240 } +{ "metric": "requests", "timestamp": 1778023024702, "value": 3742 } ``` Each record has a `metric` name, a Unix millisecond `timestamp`, and a numeric `value`. Multiple records per metric are supported and will be plotted as a time series. @@ -26,9 +27,10 @@ To adapt the page for a different set of metrics, edit only the `METRICS` array ```js const METRICS = [ - { key: 'engine-load', label: 'Railengine', unit: 'bytes/min', color: '#6366f1' }, - { key: 'team-members', label: 'Users', unit: 'users', color: '#22d3ee', hideRepeats: true }, - { key: 'logs', label: 'Conductr Logs', unit: 'logs/min', color: '#34d399' }, + { key: 'latency-p95', label: 'API Latency (p95)', unit: 'ms', color: '#22d3ee' }, + { key: 'error-rate', label: 'Error Rate', unit: 'errors/min', color: '#f59e0b' }, + { key: 'active-users', label: 'Active Users', unit: 'users', color: '#34d399', hideRepeats: true }, + { key: 'requests', label: 'Requests', unit: 'req/min', color: '#6366f1' }, ]; ``` From cb0f1c4bb9ea24dbcbd1a5935c3f1d38ea6202ba Mon Sep 17 00:00:00 2001 From: Matthew Boulton Date: Wed, 6 May 2026 10:54:58 -0700 Subject: [PATCH 09/22] cleanup references to engine/Railengine --- CSharp/Examples/RailenginePoweredStatusPage/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CSharp/Examples/RailenginePoweredStatusPage/README.md b/CSharp/Examples/RailenginePoweredStatusPage/README.md index dcb2295..91e4795 100644 --- a/CSharp/Examples/RailenginePoweredStatusPage/README.md +++ b/CSharp/Examples/RailenginePoweredStatusPage/README.md @@ -4,11 +4,11 @@ A minimal ASP.NET Core web app that demonstrates using the [Railengine](https:// ## What it does -The app exposes a `/api/metrics` endpoint that queries a Railengine engine for metric records, then serves a status page that renders the results as one Chart.js line chart per configured metric. Each card header shows the most recent value with its unit, rounded to 2 decimal places. The page fetches updated data every 5 minutes (configurable via `REFRESH_INTERVAL_MS` in `wwwroot/index.html`) and updates the charts in place — no full page reload. +The app exposes a `/api/metrics` endpoint that queries Railengine for metric records, then serves a status page that renders the results as one Chart.js line chart per configured metric. Each card header shows the most recent value with its unit, rounded to 2 decimal places. The page fetches updated data every 5 minutes (configurable via `REFRESH_INTERVAL_MS` in `wwwroot/index.html`) and updates the charts in place — no full page reload. ## Expected metric shape -The engine is expected to return records in the following format: +Railengine is expected to return records in the following format: ```json { "metric": "latency-p95", "timestamp": 1778023020646, "value": 87.3 } From 600ffc59e69a3b5e38ccecb5abe333bf3287e112 Mon Sep 17 00:00:00 2001 From: Matthew Boulton Date: Wed, 6 May 2026 11:00:28 -0700 Subject: [PATCH 10/22] updated csharp readme with more instructions and references to the sample app --- CSharp/README.md | 80 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 78 insertions(+), 2 deletions(-) diff --git a/CSharp/README.md b/CSharp/README.md index 0206521..966f210 100644 --- a/CSharp/README.md +++ b/CSharp/README.md @@ -1,5 +1,81 @@ # Railengine samples that use the C# SDK -Coming soon! +This folder contains C# / .NET examples that use the [Railengine](https://railengine.ai) SDK. The SDK is published on NuGet and works in any .NET 8+ application. -[Quick start](https://www.nuget.org/packages/Railengine.Ingestion) +## Prerequisites + +- [.NET 8 SDK](https://dotnet.microsoft.com/download) or later +- A free [Railengine](https://railengine.ai) account + +## Set up Railengine + +1. Sign up for a free account at [Railengine](https://railengine.ai) +2. Create an Agent project +3. Create a new Engine with a schema that matches the data you want to store +4. From the engine settings, generate the credentials you need: + - **PAT** — Personal Access Token, used for retrieval + - **Engine Token** — used for ingestion + - **Engine ID** — shown on the engine page + +For a worked end-to-end setup with an example schema, see the [TypeScript Expense Tracker](../TypeScript/nextjs-expensify) — the engine setup flow is the same regardless of language. + +## Install the NuGet packages + +The Railengine C# SDK is split into separate packages so you can install only what you need: + +- **[Railengine.Retrieval](https://www.nuget.org/packages/Railengine.Retrieval)** — query stored documents (list, get by ID, index search, vector search). Used by the status page example. +- **[Railengine.Ingestion](https://www.nuget.org/packages/Railengine.Ingestion)** — upsert and delete documents. + +Install via the .NET CLI: + +```bash +dotnet add package Railengine.Retrieval +dotnet add package Railengine.Ingestion +``` + +Or add a `` to your `.csproj`: + +```xml + +``` + +## Configure credentials + +Examples in this folder read credentials from `appsettings.Development.json` (committed only as `.sample.json` for safety). A typical configuration block: + +```json +"RailEngine": { + "PAT": "[your-pat-token-here]", + "EngineId": "[your-engine-id-here]" +} +``` + +Register `RailengineClient` as a singleton in `Program.cs`: + +```csharp +using Railengine; + +builder.Services.AddHttpClient(); +builder.Services.AddSingleton(sp => +{ + var config = sp.GetRequiredService(); + var httpClient = sp.GetRequiredService().CreateClient(); + var pat = config["RailEngine:PAT"]!; + return new RailengineClient(httpClient, pat); +}); +``` + +Then inject `RailengineClient` into your controllers or services. + +## Examples + +- **[RailenginePoweredStatusPage](Examples/RailenginePoweredStatusPage)** — ASP.NET Core status page that pulls metric records from Railengine and renders them as live Chart.js line charts. Demonstrates `RailengineClient.ListStorageDocuments`. + +## Run an example + +```bash +cd Examples/RailenginePoweredStatusPage +cp appsettings.Development.sample.json appsettings.Development.json +# edit appsettings.Development.json with your PAT and Engine ID +dotnet run +``` From 96b3ea6ce4c67cf27293d2c03cf5a54df6f97493 Mon Sep 17 00:00:00 2001 From: Matthew Boulton Date: Wed, 6 May 2026 11:04:58 -0700 Subject: [PATCH 11/22] clarified .net requirements --- CSharp/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CSharp/README.md b/CSharp/README.md index 966f210..87ae19d 100644 --- a/CSharp/README.md +++ b/CSharp/README.md @@ -1,10 +1,10 @@ # Railengine samples that use the C# SDK -This folder contains C# / .NET examples that use the [Railengine](https://railengine.ai) SDK. The SDK is published on NuGet and works in any .NET 8+ application. +This folder contains C# / .NET examples that use the [Railengine](https://railengine.ai) SDK. The SDK targets **.NET Standard 2.0**, so it can be consumed from any compatible runtime (.NET Framework 4.6.1+, .NET Core 2.0+, .NET 5+, etc.). The sample apps in this folder target **.NET 8**. ## Prerequisites -- [.NET 8 SDK](https://dotnet.microsoft.com/download) or later +- [.NET 8 SDK](https://dotnet.microsoft.com/download) (required to build and run the samples; the SDK itself only requires a .NET Standard 2.0–compatible runtime) - A free [Railengine](https://railengine.ai) account ## Set up Railengine From e2c7950d392c46e3c2dae87be8733a7d751664b5 Mon Sep 17 00:00:00 2001 From: Matthew Boulton Date: Wed, 6 May 2026 11:36:23 -0700 Subject: [PATCH 12/22] added a daily insight to the status page using railengine MCP --- .../Controllers/InsightController.cs | 24 ++++ .../RailenginePoweredStatusPage/Program.cs | 3 + .../RailenginePoweredStatusPage/README.md | 15 +- .../Services/DailyInsight.cs | 8 ++ .../Services/DailyInsightService.cs | 130 ++++++++++++++++++ .../appsettings.Development.sample.json | 3 + .../appsettings.json | 9 +- .../wwwroot/index.html | 36 ++++- .../wwwroot/styles.css | 35 +++++ 9 files changed, 259 insertions(+), 4 deletions(-) create mode 100644 CSharp/Examples/RailenginePoweredStatusPage/Controllers/InsightController.cs create mode 100644 CSharp/Examples/RailenginePoweredStatusPage/Services/DailyInsight.cs create mode 100644 CSharp/Examples/RailenginePoweredStatusPage/Services/DailyInsightService.cs diff --git a/CSharp/Examples/RailenginePoweredStatusPage/Controllers/InsightController.cs b/CSharp/Examples/RailenginePoweredStatusPage/Controllers/InsightController.cs new file mode 100644 index 0000000..28bb178 --- /dev/null +++ b/CSharp/Examples/RailenginePoweredStatusPage/Controllers/InsightController.cs @@ -0,0 +1,24 @@ +using Microsoft.AspNetCore.Mvc; +using RailenginePoweredStatusPage.Services; + +namespace RailenginePoweredStatusPage.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class InsightController : ControllerBase +{ + private readonly DailyInsight insight; + + public InsightController(DailyInsight insight) + { + this.insight = insight; + } + + [HttpGet] + public IActionResult Get() => Ok(new + { + text = insight.Text, + generatedAt = insight.GeneratedAt, + error = insight.Error, + }); +} diff --git a/CSharp/Examples/RailenginePoweredStatusPage/Program.cs b/CSharp/Examples/RailenginePoweredStatusPage/Program.cs index 4c15d70..275bfbb 100644 --- a/CSharp/Examples/RailenginePoweredStatusPage/Program.cs +++ b/CSharp/Examples/RailenginePoweredStatusPage/Program.cs @@ -1,5 +1,6 @@ using Railengine; using RailenginePoweredStatusPage.Middleware; +using RailenginePoweredStatusPage.Services; var builder = WebApplication.CreateBuilder(args); @@ -12,6 +13,8 @@ var pat = config["RailEngine:PAT"]!; return new RailengineClient(httpClient, pat); }); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); var app = builder.Build(); diff --git a/CSharp/Examples/RailenginePoweredStatusPage/README.md b/CSharp/Examples/RailenginePoweredStatusPage/README.md index 91e4795..d4be078 100644 --- a/CSharp/Examples/RailenginePoweredStatusPage/README.md +++ b/CSharp/Examples/RailenginePoweredStatusPage/README.md @@ -44,6 +44,16 @@ Each entry: Cards are generated from this list at page load, so adding, removing, renaming, or recoloring a metric is a one-line change. The page title and `

` heading are still hardcoded in `wwwroot/index.html` if you want to rebrand those too. +## Daily insight (optional) + +If an `Anthropic:ApiKey` is configured, the app runs a `BackgroundService` that wakes once every 24 hours, calls the Claude API with the engine's [Railengine MCP server](https://cndr.railtown.ai/api/mcp/engine/{EngineId}) attached as a tool source, and asks Claude to review the recent metric data. The result is exposed at `/api/insight` and rendered as a "Daily Insight" card above the charts, with a "generated Xh ago" timestamp. + +This demonstrates Claude autonomously deciding which Railengine tools to call (`getEngineStorageDocuments`, `searchEngineDocuments`, …) and reasoning over the results — useful as a worked example of the MCP client beta in C#. The same PAT used for the SDK is forwarded as the MCP server's bearer token. + +If `Anthropic:ApiKey` is not set, the service logs a notice and exits cleanly; the insight card simply doesn't appear. + +> **Heads-up for local development:** the 24-hour timer is in-memory only, so each app restart triggers a fresh generation and a corresponding Anthropic API call. If you're iterating on the app you may want to comment out `AddHostedService()` in `Program.cs` until you're ready to test it. Once deployed to a long-running host, restarts are rare and this isn't a concern. + ## Configuration ### IP allowlist @@ -62,8 +72,9 @@ The default permits localhost only. Add the IP addresses of any machines that sh 1. Copy `appsettings.Development.sample.json` to `appsettings.Development.json` 2. Fill in your Railengine PAT and engine ID -3. Add any additional IP addresses to `AllowedIPs` as needed -4. Run the app: +3. (Optional) Set `Anthropic:ApiKey` to enable the daily insight card +4. Add any additional IP addresses to `AllowedIPs` as needed +5. Run the app: ```bash dotnet run diff --git a/CSharp/Examples/RailenginePoweredStatusPage/Services/DailyInsight.cs b/CSharp/Examples/RailenginePoweredStatusPage/Services/DailyInsight.cs new file mode 100644 index 0000000..dfd7b50 --- /dev/null +++ b/CSharp/Examples/RailenginePoweredStatusPage/Services/DailyInsight.cs @@ -0,0 +1,8 @@ +namespace RailenginePoweredStatusPage.Services; + +public class DailyInsight +{ + public string? Text { get; set; } + public DateTimeOffset? GeneratedAt { get; set; } + public string? Error { get; set; } +} diff --git a/CSharp/Examples/RailenginePoweredStatusPage/Services/DailyInsightService.cs b/CSharp/Examples/RailenginePoweredStatusPage/Services/DailyInsightService.cs new file mode 100644 index 0000000..e509a98 --- /dev/null +++ b/CSharp/Examples/RailenginePoweredStatusPage/Services/DailyInsightService.cs @@ -0,0 +1,130 @@ +using System.Net.Http.Json; +using System.Text.Json; + +namespace RailenginePoweredStatusPage.Services; + +public class DailyInsightService : BackgroundService +{ + private readonly IHttpClientFactory httpClientFactory; + private readonly DailyInsight state; + private readonly ILogger logger; + private readonly string apiKey; + private readonly string apiVersion; + private readonly string beta; + private readonly string model; + private readonly Guid engineId; + private readonly string pat; + private readonly string mcpServerBaseUrl; + private readonly string mcpServerName; + + private static readonly TimeSpan Interval = TimeSpan.FromHours(24); + + private const string Prompt = + "You are an automated reviewer for a status page dashboard. " + + "Use the available Railengine tools to fetch the most recent metric records stored in this engine, then summarize what you find.\n\n" + + "Output format (strict):\n" + + "- One single line per metric, nothing else.\n" + + "- Format: \"{Metric name}: {one-sentence insight including the latest value and any notable trend}\"\n" + + "- Plain text only. No markdown, no headers, no bullets, no emoji, no bold.\n" + + "- No preamble, no commentary about your process, no closing remarks.\n" + + "- If a metric is flat, say so concisely."; + + public DailyInsightService( + IHttpClientFactory httpClientFactory, + DailyInsight state, + IConfiguration configuration, + ILogger logger) + { + this.httpClientFactory = httpClientFactory; + this.state = state; + this.logger = logger; + apiKey = configuration["Anthropic:ApiKey"] ?? ""; + apiVersion = configuration["Anthropic:ApiVersion"]!; + beta = configuration["Anthropic:Beta"]!; + model = configuration["Anthropic:Model"]!; + engineId = Guid.Parse(configuration["RailEngine:EngineId"]!); + pat = configuration["RailEngine:PAT"]!; + mcpServerBaseUrl = configuration["RailEngine:McpServerBaseUrl"]!.TrimEnd('/'); + mcpServerName = configuration["RailEngine:McpServerName"]!; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + if (string.IsNullOrEmpty(apiKey)) + { + logger.LogInformation("Anthropic:ApiKey not configured; daily insight disabled."); + return; + } + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await GenerateAsync(stoppingToken); + state.Error = null; + } + catch (Exception ex) + { + logger.LogError(ex, "Daily insight generation failed"); + state.Error = ex.Message; + } + + try { await Task.Delay(Interval, stoppingToken); } + catch (TaskCanceledException) { return; } + } + } + + private async Task GenerateAsync(CancellationToken ct) + { + using var client = httpClientFactory.CreateClient(); + using var request = new HttpRequestMessage(HttpMethod.Post, "https://api.anthropic.com/v1/messages"); + request.Headers.Add("x-api-key", apiKey); + request.Headers.Add("anthropic-version", apiVersion); + request.Headers.Add("anthropic-beta", beta); + + var body = new + { + model, + max_tokens = 1024, + mcp_servers = new[] + { + new + { + type = "url", + url = $"{mcpServerBaseUrl}/{engineId}", + name = mcpServerName, + authorization_token = pat, + }, + }, + messages = new[] + { + new { role = "user", content = Prompt }, + }, + }; + + request.Content = JsonContent.Create(body); + + using var response = await client.SendAsync(request, ct); + var responseBody = await response.Content.ReadAsStringAsync(ct); + + if (!response.IsSuccessStatusCode) + { + throw new InvalidOperationException($"Claude API returned {(int)response.StatusCode}: {responseBody}"); + } + + using var doc = JsonDocument.Parse(responseBody); + var content = doc.RootElement.GetProperty("content"); + + // Take the final text block. Earlier text blocks are narration emitted + // between MCP tool calls ("I'll start by exploring…") which we don't want. + var text = content.EnumerateArray() + .Where(b => b.GetProperty("type").GetString() == "text") + .Select(b => b.GetProperty("text").GetString()) + .Where(s => !string.IsNullOrWhiteSpace(s)) + .LastOrDefault() ?? ""; + + state.Text = text; + state.GeneratedAt = DateTimeOffset.UtcNow; + logger.LogInformation("Daily insight generated ({Length} chars)", text.Length); + } +} diff --git a/CSharp/Examples/RailenginePoweredStatusPage/appsettings.Development.sample.json b/CSharp/Examples/RailenginePoweredStatusPage/appsettings.Development.sample.json index d95b2f7..4d1fdca 100644 --- a/CSharp/Examples/RailenginePoweredStatusPage/appsettings.Development.sample.json +++ b/CSharp/Examples/RailenginePoweredStatusPage/appsettings.Development.sample.json @@ -9,5 +9,8 @@ "PAT": "[your-pat-token-here]", "EngineId": "[your-engine-id-here]" }, + "Anthropic": { + "ApiKey": "[your-anthropic-api-key-here]" + }, "AllowedIPs": [ "127.0.0.1", "::1" ] } diff --git a/CSharp/Examples/RailenginePoweredStatusPage/appsettings.json b/CSharp/Examples/RailenginePoweredStatusPage/appsettings.json index ffccb6f..40d6b85 100644 --- a/CSharp/Examples/RailenginePoweredStatusPage/appsettings.json +++ b/CSharp/Examples/RailenginePoweredStatusPage/appsettings.json @@ -8,7 +8,14 @@ "AllowedHosts": "*", "RailEngine": { "PAT": "[your-pat-token-here]", - "EngineId": "[your-engine-id-here]" + "EngineId": "[your-engine-id-here]", + "McpServerBaseUrl": "https://cndr.railtown.ai/api/mcp/engine", + "McpServerName": "railengine" + }, + "Anthropic": { + "ApiVersion": "2023-06-01", + "Beta": "mcp-client-2025-04-04", + "Model": "claude-sonnet-4-6" }, "AllowedIPs": [ "127.0.0.1", "::1" ] } diff --git a/CSharp/Examples/RailenginePoweredStatusPage/wwwroot/index.html b/CSharp/Examples/RailenginePoweredStatusPage/wwwroot/index.html index dd2207f..3026ae0 100644 --- a/CSharp/Examples/RailenginePoweredStatusPage/wwwroot/index.html +++ b/CSharp/Examples/RailenginePoweredStatusPage/wwwroot/index.html @@ -9,6 +9,12 @@

railtown.ai system metrics

+ + +
@@ -112,6 +118,33 @@

railtown.ai system metrics

return res.json(); } + function formatTimeAgo(iso) { + if (!iso) return ''; + const ms = Date.now() - new Date(iso).getTime(); + const minutes = Math.floor(ms / 60000); + if (minutes < 1) return 'just now'; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; + } + + async function loadInsight() { + const section = document.querySelector('.insight'); + try { + const res = await fetch('/api/insight'); + if (!res.ok) { section.hidden = true; return; } + const data = await res.json(); + if (!data.text) { section.hidden = true; return; } + section.querySelector('.insight-body').textContent = data.text; + section.querySelector('.insight-meta').textContent = `generated ${formatTimeAgo(data.generatedAt)}`; + section.hidden = false; + } catch { + section.hidden = true; + } + } + async function refresh() { try { const records = await loadMetrics(); @@ -148,7 +181,8 @@

railtown.ai system metrics

buildCards(METRICS, document.querySelector('.charts')); refresh(); - setInterval(refresh, REFRESH_INTERVAL_MS); + loadInsight(); + setInterval(() => { refresh(); loadInsight(); }, REFRESH_INTERVAL_MS); diff --git a/CSharp/Examples/RailenginePoweredStatusPage/wwwroot/styles.css b/CSharp/Examples/RailenginePoweredStatusPage/wwwroot/styles.css index e65bc42..c0efb70 100644 --- a/CSharp/Examples/RailenginePoweredStatusPage/wwwroot/styles.css +++ b/CSharp/Examples/RailenginePoweredStatusPage/wwwroot/styles.css @@ -15,6 +15,41 @@ h1 { color: #f8fafc; } +.insight { + background: #1e2130; + border: 1px solid #2d3248; + border-radius: 12px; + padding: 1.5rem; + margin-bottom: 1.5rem; +} + +.insight h2 { + display: flex; + align-items: baseline; + gap: 0.75rem; + font-size: 0.85rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #94a3b8; + margin-bottom: 0.75rem; +} + +.insight-meta { + font-size: 0.75rem; + text-transform: none; + letter-spacing: 0; + color: #64748b; + font-weight: 400; +} + +.insight-body { + font-size: 0.95rem; + line-height: 1.5; + color: #e2e8f0; + white-space: pre-wrap; +} + .charts { display: grid; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); From 6209ca1111aa03b8aa9551ce0b7ae953d68e0f95 Mon Sep 17 00:00:00 2001 From: Matthew Boulton Date: Wed, 6 May 2026 14:29:58 -0700 Subject: [PATCH 13/22] ignore azure publish profiles and dependencies --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index e204c20..185ad45 100644 --- a/.gitignore +++ b/.gitignore @@ -100,6 +100,8 @@ dmypy.json **/publish/ **/Secrets.cs **/secrets.py - +**/PublishProfiles +**/ServiceDependencies /CSharp/Examples/RailenginePoweredStatusPage/appsettings.Development.json + From e762bc2f3839b9816d424a61cb93f7077b268c83 Mon Sep 17 00:00:00 2001 From: Matthew Boulton Date: Wed, 6 May 2026 16:05:47 -0700 Subject: [PATCH 14/22] use streaming and optimise model and prompt to get a quick response --- .../Services/DailyInsightService.cs | 107 +++++++++++++++--- .../appsettings.json | 2 +- 2 files changed, 92 insertions(+), 17 deletions(-) diff --git a/CSharp/Examples/RailenginePoweredStatusPage/Services/DailyInsightService.cs b/CSharp/Examples/RailenginePoweredStatusPage/Services/DailyInsightService.cs index e509a98..2edf56d 100644 --- a/CSharp/Examples/RailenginePoweredStatusPage/Services/DailyInsightService.cs +++ b/CSharp/Examples/RailenginePoweredStatusPage/Services/DailyInsightService.cs @@ -1,4 +1,5 @@ using System.Net.Http.Json; +using System.Text; using System.Text.Json; namespace RailenginePoweredStatusPage.Services; @@ -18,10 +19,11 @@ public class DailyInsightService : BackgroundService private readonly string mcpServerName; private static readonly TimeSpan Interval = TimeSpan.FromHours(24); + private static readonly TimeSpan RequestTimeout = TimeSpan.FromMinutes(10); private const string Prompt = - "You are an automated reviewer for a status page dashboard. " + - "Use the available Railengine tools to fetch the most recent metric records stored in this engine, then summarize what you find.\n\n" + + "You are an automated reviewer for a status page dashboard.\n\n" + + "Use AT MOST 2 Railengine tool calls. Call `getEngineStorageDocuments` to fetch the 50 most recent metric records in a single call, then summarize. Do not use the vector or embedding search tools — this engine has no vectors. Do not make additional exploratory calls.\n\n" + "Output format (strict):\n" + "- One single line per metric, nothing else.\n" + "- Format: \"{Metric name}: {one-sentence insight including the latest value and any notable trend}\"\n" + @@ -76,7 +78,15 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) private async Task GenerateAsync(CancellationToken ct) { + logger.LogInformation("Starting daily insight generation…"); + + // Bound the entire streamed call by RequestTimeout via a linked CTS, + // and disable HttpClient.Timeout so it doesn't cancel the stream mid-read. + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(RequestTimeout); + using var client = httpClientFactory.CreateClient(); + client.Timeout = Timeout.InfiniteTimeSpan; using var request = new HttpRequestMessage(HttpMethod.Post, "https://api.anthropic.com/v1/messages"); request.Headers.Add("x-api-key", apiKey); request.Headers.Add("anthropic-version", apiVersion); @@ -85,7 +95,8 @@ private async Task GenerateAsync(CancellationToken ct) var body = new { model, - max_tokens = 1024, + max_tokens = 256, + stream = true, mcp_servers = new[] { new @@ -104,27 +115,91 @@ private async Task GenerateAsync(CancellationToken ct) request.Content = JsonContent.Create(body); - using var response = await client.SendAsync(request, ct); - var responseBody = await response.Content.ReadAsStringAsync(ct); + // ResponseHeadersRead returns as soon as headers arrive; stream is read below. + using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cts.Token); if (!response.IsSuccessStatusCode) { - throw new InvalidOperationException($"Claude API returned {(int)response.StatusCode}: {responseBody}"); + var errBody = await response.Content.ReadAsStringAsync(cts.Token); + throw new InvalidOperationException($"Claude API returned {(int)response.StatusCode}: {errBody}"); } - using var doc = JsonDocument.Parse(responseBody); - var content = doc.RootElement.GetProperty("content"); - - // Take the final text block. Earlier text blocks are narration emitted - // between MCP tool calls ("I'll start by exploring…") which we don't want. - var text = content.EnumerateArray() - .Where(b => b.GetProperty("type").GetString() == "text") - .Select(b => b.GetProperty("text").GetString()) - .Where(s => !string.IsNullOrWhiteSpace(s)) - .LastOrDefault() ?? ""; + var text = await ReadTextFromStreamAsync(response, cts.Token); state.Text = text; state.GeneratedAt = DateTimeOffset.UtcNow; logger.LogInformation("Daily insight generated ({Length} chars)", text.Length); } + + // Parse Anthropic's SSE stream and accumulate text per content-block index. + // We only care about blocks of type "text"; mcp_tool_use and mcp_tool_result + // blocks are tracked but their content ignored. The final assistant answer + // is the highest-indexed text block. + private static async Task ReadTextFromStreamAsync(HttpResponseMessage response, CancellationToken ct) + { + var blocks = new Dictionary(); + + using var stream = await response.Content.ReadAsStreamAsync(ct); + using var reader = new StreamReader(stream); + + string? eventType = null; + string? line; + while ((line = await reader.ReadLineAsync(ct)) != null) + { + if (line.StartsWith("event:")) + { + eventType = line["event:".Length..].Trim(); + continue; + } + if (!line.StartsWith("data:")) continue; + + var data = line["data:".Length..].Trim(); + if (string.IsNullOrEmpty(data)) continue; + + using var doc = JsonDocument.Parse(data); + var root = doc.RootElement; + + switch (eventType) + { + case "content_block_start": + { + var index = root.GetProperty("index").GetInt32(); + var block = root.GetProperty("content_block"); + var blockType = block.GetProperty("type").GetString() ?? ""; + var sb = new StringBuilder(); + if (blockType == "text" && block.TryGetProperty("text", out var initialText)) + { + sb.Append(initialText.GetString()); + } + blocks[index] = (blockType, sb); + break; + } + case "content_block_delta": + { + var index = root.GetProperty("index").GetInt32(); + var delta = root.GetProperty("delta"); + if (delta.GetProperty("type").GetString() == "text_delta" + && blocks.TryGetValue(index, out var entry)) + { + entry.Text.Append(delta.GetProperty("text").GetString()); + } + break; + } + case "error": + { + var msg = root.TryGetProperty("error", out var err) && err.TryGetProperty("message", out var m) + ? m.GetString() + : data; + throw new InvalidOperationException($"Claude API stream error: {msg}"); + } + } + } + + return blocks + .OrderBy(kvp => kvp.Key) + .Where(kvp => kvp.Value.Type == "text") + .Select(kvp => kvp.Value.Text.ToString()) + .Where(s => !string.IsNullOrWhiteSpace(s)) + .LastOrDefault() ?? ""; + } } diff --git a/CSharp/Examples/RailenginePoweredStatusPage/appsettings.json b/CSharp/Examples/RailenginePoweredStatusPage/appsettings.json index 40d6b85..9254a89 100644 --- a/CSharp/Examples/RailenginePoweredStatusPage/appsettings.json +++ b/CSharp/Examples/RailenginePoweredStatusPage/appsettings.json @@ -15,7 +15,7 @@ "Anthropic": { "ApiVersion": "2023-06-01", "Beta": "mcp-client-2025-04-04", - "Model": "claude-sonnet-4-6" + "Model": "claude-haiku-4-5-20251001" }, "AllowedIPs": [ "127.0.0.1", "::1" ] } From 69a4c81f9a45acdc5655a35e2a19fd30caa087ab Mon Sep 17 00:00:00 2001 From: Matthew Boulton Date: Wed, 6 May 2026 16:09:39 -0700 Subject: [PATCH 15/22] provide more info about the model and prompt choice for the example insights --- CSharp/Examples/RailenginePoweredStatusPage/README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CSharp/Examples/RailenginePoweredStatusPage/README.md b/CSharp/Examples/RailenginePoweredStatusPage/README.md index d4be078..52a3a9b 100644 --- a/CSharp/Examples/RailenginePoweredStatusPage/README.md +++ b/CSharp/Examples/RailenginePoweredStatusPage/README.md @@ -50,6 +50,10 @@ If an `Anthropic:ApiKey` is configured, the app runs a `BackgroundService` that This demonstrates Claude autonomously deciding which Railengine tools to call (`getEngineStorageDocuments`, `searchEngineDocuments`, …) and reasoning over the results — useful as a worked example of the MCP client beta in C#. The same PAT used for the SDK is forwarded as the MCP server's bearer token. +The model is configurable via `Anthropic:Model` in `appsettings.json` and defaults to `claude-haiku-4-5-20251001` for speed and cost; swap to a Sonnet or Opus identifier if you'd prefer richer prose at the expense of latency. + +The prompt in `Services/DailyInsightService.cs` is deliberately tuned for short, plain-text output (low `max_tokens`, "no markdown" instructions, one line per metric). If you adapt it for your own Railengine and want a longer or formatted summary, expect to relax those constraints. + If `Anthropic:ApiKey` is not set, the service logs a notice and exits cleanly; the insight card simply doesn't appear. > **Heads-up for local development:** the 24-hour timer is in-memory only, so each app restart triggers a fresh generation and a corresponding Anthropic API call. If you're iterating on the app you may want to comment out `AddHostedService()` in `Program.cs` until you're ready to test it. Once deployed to a long-running host, restarts are rare and this isn't a concern. From 6c1751f59726a8e210f1cd4575faf097533238e4 Mon Sep 17 00:00:00 2001 From: Matthew Boulton Date: Wed, 6 May 2026 16:48:48 -0700 Subject: [PATCH 16/22] fix linter errors and supress linter verbose output - ascii art and successes --- .github/workflows/super-linter.yml | 4 ++ .../RailenginePoweredStatusPage/README.md | 15 ++++-- .../Services/DailyInsightService.cs | 49 +++++++++---------- .../wwwroot/index.html | 2 +- .../wwwroot/styles.css | 8 ++- 5 files changed, 48 insertions(+), 30 deletions(-) diff --git a/.github/workflows/super-linter.yml b/.github/workflows/super-linter.yml index 7915c66..7f4aa73 100644 --- a/.github/workflows/super-linter.yml +++ b/.github/workflows/super-linter.yml @@ -39,3 +39,7 @@ jobs: VALIDATE_TYPESCRIPT_STANDARD: false DEFAULT_BRANCH: "main" GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Suppress per-file "successfully linted" INFO lines and tool banners. + # Only WARN/ERROR output is shown. Switch back to VERBOSE if you need + # to debug which files a linter is touching. + LOG_LEVEL: ERROR diff --git a/CSharp/Examples/RailenginePoweredStatusPage/README.md b/CSharp/Examples/RailenginePoweredStatusPage/README.md index 52a3a9b..3e99c7d 100644 --- a/CSharp/Examples/RailenginePoweredStatusPage/README.md +++ b/CSharp/Examples/RailenginePoweredStatusPage/README.md @@ -4,7 +4,11 @@ A minimal ASP.NET Core web app that demonstrates using the [Railengine](https:// ## What it does -The app exposes a `/api/metrics` endpoint that queries Railengine for metric records, then serves a status page that renders the results as one Chart.js line chart per configured metric. Each card header shows the most recent value with its unit, rounded to 2 decimal places. The page fetches updated data every 5 minutes (configurable via `REFRESH_INTERVAL_MS` in `wwwroot/index.html`) and updates the charts in place — no full page reload. +The app exposes a `/api/metrics` endpoint that queries Railengine for metric records, then +serves a status page that renders the results as one Chart.js line chart per configured metric. +Each card header shows the most recent value with its unit, rounded to 2 decimal places. The +page fetches updated data every 5 minutes (configurable via `REFRESH_INTERVAL_MS` in +`wwwroot/index.html`) and updates the charts in place — no full page reload. ## Expected metric shape @@ -46,13 +50,18 @@ Cards are generated from this list at page load, so adding, removing, renaming, ## Daily insight (optional) -If an `Anthropic:ApiKey` is configured, the app runs a `BackgroundService` that wakes once every 24 hours, calls the Claude API with the engine's [Railengine MCP server](https://cndr.railtown.ai/api/mcp/engine/{EngineId}) attached as a tool source, and asks Claude to review the recent metric data. The result is exposed at `/api/insight` and rendered as a "Daily Insight" card above the charts, with a "generated Xh ago" timestamp. +If an `Anthropic:ApiKey` is configured, the app runs a `BackgroundService` that wakes once every +24 hours, calls the Claude API with the engine's +[Railengine MCP server](https://cndr.railtown.ai/api/mcp/engine/{EngineId}) attached as a tool +source, and asks Claude to review the recent metric data. The result is exposed at +`/api/insight` and rendered as a "Daily Insight" card above the charts, with a "generated Xh +ago" timestamp. This demonstrates Claude autonomously deciding which Railengine tools to call (`getEngineStorageDocuments`, `searchEngineDocuments`, …) and reasoning over the results — useful as a worked example of the MCP client beta in C#. The same PAT used for the SDK is forwarded as the MCP server's bearer token. The model is configurable via `Anthropic:Model` in `appsettings.json` and defaults to `claude-haiku-4-5-20251001` for speed and cost; swap to a Sonnet or Opus identifier if you'd prefer richer prose at the expense of latency. -The prompt in `Services/DailyInsightService.cs` is deliberately tuned for short, plain-text output (low `max_tokens`, "no markdown" instructions, one line per metric). If you adapt it for your own Railengine and want a longer or formatted summary, expect to relax those constraints. +The prompt in `Services/DailyInsightService.cs` is deliberately tuned for short, plain-text output (low `max_tokens`, "no Markdown" instructions, one line per metric). If you adapt it for your own Railengine and want a longer or formatted summary, expect to relax those constraints. If `Anthropic:ApiKey` is not set, the service logs a notice and exits cleanly; the insight card simply doesn't appear. diff --git a/CSharp/Examples/RailenginePoweredStatusPage/Services/DailyInsightService.cs b/CSharp/Examples/RailenginePoweredStatusPage/Services/DailyInsightService.cs index 2edf56d..9e6fb83 100644 --- a/CSharp/Examples/RailenginePoweredStatusPage/Services/DailyInsightService.cs +++ b/CSharp/Examples/RailenginePoweredStatusPage/Services/DailyInsightService.cs @@ -1,4 +1,3 @@ -using System.Net.Http.Json; using System.Text; using System.Text.Json; @@ -162,36 +161,36 @@ private static async Task ReadTextFromStreamAsync(HttpResponseMessage re switch (eventType) { case "content_block_start": - { - var index = root.GetProperty("index").GetInt32(); - var block = root.GetProperty("content_block"); - var blockType = block.GetProperty("type").GetString() ?? ""; - var sb = new StringBuilder(); - if (blockType == "text" && block.TryGetProperty("text", out var initialText)) { - sb.Append(initialText.GetString()); + var index = root.GetProperty("index").GetInt32(); + var block = root.GetProperty("content_block"); + var blockType = block.GetProperty("type").GetString() ?? ""; + var sb = new StringBuilder(); + if (blockType == "text" && block.TryGetProperty("text", out var initialText)) + { + sb.Append(initialText.GetString()); + } + blocks[index] = (blockType, sb); + break; } - blocks[index] = (blockType, sb); - break; - } case "content_block_delta": - { - var index = root.GetProperty("index").GetInt32(); - var delta = root.GetProperty("delta"); - if (delta.GetProperty("type").GetString() == "text_delta" - && blocks.TryGetValue(index, out var entry)) { - entry.Text.Append(delta.GetProperty("text").GetString()); + var index = root.GetProperty("index").GetInt32(); + var delta = root.GetProperty("delta"); + if (delta.GetProperty("type").GetString() == "text_delta" + && blocks.TryGetValue(index, out var entry)) + { + entry.Text.Append(delta.GetProperty("text").GetString()); + } + break; } - break; - } case "error": - { - var msg = root.TryGetProperty("error", out var err) && err.TryGetProperty("message", out var m) - ? m.GetString() - : data; - throw new InvalidOperationException($"Claude API stream error: {msg}"); - } + { + var msg = root.TryGetProperty("error", out var err) && err.TryGetProperty("message", out var m) + ? m.GetString() + : data; + throw new InvalidOperationException($"Claude API stream error: {msg}"); + } } } diff --git a/CSharp/Examples/RailenginePoweredStatusPage/wwwroot/index.html b/CSharp/Examples/RailenginePoweredStatusPage/wwwroot/index.html index 3026ae0..14d78fd 100644 --- a/CSharp/Examples/RailenginePoweredStatusPage/wwwroot/index.html +++ b/CSharp/Examples/RailenginePoweredStatusPage/wwwroot/index.html @@ -5,9 +5,9 @@ railtown.ai system metrics - +

railtown.ai system metrics