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/.gitignore b/.gitignore index f6c2a54..185ad45 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/CSharp/Examples/RailenginePoweredStatusPage/.config/dotnet-tools.json b/CSharp/Examples/RailenginePoweredStatusPage/.config/dotnet-tools.json new file mode 100644 index 0000000..ce0aab7 --- /dev/null +++ b/CSharp/Examples/RailenginePoweredStatusPage/.config/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "10.0.7", + "commands": [ + "dotnet-ef" + ], + "rollForward": false + } + } +} \ No newline at end of file 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/Controllers/MetricsController.cs b/CSharp/Examples/RailenginePoweredStatusPage/Controllers/MetricsController.cs new file mode 100644 index 0000000..8a89b71 --- /dev/null +++ b/CSharp/Examples/RailenginePoweredStatusPage/Controllers/MetricsController.cs @@ -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 externalSources; + + public MetricsController(RailengineClient railengineClient, IConfiguration configuration, IEnumerable externalSources) + { + this.railengineClient = railengineClient; + this.externalSources = externalSources; + engineId = Guid.Parse(configuration["RailEngine:EngineId"]!); + } + + [HttpGet] + public async Task>> GetMetrics() + { + var page1 = await railengineClient.ListStorageDocuments(engineId, pageNumber: 1, pageSize: 100); + var page2 = await railengineClient.ListStorageDocuments(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()); + } +} 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/Models/MetricRecord.cs b/CSharp/Examples/RailenginePoweredStatusPage/Models/MetricRecord.cs new file mode 100644 index 0000000..7ab496f --- /dev/null +++ b/CSharp/Examples/RailenginePoweredStatusPage/Models/MetricRecord.cs @@ -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; } +} diff --git a/CSharp/Examples/RailenginePoweredStatusPage/Models/PyPiRecentResponse.cs b/CSharp/Examples/RailenginePoweredStatusPage/Models/PyPiRecentResponse.cs new file mode 100644 index 0000000..418efb1 --- /dev/null +++ b/CSharp/Examples/RailenginePoweredStatusPage/Models/PyPiRecentResponse.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace RailenginePoweredStatusPage.Models; + +public class PyPiOverallResponse +{ + [JsonPropertyName("data")] + public List 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; } +} diff --git a/CSharp/Examples/RailenginePoweredStatusPage/Program.cs b/CSharp/Examples/RailenginePoweredStatusPage/Program.cs new file mode 100644 index 0000000..05f96f5 --- /dev/null +++ b/CSharp/Examples/RailenginePoweredStatusPage/Program.cs @@ -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(sp => +{ + var config = sp.GetRequiredService(); + var httpClient = sp.GetRequiredService().CreateClient(); + var pat = config["RailEngine:PAT"]!; + return new RailengineClient(httpClient, pat); +}); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); +builder.Services.AddTransient(); + +var app = builder.Build(); + +app.UseMiddleware(); + +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/README.md b/CSharp/Examples/RailenginePoweredStatusPage/README.md new file mode 100644 index 0000000..b5beb85 --- /dev/null +++ b/CSharp/Examples/RailenginePoweredStatusPage/README.md @@ -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 `

` 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> 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(); + ``` +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()` 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:/`. 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/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..9e6fb83 --- /dev/null +++ b/CSharp/Examples/RailenginePoweredStatusPage/Services/DailyInsightService.cs @@ -0,0 +1,204 @@ +using System.Text; +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 static readonly TimeSpan RequestTimeout = TimeSpan.FromMinutes(10); + + private const string Prompt = + "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" + + "- 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) + { + 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); + request.Headers.Add("anthropic-beta", beta); + + var body = new + { + model, + max_tokens = 256, + stream = true, + 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); + + // 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) + { + var errBody = await response.Content.ReadAsStringAsync(cts.Token); + throw new InvalidOperationException($"Claude API returned {(int)response.StatusCode}: {errBody}"); + } + + 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/Services/IExternalMetricSource.cs b/CSharp/Examples/RailenginePoweredStatusPage/Services/IExternalMetricSource.cs new file mode 100644 index 0000000..54b3b1b --- /dev/null +++ b/CSharp/Examples/RailenginePoweredStatusPage/Services/IExternalMetricSource.cs @@ -0,0 +1,8 @@ +using RailenginePoweredStatusPage.Models; + +namespace RailenginePoweredStatusPage.Services; + +public interface IExternalMetricSource +{ + Task> GetRecordsAsync(); +} diff --git a/CSharp/Examples/RailenginePoweredStatusPage/Services/PyPiDownloadsSource.cs b/CSharp/Examples/RailenginePoweredStatusPage/Services/PyPiDownloadsSource.cs new file mode 100644 index 0000000..dd7599e --- /dev/null +++ b/CSharp/Examples/RailenginePoweredStatusPage/Services/PyPiDownloadsSource.cs @@ -0,0 +1,40 @@ +using System.Net.Http.Json; +using RailenginePoweredStatusPage.Models; + +namespace RailenginePoweredStatusPage.Services; + +public class PyPiDownloadsSource : IExternalMetricSource +{ + private readonly IHttpClientFactory httpClientFactory; + private readonly string package; + + public PyPiDownloadsSource(IHttpClientFactory httpClientFactory, IConfiguration configuration) + { + this.httpClientFactory = httpClientFactory; + package = configuration["PyPi:Package"] ?? "railtracks"; + } + + public async Task> GetRecordsAsync() + { + var client = httpClientFactory.CreateClient(); + var httpResponse = await client.GetAsync( + $"https://pypistats.org/api/packages/{package}/overall"); + + if (!httpResponse.IsSuccessStatusCode) return []; + + var response = await httpResponse.Content.ReadFromJsonAsync(); + + if (response?.Data == null) return []; + + return response.Data + .Where(e => e.Category == "without_mirrors") + .OrderByDescending(e => e.Date) + .Take(30) + .Select(e => new MetricRecord + { + Metric = "pypi-downloads", + Timestamp = DateTimeOffset.Parse(e.Date).ToUnixTimeMilliseconds(), + Value = e.Downloads, + }); + } +} diff --git a/CSharp/Examples/RailenginePoweredStatusPage/appsettings.Development.sample.json b/CSharp/Examples/RailenginePoweredStatusPage/appsettings.Development.sample.json new file mode 100644 index 0000000..4d1fdca --- /dev/null +++ b/CSharp/Examples/RailenginePoweredStatusPage/appsettings.Development.sample.json @@ -0,0 +1,16 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "RailEngine": { + "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 new file mode 100644 index 0000000..9254a89 --- /dev/null +++ b/CSharp/Examples/RailenginePoweredStatusPage/appsettings.json @@ -0,0 +1,21 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "RailEngine": { + "PAT": "[your-pat-token-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-haiku-4-5-20251001" + }, + "AllowedIPs": [ "127.0.0.1", "::1" ] +} 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 0000000..7cf5aa9 Binary files /dev/null and b/CSharp/Examples/RailenginePoweredStatusPage/wwwroot/cropped-Railengine_logo_dark-1-scaled-1-2048x585.png differ diff --git a/CSharp/Examples/RailenginePoweredStatusPage/wwwroot/index.html b/CSharp/Examples/RailenginePoweredStatusPage/wwwroot/index.html new file mode 100644 index 0000000..6202429 --- /dev/null +++ b/CSharp/Examples/RailenginePoweredStatusPage/wwwroot/index.html @@ -0,0 +1,195 @@ + + + + + + railtown.ai system metrics + + + + +

railtown.ai system metrics

+ + + +
+ + + + + + + + diff --git a/CSharp/Examples/RailenginePoweredStatusPage/wwwroot/styles.css b/CSharp/Examples/RailenginePoweredStatusPage/wwwroot/styles.css new file mode 100644 index 0000000..44f258d --- /dev/null +++ b/CSharp/Examples/RailenginePoweredStatusPage/wwwroot/styles.css @@ -0,0 +1,131 @@ +*, +*::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: 1rem; + color: #f8fafc; +} + +.insight { + background: #1e2130; + border: 1px solid #2d3248; + border-radius: 12px; + padding: 1rem; + margin-bottom: 1rem; +} + +.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)); + gap: 1.5rem; +} + +.charts[hidden] { + display: none; +} + +.card { + background: #1e2130; + border: 1px solid #2d3248; + border-radius: 12px; + padding: 1rem; +} + +.card h2 { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.5rem; + font-size: 0.85rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #94a3b8; + margin-bottom: 1rem; +} + +.card h2 .latest { + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0; + color: #f8fafc; +} + +.card-interval { + font-size: 0.75rem; + color: #64748b; + margin-top: -0.5rem; + margin-bottom: 0.75rem; +} + +.chart-wrap { + position: relative; + height: 220px; +} + +.error { + color: #f87171; + font-size: 0.875rem; + margin-top: 1rem; +} + +.powered-by { + display: flex; + align-items: center; + justify-content: center; + gap: 0.6rem; + margin-top: 1rem; + color: #ccc; + font-size: 0.9rem; + letter-spacing: 0.05em; +} + +.powered-by img { + height: 2.8rem; + width: auto; + display: block; + background: #e2e8f0; + border-radius: 6px; +} diff --git a/CSharp/README.md b/CSharp/README.md index 0206521..87ae19d 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 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**. -[Quick start](https://www.nuget.org/packages/Railengine.Ingestion) +## Prerequisites + +- [.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 + +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 +```