Innovenergy_trunk/csharp/App/Backend/Services/AutoTicketService.cs

251 lines
10 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;
// Edit/testing mode: alarms during commissioning shouldn't open tickets.
if (installation.TestingMode) 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;
// Edit/testing mode: offline transitions during commissioning shouldn't open tickets.
if (installation.TestingMode) 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)
};
}