auto create a ticket with necessary informaton triggered b y a installation status changed
This commit is contained in:
parent
ed00b742a1
commit
26cc0ac2a1
|
|
@ -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}"
|
||||
};
|
||||
}
|
||||
|
|
@ -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)
|
||||
};
|
||||
}
|
||||
|
|
@ -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}"); }
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue