auto create a ticket with necessary informaton triggered b y a installation status changed

This commit is contained in:
Yinyin Liu 2026-04-29 12:51:37 +02:00
parent ed00b742a1
commit 26cc0ac2a1
4 changed files with 271 additions and 37 deletions

View File

@ -1,6 +1,5 @@
using InnovEnergy.App.Backend.Database;
using InnovEnergy.App.Backend.Relations;
using InnovEnergy.Lib.Mailer;
using InnovEnergy.Lib.Utils;
namespace InnovEnergy.App.Backend.DataTypes.Methods;
@ -173,38 +172,4 @@ public static class InstallationMethods
return true;
}
private const String SupportEmail = "support@inesco.energy";
private const String SupportName = "inesco energy Support Team";
public static Task SendAlarmNotificationToSupport(this Installation installation, Int32 prevStatus)
{
var productName = ProductName(installation.Product);
var fromStatus = StatusName(prevStatus);
var subject = $"[inesco energy] Alarm: {installation.Name}";
var body =
$"Installation \"{installation.Name}\" (ID {installation.Id}, {productName})\n" +
$"status changed from {fromStatus} to Alarm.\n\n" +
"Please check the Log tab on the Monitor to see detailed errors and warnings.\n";
return Mailer.Send(SupportName, SupportEmail, subject, body);
}
private static String StatusName(Int32 status) => status switch
{
-1 => "Offline",
0 => "Green",
1 => "Warning",
2 => "Alarm",
_ => "Unknown"
};
private static String ProductName(Int32 product) => product switch
{
2 => "Sodistore Home",
3 => "Sodistore Max",
4 => "Sodistore Grid",
5 => "Sodistore Pro",
_ => $"Product {product}"
};
}

View File

@ -0,0 +1,246 @@
using System.Text;
using System.Text.RegularExpressions;
using InnovEnergy.App.Backend.Database;
using InnovEnergy.App.Backend.DataTypes;
using InnovEnergy.App.Backend.DataTypes.Methods;
using InnovEnergy.App.Backend.Websockets;
using InnovEnergy.Lib.Utils;
namespace InnovEnergy.App.Backend.Services;
public static class AutoTicketService
{
private const String NicoLappName = "Nico Lapp";
private const String AtefSarrajName = "Atef Sarraj";
private const String TagAlarm = "auto:alarm";
private const String TagOffline = "auto:offline";
private const Int32 AlarmStatus = (Int32)StatusType.Alarm;
private const Int32 OfflineStatus = (Int32)StatusType.Offline;
public static async Task MaybeCreateForAlarmAsync(
Installation installation,
Int32 prevStatus,
IReadOnlyList<AlarmOrWarning> alarms)
{
if (!IsSodistore(installation.Product)) return;
if (prevStatus == AlarmStatus) return;
var assignee = FindAdminByName(NicoLappName);
if (assignee is null)
{
Console.WriteLine($"[AutoTicket] assignee \"{NicoLappName}\" not found, skipping alarm ticket for installation {installation.Id}");
return;
}
if (alarms.Count == 0)
{
Console.WriteLine($"[AutoTicket] alarm transition for installation {installation.Id} carried no alarm payload, skipping (likely transient)");
return;
}
if (HasOpenAutoTicket(installation.Id, TagAlarm)) return;
var lang = String.IsNullOrWhiteSpace(assignee.Language) ? "en" : assignee.Language;
var firstName = SplitCamelCase(alarms[0].Description);
var suffix = alarms.Count > 1 ? $" (+{alarms.Count - 1} more)" : "";
var subject = $"[Auto] {installation.Name} entered error state [{firstName}]{suffix}";
var description = await BuildAlarmDescriptionAsync(installation, prevStatus, alarms, lang);
CreateTicket(installation, assignee, subject, description, TagAlarm);
}
public static Task MaybeCreateForOfflineAsync(
Installation installation,
Int32 prevStatus,
DateTime lastSeen)
{
if (!IsSodistore(installation.Product)) return Task.CompletedTask;
if (prevStatus == OfflineStatus) return Task.CompletedTask;
var assignee = FindAdminByName(AtefSarrajName);
if (assignee is null)
{
Console.WriteLine($"[AutoTicket] assignee \"{AtefSarrajName}\" not found, skipping offline ticket for installation {installation.Id}");
return Task.CompletedTask;
}
if (HasOpenAutoTicket(installation.Id, TagOffline)) return Task.CompletedTask;
var subject = $"[Auto] {installation.Name} went offline";
var description = BuildOfflineDescription(installation, prevStatus, lastSeen);
CreateTicket(installation, assignee, subject, description, TagOffline);
return Task.CompletedTask;
}
private static void CreateTicket(
Installation installation,
User assignee,
String subject,
String description,
String tag)
{
var ticket = new Ticket
{
Subject = subject,
Description = description,
Status = (Int32)TicketStatus.Open,
Priority = (Int32)TicketPriority.Critical,
Category = (Int32)TicketCategory.Other,
SubCategory = 0,
Source = (Int32)TicketSource.AutoAlert,
InstallationId = installation.Id,
AssigneeId = assignee.Id,
CreatedByUserId = assignee.Id,
Tags = tag,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
if (!Db.Create(ticket))
{
Console.WriteLine($"[AutoTicket] Db.Create failed for installation {installation.Id} ({tag})");
return;
}
var timelineOk = Db.Create(new TicketTimelineEvent
{
TicketId = ticket.Id,
EventType = (Int32)TimelineEventType.Created,
Description = $"Ticket auto-created by status monitor ({tag}).",
ActorType = (Int32)TimelineActorType.System,
ActorId = null,
CreatedAt = DateTime.UtcNow
});
if (!timelineOk)
Console.WriteLine($"[AutoTicket] timeline event insert failed for ticket {ticket.Id} (informational only — ticket itself is fine)");
_ = Task.Run(async () =>
{
try { await assignee.SendTicketAssignedEmail(ticket); }
catch (Exception ex) { Console.WriteLine($"[AutoTicket] assignment email failed for ticket {ticket.Id}: {ex.Message}"); }
});
var lang = String.IsNullOrWhiteSpace(assignee.Language) ? "en" : assignee.Language;
TicketDiagnosticService.DiagnoseTicketAsync(ticket.Id, lang).SupressAwaitWarning();
}
private static Boolean HasOpenAutoTicket(Int64 installationId, String tag)
{
return Db.GetTicketsForInstallation(installationId).Any(t =>
t.Source == (Int32)TicketSource.AutoAlert
&& (t.Status == (Int32)TicketStatus.Open || t.Status == (Int32)TicketStatus.InProgress)
&& t.Tags == tag);
}
private static User? FindAdminByName(String name) =>
Db.Users.FirstOrDefault(u => u.UserType == 2 && u.Name == name);
private static Boolean IsSodistore(Int32 product) => product is 2 or 3 or 4 or 5;
private static String SplitCamelCase(String name) =>
Regex.Replace(name ?? "", @"(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])", " ").Trim();
private static async Task<String> BuildAlarmDescriptionAsync(
Installation installation,
Int32 prevStatus,
IReadOnlyList<AlarmOrWarning> alarms,
String lang)
{
var sb = new StringBuilder();
sb.AppendLine("Auto-created by status monitor.");
sb.AppendLine($"Installation: {installation.Name} (id {installation.Id})");
sb.AppendLine($"Location: {installation.Location}");
sb.AppendLine($"Transition: {StatusLabel(prevStatus)} → Alarm");
sb.AppendLine($"Detected at: {DateTime.UtcNow:O} UTC");
sb.AppendLine();
sb.AppendLine($"—— Alarms ({alarms.Count}) ——");
foreach (var alarm in alarms)
{
var displayName = SplitCamelCase(alarm.Description);
sb.AppendLine();
sb.AppendLine($"[{displayName}] (raw: {alarm.Description}) — {alarm.Date} {alarm.Time} from {alarm.CreatedBy}");
DiagnosticResponse? diag = null;
try
{
diag = await DiagnosticService.DiagnoseAsync(installation.Id, alarm.Description, lang);
}
catch (Exception ex)
{
Console.WriteLine($"[AutoTicket] DiagnoseAsync failed for {alarm.Description}: {ex.Message}");
}
if (diag is null)
{
sb.AppendLine(" (no diagnosis available)");
continue;
}
sb.AppendLine($" Explanation: {diag.Explanation}");
if (diag.Causes is { Count: > 0 })
{
sb.AppendLine(" Likely causes:");
foreach (var cause in diag.Causes) sb.AppendLine($" - {cause}");
}
if (diag.NextSteps is { Count: > 0 })
{
sb.AppendLine(" Suggested next steps:");
var i = 1;
foreach (var step in diag.NextSteps) sb.AppendLine($" {i++}. {step}");
}
}
return sb.ToString();
}
private static String BuildOfflineDescription(Installation installation, Int32 prevStatus, DateTime lastSeen)
{
// lastSeen is captured from WebsocketManager which uses DateTime.Now (server local time);
// normalise to UTC so the rendered timestamps + "min ago" delta are consistent and unambiguous.
var lastSeenUtc = lastSeen.Kind == DateTimeKind.Utc ? lastSeen : lastSeen.ToUniversalTime();
var minutesAgo = (DateTime.UtcNow - lastSeenUtc).TotalMinutes;
var (productLabel, timeoutMin) = OfflineProfile(installation.Product);
var sb = new StringBuilder();
sb.AppendLine("Auto-created by status monitor.");
sb.AppendLine($"Installation: {installation.Name} (id {installation.Id})");
sb.AppendLine($"Location: {installation.Location}");
sb.AppendLine($"Transition: {StatusLabel(prevStatus)} → Offline");
sb.AppendLine($"Detected at: {DateTime.UtcNow:O} UTC");
sb.AppendLine();
sb.AppendLine($"Last heartbeat received: {lastSeenUtc:O} UTC ({minutesAgo:F1} min ago)");
sb.AppendLine($"Expected heartbeat interval for {productLabel}: {timeoutMin} min");
sb.AppendLine();
sb.AppendLine("This ticket was auto-created because no message was received from the");
sb.AppendLine("installation within the expected interval. Please verify network/VPN");
sb.AppendLine("connectivity, then check the device side.");
return sb.ToString();
}
private static String StatusLabel(Int32 status) => status switch
{
(Int32)StatusType.Offline => "Offline",
(Int32)StatusType.Green => "Green",
(Int32)StatusType.Warning => "Warning",
(Int32)StatusType.Alarm => "Alarm",
-2 => "Unknown",
_ => $"Status({status})"
};
private static (String label, Int32 timeoutMinutes) OfflineProfile(Int32 product) => product switch
{
(Int32)ProductType.SodioHome => ("Sodistore Home", 4),
(Int32)ProductType.SodiStoreMax => ("Sodistore Max", 2),
(Int32)ProductType.SodistoreGrid => ("Sodistore Grid", 2),
(Int32)ProductType.SodistorePro => ("Sodistore Pro", 4),
_ => ($"Product {product}", 0)
};
}

View File

@ -3,6 +3,7 @@ using System.Text.Json;
using InnovEnergy.App.Backend.Database;
using InnovEnergy.App.Backend.DataTypes;
using InnovEnergy.App.Backend.DataTypes.Methods;
using InnovEnergy.App.Backend.Services;
using InnovEnergy.Lib.Utils;
using RabbitMQ.Client;
using RabbitMQ.Client.Events;
@ -194,10 +195,12 @@ public static class RabbitMqManager
&& receivedStatusMessage.Status == AlarmStatus)
{
var prev = prevStatus;
var alarmsSnapshot = (IReadOnlyList<AlarmOrWarning>)
(receivedStatusMessage.Alarms?.ToList() ?? new List<AlarmOrWarning>());
_ = Task.Run(async () =>
{
try { await installation.SendAlarmNotificationToSupport(prev); }
catch (Exception ex) { Console.WriteLine($"[AlarmNotify] failed for {installationId}: {ex.Message}"); }
try { await AutoTicketService.MaybeCreateForAlarmAsync(installation, prev, alarmsSnapshot); }
catch (Exception ex) { Console.WriteLine($"[AutoTicket] alarm failed for {installationId}: {ex.Message}"); }
});
}

View File

@ -5,6 +5,7 @@ using System.Text;
using System.Text.Json;
using InnovEnergy.App.Backend.Database;
using InnovEnergy.App.Backend.DataTypes;
using InnovEnergy.App.Backend.Services;
using InnovEnergy.Lib.Utils;
namespace InnovEnergy.App.Backend.Websockets;
@ -18,6 +19,7 @@ public static class WebsocketManager
while (true)
{
var idsToInform = new List<Int64>();
var offlineTransitions = new List<(Int64 Id, Int32 PrevStatus, DateTime LastSeen)>();
lock (InstallationConnections)
{
@ -38,12 +40,18 @@ public static class WebsocketManager
Console.WriteLine("Installation ID is " + installationConnection.Key);
Console.WriteLine("installationConnection.Value.Timestamp is " + installationConnection.Value.Timestamp);
var prevStatus = installationConnection.Value.Status;
var lastSeen = installationConnection.Value.Timestamp;
installationConnection.Value.Status = (int)StatusType.Offline;
Db.UpdateInstallationStatus(installationConnection.Key, (int)StatusType.Offline);
if (installationConnection.Value.Connections.Count > 0)
{
idsToInform.Add(installationConnection.Key);
}
if (prevStatus != (int)StatusType.Offline)
offlineTransitions.Add((installationConnection.Key, prevStatus, lastSeen));
}
}
}
@ -52,6 +60,18 @@ public static class WebsocketManager
foreach (var id in idsToInform)
await InformWebsocketsForInstallation(id);
// Auto-create tickets for fresh offline transitions (outside the lock — needs DB reads + Task.Run)
foreach (var (id, prevStatus, lastSeen) in offlineTransitions)
{
var installation = Db.GetInstallationById(id);
if (installation is null) continue;
_ = Task.Run(async () =>
{
try { await AutoTicketService.MaybeCreateForOfflineAsync(installation, prevStatus, lastSeen); }
catch (Exception ex) { Console.WriteLine($"[AutoTicket] offline failed for {id}: {ex.Message}"); }
});
}
await Task.Delay(TimeSpan.FromMinutes(1));
}
}