Skip to content

Commit 0939b70

Browse files
committed
Add Slack/webhook alerting and self-contained HTML report
- WebhookNotifier: optional webhookUrl on the API request and --webhook-url CLI flag, auto-detects Slack incoming webhooks, POSTs the full response to any other listener (Teams/PagerDuty/custom). Fires only on HIGH/CRITICAL findings; network failures never fail the request. - HtmlReportRenderer: --html CLI flag writes a self-contained report (inline CSS, no CDN) for demos and sharing — same visual language as JIT-Optimization-Engine's equivalent report. Both were the most commonly cited gaps against commercial FinOps/architecture tools (Vantage, CloudZero, Backstage) without pulling in a database or a served dashboard, which the engine deliberately avoids (see ARCHITECTURE.md).
1 parent df891ff commit 0939b70

8 files changed

Lines changed: 453 additions & 5 deletions

File tree

README.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,12 +169,52 @@ Response shape:
169169
```bash
170170
dotnet run --project src/CloudSealed.ML.CLI -- examples/inventory.json
171171
dotnet run --project src/CloudSealed.ML.CLI -- examples/inventory.json --json
172+
dotnet run --project src/CloudSealed.ML.CLI -- examples/inventory.json --html report.html
172173
```
173174

175+
`--html` writes a self-contained report (inline CSS, no CDN) alongside
176+
whatever other output is requested — open it straight from disk, or attach it
177+
to an email.
178+
174179
Runs the same analysis without starting a server, printing either a
175180
human-readable summary or the raw JSON response. [`examples/inventory.json`](examples/inventory.json)
176181
is a ready-to-run sample with a mix of criticality levels and system types.
177182

183+
## GitHub Action
184+
185+
Run the audit in CI and get the findings as a pull request comment, without
186+
installing anything locally:
187+
188+
```yaml
189+
- uses: cloudsealed/Predictive-ML-Core@main
190+
with:
191+
inventory-json: inventory.json
192+
fail-on-severity: CRITICAL # optional: fail the check on CRITICAL findings
193+
```
194+
195+
Re-runs on the same PR edit the existing comment instead of piling up new
196+
ones. See [action.yml](action.yml) for all inputs/outputs and
197+
[.github/workflows/example-usage.yml](.github/workflows/example-usage.yml)
198+
for a working example (this repository dogfoods its own action against
199+
[examples/inventory.json](examples/inventory.json) on every push).
200+
201+
## Alerts
202+
203+
Send the result to Slack (or any generic webhook listener) when a finding
204+
reaches a severity threshold, without standing up a dashboard:
205+
206+
```bash
207+
dotnet run --project src/CloudSealed.ML.CLI -- examples/inventory.json --webhook-url "$SLACK_WEBHOOK_URL"
208+
```
209+
210+
A Slack incoming-webhook URL (`hooks.slack.com`) is auto-detected and
211+
rendered as a formatted message; any other URL receives the full JSON
212+
response, so it works as-is with Teams, PagerDuty, or a custom listener.
213+
Nothing is sent unless a finding is HIGH or CRITICAL. The same behaviour is
214+
available in the HTTP API via the optional `webhookUrl` field on
215+
`/v1/predict-architecture`. A failed webhook is logged and never fails the
216+
request.
217+
178218
## Development
179219

180220
```bash

src/CloudSealed.ML.API/Program.cs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using CloudSealed.ML.Engine.Models;
2+
using CloudSealed.ML.Engine.Notifications;
23
using CloudSealed.ML.Engine.Scoring;
34
using Microsoft.OpenApi.Models;
45

@@ -14,6 +15,7 @@
1415
});
1516

1617
builder.Services.AddSingleton<ArchitectureAnalyzer>();
18+
builder.Services.AddHttpClient("webhook");
1719
builder.Services.AddEndpointsApiExplorer();
1820
builder.Services.AddSwaggerGen(options =>
1921
{
@@ -41,10 +43,11 @@
4143

4244
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
4345

44-
app.MapPost("/v1/predict-architecture", (
46+
app.MapPost("/v1/predict-architecture", async (
4547
PredictArchitectureRequest? request,
4648
HttpRequest httpRequest,
47-
ArchitectureAnalyzer analyzer) =>
49+
ArchitectureAnalyzer analyzer,
50+
IHttpClientFactory httpClientFactory) =>
4851
{
4952
var apiKey = Environment.GetEnvironmentVariable("PREDICTIVE_ML_CORE_API_KEY");
5053
if (!string.IsNullOrEmpty(apiKey))
@@ -62,6 +65,13 @@
6265
}
6366

6467
var response = analyzer.Analyze(request);
68+
69+
if (!string.IsNullOrWhiteSpace(request.WebhookUrl))
70+
{
71+
await WebhookNotifier.NotifyAsync(
72+
httpClientFactory.CreateClient("webhook"), request.WebhookUrl, response, request.CompanyName);
73+
}
74+
6575
return Results.Ok(response);
6676
});
6777

src/CloudSealed.ML.CLI/Program.cs

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
using System.Text.Json;
22
using CloudSealed.ML.Engine.Models;
3+
using CloudSealed.ML.Engine.Notifications;
4+
using CloudSealed.ML.Engine.Reporting;
35
using CloudSealed.ML.Engine.Scoring;
46

57
namespace CloudSealed.ML.CLI;
@@ -15,12 +17,16 @@ internal static class Program
1517
WriteIndented = true,
1618
};
1719

18-
private static int Main(string[] args)
20+
private static async Task<int> Main(string[] args)
1921
{
20-
var positional = args.Where(a => a != "--json").ToArray();
22+
var webhookUrl = ExtractOptionValue(args, "--webhook-url");
23+
var htmlPath = ExtractOptionValue(args, "--html");
24+
var consumed = new[] { "--json", webhookUrl, "--webhook-url", htmlPath, "--html" };
25+
var positional = args.Where(a => !consumed.Contains(a)).ToArray();
2126
if (positional.Length != 1)
2227
{
23-
Console.Error.WriteLine("Uso: cloudsealed-predictive-ml <inventory.json> [--json]");
28+
Console.Error.WriteLine(
29+
"Uso: cloudsealed-predictive-ml <inventory.json> [--json] [--html PATH] [--webhook-url URL]");
2430
return 1;
2531
}
2632

@@ -51,6 +57,17 @@ private static int Main(string[] args)
5157

5258
var response = new ArchitectureAnalyzer().Analyze(request);
5359

60+
if (webhookUrl is not null)
61+
{
62+
using var client = new HttpClient();
63+
await WebhookNotifier.NotifyAsync(client, webhookUrl, response, request.CompanyName);
64+
}
65+
66+
if (htmlPath is not null)
67+
{
68+
await File.WriteAllTextAsync(htmlPath, HtmlReportRenderer.Render(response, request.CompanyName));
69+
}
70+
5471
if (args.Contains("--json"))
5572
{
5673
Console.WriteLine(JsonSerializer.Serialize(response, JsonOptions));
@@ -61,6 +78,12 @@ private static int Main(string[] args)
6178
return 0;
6279
}
6380

81+
private static string? ExtractOptionValue(string[] args, string optionName)
82+
{
83+
var index = Array.IndexOf(args, optionName);
84+
return index >= 0 && index + 1 < args.Length ? args[index + 1] : null;
85+
}
86+
6487
private static void PrintHumanReadable(PredictArchitectureRequest request, PredictArchitectureResponse response)
6588
{
6689
Console.WriteLine($"Predictive-ML-Core — {request.CompanyName}");

src/CloudSealed.ML.Engine/Models/ArchitectureContract.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ public class PredictArchitectureRequest
3636
public List<SystemInput> Systems { get; set; } = new();
3737

3838
public HistoricalMetrics? HistoricalMetrics { get; set; }
39+
40+
// Opcional/aditivo: se definido, o resultado é enviado para esta URL
41+
// (Slack incoming webhook ou listener genérico) quando algum finding
42+
// atinge HIGH/CRITICAL. Ver WebhookNotifier.
43+
public string? WebhookUrl { get; set; }
3944
}
4045

4146
public class RiskScores
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
using System.Net.Http.Json;
2+
using System.Text.Json;
3+
using CloudSealed.ML.Engine.Models;
4+
5+
namespace CloudSealed.ML.Engine.Notifications;
6+
7+
// Efeito colateral, não parte do contrato de análise: uma falha de rede é
8+
// engolida (nunca propagada), para que um webhook fora do ar não derrube uma
9+
// resposta que já foi calculada com sucesso. Espelha cloudsealed_jit/notify.py.
10+
public static class WebhookNotifier
11+
{
12+
private static readonly Dictionary<string, int> SeverityRank = new()
13+
{
14+
["LOW"] = 0,
15+
["MEDIUM"] = 1,
16+
["HIGH"] = 2,
17+
["CRITICAL"] = 3,
18+
};
19+
20+
public static bool IsSlackWebhook(string url) => url.Contains("hooks.slack.com");
21+
22+
public static object BuildSlackPayload(PredictArchitectureResponse response, string companyName)
23+
{
24+
var lines = new List<string>
25+
{
26+
$"*🏗️ Predictive-ML-Core — {companyName}*",
27+
response.ArchitectureSummary,
28+
$"Overall architecture score: *{response.OverallArchitectureScore}/100*",
29+
};
30+
31+
foreach (var prediction in response.Predictions)
32+
{
33+
foreach (var finding in prediction.Findings.Take(5))
34+
{
35+
lines.Add($"• `{prediction.SystemName}` *{finding.Severity}* — {finding.Title}");
36+
}
37+
}
38+
39+
foreach (var prediction in response.Predictions)
40+
{
41+
foreach (var recommendation in prediction.Recommendations.Take(3))
42+
{
43+
lines.Add($"→ *{recommendation.Title}* ({prediction.SystemName}, effort {recommendation.Effort})");
44+
}
45+
}
46+
47+
return new { text = string.Join("\n", lines) };
48+
}
49+
50+
private static bool MeetsThreshold(PredictArchitectureResponse response, string minSeverity)
51+
{
52+
var threshold = SeverityRank.GetValueOrDefault(minSeverity.ToUpperInvariant(), SeverityRank["HIGH"]);
53+
return response.Predictions
54+
.SelectMany(p => p.Findings)
55+
.Any(f => SeverityRank.GetValueOrDefault(f.Severity, 0) >= threshold);
56+
}
57+
58+
// Retorna se o webhook foi enviado (false = abaixo do limiar ou falha de rede).
59+
public static async Task<bool> NotifyAsync(
60+
HttpClient client,
61+
string webhookUrl,
62+
PredictArchitectureResponse response,
63+
string companyName,
64+
string minSeverity = "HIGH")
65+
{
66+
if (!MeetsThreshold(response, minSeverity))
67+
{
68+
return false;
69+
}
70+
71+
object payload = IsSlackWebhook(webhookUrl)
72+
? BuildSlackPayload(response, companyName)
73+
: response;
74+
75+
try
76+
{
77+
using var httpResponse = await client.PostAsJsonAsync(webhookUrl, payload);
78+
return true;
79+
}
80+
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException)
81+
{
82+
return false;
83+
}
84+
}
85+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
using System.Text;
2+
using System.Text.Encodings.Web;
3+
using System.Text.Unicode;
4+
using CloudSealed.ML.Engine.Models;
5+
6+
namespace CloudSealed.ML.Engine.Reporting;
7+
8+
// HTML autocontido (CSS inline, sem CDN) para demo/compartilhamento — não é
9+
// um dashboard servido. Mesma paleta visual do relatório do JIT-Optimization-Engine
10+
// para consistência de marca entre os dois motores.
11+
public static class HtmlReportRenderer
12+
{
13+
private static readonly Dictionary<string, string> SeverityColors = new()
14+
{
15+
["LOW"] = "#6b7280",
16+
["MEDIUM"] = "#d97706",
17+
["HIGH"] = "#dc2626",
18+
["CRITICAL"] = "#991b1b",
19+
};
20+
21+
private const string Css = """
22+
body { font-family: -apple-system, Helvetica, Arial, sans-serif; max-width: 900px;
23+
margin: 40px auto; padding: 0 20px; color: #1f2937; background: #fff; }
24+
h1 { font-size: 22px; } h2 { font-size: 16px; margin-top: 32px; color: #374151; }
25+
.summary { color: #4b5563; }
26+
.cards { display: flex; gap: 16px; margin: 20px 0; flex-wrap: wrap; }
27+
.card { border: 1px solid #e5e7eb; border-radius: 8px; padding: 12px 16px; min-width: 140px; }
28+
.card .label { font-size: 12px; color: #6b7280; text-transform: uppercase; }
29+
.card .value { font-size: 22px; font-weight: 600; }
30+
table { border-collapse: collapse; width: 100%; margin-top: 8px; }
31+
th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid #e5e7eb; font-size: 13px; }
32+
th { color: #6b7280; font-weight: 600; }
33+
.badge { display: inline-block; padding: 2px 8px; border-radius: 999px; color: #fff; font-size: 11px; font-weight: 600; }
34+
footer { margin-top: 40px; color: #9ca3af; font-size: 12px; }
35+
""";
36+
37+
public static string Render(PredictArchitectureResponse response, string companyName)
38+
{
39+
var title = string.IsNullOrWhiteSpace(companyName)
40+
? "Predictive-ML-Core"
41+
: $"Predictive-ML-Core — {companyName}";
42+
43+
var findingRows = new StringBuilder();
44+
var recommendationRows = new StringBuilder();
45+
foreach (var prediction in response.Predictions)
46+
{
47+
foreach (var finding in prediction.Findings)
48+
{
49+
var color = SeverityColors.GetValueOrDefault(finding.Severity, "#6b7280");
50+
findingRows.Append(
51+
$"<tr><td>{Esc(prediction.SystemName)}</td>"
52+
+ $"<td><span class='badge' style='background:{color}'>{Esc(finding.Severity)}</span></td>"
53+
+ $"<td>{Esc(finding.Title)}</td><td>{Esc(finding.Description)}</td></tr>");
54+
}
55+
foreach (var recommendation in prediction.Recommendations)
56+
{
57+
recommendationRows.Append(
58+
$"<tr><td>{Esc(prediction.SystemName)}</td><td>{Esc(recommendation.Title)}</td>"
59+
+ $"<td>{Esc(recommendation.Effort)}</td><td>{Esc(recommendation.Description)}</td></tr>");
60+
}
61+
}
62+
63+
var findingsBody = findingRows.Length > 0 ? findingRows.ToString() : "<tr><td colspan=\"4\">None found.</td></tr>";
64+
var recommendationsBody = recommendationRows.Length > 0 ? recommendationRows.ToString() : "<tr><td colspan=\"4\">None.</td></tr>";
65+
66+
return $"""
67+
<!DOCTYPE html>
68+
<html lang="en">
69+
<head>
70+
<meta charset="utf-8">
71+
<title>{Esc(title)}</title>
72+
<style>{Css}</style>
73+
</head>
74+
<body>
75+
<h1>{Esc(title)}</h1>
76+
<p class="summary">{Esc(response.ArchitectureSummary)}</p>
77+
<div class="cards">
78+
<div class="card"><div class="label">Overall score</div><div class="value">{response.OverallArchitectureScore}/100</div></div>
79+
<div class="card"><div class="label">Systems</div><div class="value">{response.Predictions.Count}</div></div>
80+
<div class="card"><div class="label">Findings</div><div class="value">{response.Predictions.Sum(p => p.Findings.Count)}</div></div>
81+
</div>
82+
<h2>Findings</h2>
83+
<table>
84+
<tr><th>System</th><th>Severity</th><th>Title</th><th>Description</th></tr>
85+
{findingsBody}
86+
</table>
87+
<h2>Recommendations</h2>
88+
<table>
89+
<tr><th>System</th><th>Title</th><th>Effort</th><th>Description</th></tr>
90+
{recommendationsBody}
91+
</table>
92+
<footer>Generated by <a href="https://github.com/cloudsealed/Predictive-ML-Core">Predictive-ML-Core</a>
93+
— deterministic, auditable rule scoring.</footer>
94+
</body>
95+
</html>
96+
""";
97+
}
98+
99+
// UnicodeRanges.All keeps accented/UTF-8 text readable in the source
100+
// (matching cloudsealed_jit's report.py, which uses Python's html.escape)
101+
// while still escaping the characters that matter for XSS: &<>"'.
102+
private static readonly HtmlEncoder Encoder = HtmlEncoder.Create(UnicodeRanges.All);
103+
104+
private static string Esc(string value) => Encoder.Encode(value);
105+
}

0 commit comments

Comments
 (0)