Merge branch 'feature/auto-ticket-on-status'
This commit is contained in:
commit
3d1b249b15
|
|
@ -1,6 +1,5 @@
|
||||||
using InnovEnergy.App.Backend.Database;
|
using InnovEnergy.App.Backend.Database;
|
||||||
using InnovEnergy.App.Backend.Relations;
|
using InnovEnergy.App.Backend.Relations;
|
||||||
using InnovEnergy.Lib.Mailer;
|
|
||||||
using InnovEnergy.Lib.Utils;
|
using InnovEnergy.Lib.Utils;
|
||||||
|
|
||||||
namespace InnovEnergy.App.Backend.DataTypes.Methods;
|
namespace InnovEnergy.App.Backend.DataTypes.Methods;
|
||||||
|
|
@ -173,38 +172,4 @@ public static class InstallationMethods
|
||||||
return true;
|
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.Database;
|
||||||
using InnovEnergy.App.Backend.DataTypes;
|
using InnovEnergy.App.Backend.DataTypes;
|
||||||
using InnovEnergy.App.Backend.DataTypes.Methods;
|
using InnovEnergy.App.Backend.DataTypes.Methods;
|
||||||
|
using InnovEnergy.App.Backend.Services;
|
||||||
using InnovEnergy.Lib.Utils;
|
using InnovEnergy.Lib.Utils;
|
||||||
using RabbitMQ.Client;
|
using RabbitMQ.Client;
|
||||||
using RabbitMQ.Client.Events;
|
using RabbitMQ.Client.Events;
|
||||||
|
|
@ -194,10 +195,12 @@ public static class RabbitMqManager
|
||||||
&& receivedStatusMessage.Status == AlarmStatus)
|
&& receivedStatusMessage.Status == AlarmStatus)
|
||||||
{
|
{
|
||||||
var prev = prevStatus;
|
var prev = prevStatus;
|
||||||
|
var alarmsSnapshot = (IReadOnlyList<AlarmOrWarning>)
|
||||||
|
(receivedStatusMessage.Alarms?.ToList() ?? new List<AlarmOrWarning>());
|
||||||
_ = Task.Run(async () =>
|
_ = Task.Run(async () =>
|
||||||
{
|
{
|
||||||
try { await installation.SendAlarmNotificationToSupport(prev); }
|
try { await AutoTicketService.MaybeCreateForAlarmAsync(installation, prev, alarmsSnapshot); }
|
||||||
catch (Exception ex) { Console.WriteLine($"[AlarmNotify] failed for {installationId}: {ex.Message}"); }
|
catch (Exception ex) { Console.WriteLine($"[AutoTicket] alarm failed for {installationId}: {ex.Message}"); }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using InnovEnergy.App.Backend.Database;
|
using InnovEnergy.App.Backend.Database;
|
||||||
using InnovEnergy.App.Backend.DataTypes;
|
using InnovEnergy.App.Backend.DataTypes;
|
||||||
|
using InnovEnergy.App.Backend.Services;
|
||||||
using InnovEnergy.Lib.Utils;
|
using InnovEnergy.Lib.Utils;
|
||||||
|
|
||||||
namespace InnovEnergy.App.Backend.Websockets;
|
namespace InnovEnergy.App.Backend.Websockets;
|
||||||
|
|
@ -18,6 +19,7 @@ public static class WebsocketManager
|
||||||
while (true)
|
while (true)
|
||||||
{
|
{
|
||||||
var idsToInform = new List<Int64>();
|
var idsToInform = new List<Int64>();
|
||||||
|
var offlineTransitions = new List<(Int64 Id, Int32 PrevStatus, DateTime LastSeen)>();
|
||||||
|
|
||||||
lock (InstallationConnections)
|
lock (InstallationConnections)
|
||||||
{
|
{
|
||||||
|
|
@ -38,12 +40,18 @@ public static class WebsocketManager
|
||||||
Console.WriteLine("Installation ID is " + installationConnection.Key);
|
Console.WriteLine("Installation ID is " + installationConnection.Key);
|
||||||
Console.WriteLine("installationConnection.Value.Timestamp is " + installationConnection.Value.Timestamp);
|
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;
|
installationConnection.Value.Status = (int)StatusType.Offline;
|
||||||
Db.UpdateInstallationStatus(installationConnection.Key, (int)StatusType.Offline);
|
Db.UpdateInstallationStatus(installationConnection.Key, (int)StatusType.Offline);
|
||||||
if (installationConnection.Value.Connections.Count > 0)
|
if (installationConnection.Value.Connections.Count > 0)
|
||||||
{
|
{
|
||||||
idsToInform.Add(installationConnection.Key);
|
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)
|
foreach (var id in idsToInform)
|
||||||
await InformWebsocketsForInstallation(id);
|
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));
|
await Task.Delay(TimeSpan.FromMinutes(1));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue