247 lines
9.9 KiB
C#
247 lines
9.9 KiB
C#
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)
|
|
};
|
|
}
|