Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
073c6df
inital commit for a railengine-based status page
mbrailtown May 5, 2026
d597109
added page and api for displaying a chart of three metrics
mbrailtown May 5, 2026
4441d8a
added readme file for status page example
mbrailtown May 5, 2026
3b4ff2e
added allowed IPs setting so the status page can be hidden from unaut…
mbrailtown May 5, 2026
c4d2437
added call to list storage documents
mbrailtown May 5, 2026
1b2d62f
fixed charts and json casing issue, stop hard refresh and just refres…
mbrailtown May 6, 2026
944aab3
created the METRICS config so any number of metrics can be added, imp…
mbrailtown May 6, 2026
abfde6d
made the readme examples more general
mbrailtown May 6, 2026
cb0f1c4
cleanup references to engine/Railengine
mbrailtown May 6, 2026
600ffc5
updated csharp readme with more instructions and references to the sa…
mbrailtown May 6, 2026
96b3ea6
clarified .net requirements
mbrailtown May 6, 2026
e2c7950
added a daily insight to the status page using railengine MCP
mbrailtown May 6, 2026
6209ca1
ignore azure publish profiles and dependencies
mbrailtown May 6, 2026
e762bc2
use streaming and optimise model and prompt to get a quick response
mbrailtown May 6, 2026
69a4c81
provide more info about the model and prompt choice for the example i…
mbrailtown May 6, 2026
6c1751f
fix linter errors and supress linter verbose output - ascii art and s…
mbrailtown May 6, 2026
6c5fcf2
added external metric sources to supplement the railengine metrics, m…
mbrailtown May 7, 2026
7b5e75c
improved logo styles
mbrailtown May 7, 2026
fddba5c
fixed linter error
mbrailtown May 7, 2026
91803b1
handle null metric values and timestamps
mbrailtown May 7, 2026
b0cc8c5
more compact styles to fit lower res screens
mbrailtown May 7, 2026
ac45fad
added additional sample metrics
mbrailtown May 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/super-linter.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
15 changes: 13 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -85,12 +85,23 @@ 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
**/PublishProfiles
**/ServiceDependencies

/CSharp/Examples/RailenginePoweredStatusPage/appsettings.Development.json

Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "10.0.7",
"commands": [
"dotnet-ef"
],
"rollForward": false
}
}
}
Original file line number Diff line number Diff line change
@@ -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,
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using Microsoft.AspNetCore.Mvc;
using Railengine;
using RailenginePoweredStatusPage.Models;
using RailenginePoweredStatusPage.Services;

namespace RailenginePoweredStatusPage.Controllers;

[ApiController]
[Route("api/[controller]")]
public class MetricsController : ControllerBase
{
private readonly RailengineClient railengineClient;
private readonly Guid engineId;
private readonly IEnumerable<IExternalMetricSource> externalSources;

public MetricsController(RailengineClient railengineClient, IConfiguration configuration, IEnumerable<IExternalMetricSource> externalSources)
{
this.railengineClient = railengineClient;
this.externalSources = externalSources;
engineId = Guid.Parse(configuration["RailEngine:EngineId"]!);
}

[HttpGet]
public async Task<ActionResult<List<MetricRecord>>> GetMetrics()
{
var page1 = await railengineClient.ListStorageDocuments<MetricRecord>(engineId, pageNumber: 1, pageSize: 100);
var page2 = await railengineClient.ListStorageDocuments<MetricRecord>(engineId, pageNumber: 2, pageSize: 100);

var railengineRecords = page1.Items.Concat(page2.Items)
.Select(i => i.Document)
.Where(r => r.Timestamp.HasValue && r.Value.HasValue)
.OrderByDescending(r => r.Timestamp)
.Take(144);

var externalTasks = externalSources.Select(s => s.GetRecordsAsync());
var externalRecords = (await Task.WhenAll(externalTasks)).SelectMany(r => r);

return Ok(railengineRecords.Concat(externalRecords).ToList());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using System.Net;

namespace RailenginePoweredStatusPage.Middleware;

public class IPAllowlistMiddleware
{
private readonly RequestDelegate next;
private readonly HashSet<IPAddress> allowedIPs;

public IPAllowlistMiddleware(RequestDelegate next, IConfiguration configuration)
{
this.next = next;
var entries = configuration.GetSection("AllowedIPs").Get<string[]>() ?? [];
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);
}
}
15 changes: 15 additions & 0 deletions CSharp/Examples/RailenginePoweredStatusPage/Models/MetricRecord.cs
Original file line number Diff line number Diff line change
@@ -0,0 +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; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using System.Text.Json.Serialization;

namespace RailenginePoweredStatusPage.Models;

public class PyPiOverallResponse
{
[JsonPropertyName("data")]
public List<PyPiOverallEntry> Data { get; set; } = [];
}

public class PyPiOverallEntry
{
[JsonPropertyName("category")]
public string Category { get; set; } = string.Empty;

[JsonPropertyName("date")]
public string Date { get; set; } = string.Empty;

[JsonPropertyName("downloads")]
public double Downloads { get; set; }
}
29 changes: 29 additions & 0 deletions CSharp/Examples/RailenginePoweredStatusPage/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using Railengine;
using RailenginePoweredStatusPage.Middleware;
using RailenginePoweredStatusPage.Services;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();
builder.Services.AddHttpClient();
builder.Services.AddSingleton<RailengineClient>(sp =>
{
var config = sp.GetRequiredService<IConfiguration>();
var httpClient = sp.GetRequiredService<IHttpClientFactory>().CreateClient();
var pat = config["RailEngine:PAT"]!;
return new RailengineClient(httpClient, pat);
});
builder.Services.AddSingleton<DailyInsight>();
builder.Services.AddHostedService<DailyInsightService>();
builder.Services.AddTransient<IExternalMetricSource, PyPiDownloadsSource>();

var app = builder.Build();

app.UseMiddleware<IPAllowlistMiddleware>();

app.UseDefaultFiles();
app.UseStaticFiles();

app.MapControllers();

app.Run();
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
}
129 changes: 129 additions & 0 deletions CSharp/Examples/RailenginePoweredStatusPage/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# 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 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

Railengine is expected to return records in the following format:

```json
{ "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.

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: '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' },
];
```

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 `<h1>` heading are still hardcoded in `wwwroot/index.html` if you want to rebrand those too.

## Adding external metrics

The app supports non-Railengine metric sources via the `IExternalMetricSource` interface in `Services/IExternalMetricSource.cs`:

```csharp
public interface IExternalMetricSource
{
Task<IEnumerable<MetricRecord>> GetRecordsAsync();
}
```

Implement this interface to pull data from any external API and shape it into `MetricRecord` objects. The built-in example is `PyPiDownloadsSource`, which fetches daily download counts for a PyPI package from the [pypistats.org API](https://pypistats.org/api):

```csharp
public class PyPiDownloadsSource : IExternalMetricSource
{
// Returns the last 30 days of daily downloads as MetricRecords
}
```

To add a new source:

1. Create a class that implements `IExternalMetricSource`
2. Register it in `Program.cs`:
```csharp
builder.Services.AddTransient<IExternalMetricSource, YourSource>();
```
3. Add a corresponding entry to the `METRICS` array in `wwwroot/index.html`, using a `key` that matches the `metric` field your source returns

The `MetricsController` injects all registered `IExternalMetricSource` implementations and merges their records with the Railengine data before returning from `/api/metrics`.

> **Note:** External metrics are display-only. The daily insight is generated by Claude querying Railengine directly via MCP — it has no awareness of external sources and will not include them in its analysis.

## 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.

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<DailyInsightService>()` 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

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. (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
```

The status page is served at `https://localhost:<port>/`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Railengine.Retrieval" Version="1.0.2" />
</ItemGroup>

</Project>
Loading