< Summary

Information
Class: OzonOrdersWeb.WebServices.SystemAlerts.SystemAlertIncidentStore
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/WebServices/SystemAlerts/SystemAlertIncidentStore.cs
Line coverage
91%
Covered lines: 148
Uncovered lines: 13
Coverable lines: 161
Total lines: 293
Line coverage: 91.9%
Branch coverage
84%
Covered branches: 96
Total branches: 114
Branch coverage: 84.2%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%44100%
GetActiveAsync(...)100%11100%
GetActiveCountsAsync()100%88100%
GetActiveIdsAsync()0%7280%
InvalidateCounts()100%44100%
SyncAsync()98.07%5252100%
ApplyOrders(...)100%66100%
PublishOrderIndex(...)70%1010100%
EnsureOrderIndexAsync()100%44100%
AcknowledgeAsync()50%22100%
MuteAsync()50%22100%
RestoreAsync()50%22100%
FindActiveAsync(...)100%11100%
IsSuppressed(...)75%44100%
ClearSuppression(...)100%11100%
SeverityRank(...)75%44100%
Truncate(...)83.33%66100%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/WebServices/SystemAlerts/SystemAlertIncidentStore.cs

#LineLine coverage
 1using Microsoft.EntityFrameworkCore;
 2using Microsoft.Extensions.Caching.Memory;
 3using Microsoft.Extensions.Options;
 4using OzonDomains.Models;
 5using OzonRepositories.Context;
 6
 7namespace OzonOrdersWeb.WebServices.SystemAlerts;
 8
 299public sealed class SystemAlertIncidentStore(
 2910    ApplicationDbContext context,
 2911    IOptions<SystemAlertsOptions>? options = null,
 2912    IMemoryCache? cache = null,
 2913    SystemAlertOrderIndex? orderIndex = null)
 14{
 15    /// <summary>Сколько заказов хранится в инциденте: столько же умещается в ссылку на сетку заказов.</summary>
 16    private const int StoredOrdersLimit = SystemAlertLinks.MaxOrdersInLink;
 17
 18    private const string CountsCacheKey = "system-alert-counts";
 19    private const string IdsCacheKey = "system-alert-ids";
 20
 21    private const int TitleMaxLength = 500;
 22    private const int DetailsMaxLength = 1000;
 23    private const int UrlMaxLength = 2000;
 24
 2925    private readonly SystemAlertsOptions _options = options?.Value ?? new SystemAlertsOptions();
 26
 27    /// <summary>
 28    /// Активные подтверждённые инциденты, которые видны админам: без «наблюдаемых» (debounce),
 29    /// принятых к сведению и заглушённых.
 30    /// </summary>
 31    public Task<List<SystemAlertIncident>> GetActiveAsync(CancellationToken cancellationToken)
 32    {
 933        var now = DateTime.UtcNow;
 934        return context.SystemAlertIncidents.AsNoTracking()
 935            .Where(x => x.IsActive && x.ConfirmedAtUtc != null && x.AcknowledgedAtUtc == null
 936                        && (x.MutedUntilUtc == null || x.MutedUntilUtc <= now))
 937            .OrderBy(x => x.Severity == "danger" ? 0 : x.Severity == "warning" ? 1 : 2)
 938            .ThenByDescending(x => x.LastObservedAtUtc).ToListAsync(cancellationToken);
 39    }
 40
 41    /// <summary>
 42    /// Счётчики видимых инцидентов по severity для чипов в шапке: один сгруппированный запрос и короткий кэш,
 43    /// который сбрасывается при любом изменении инцидентов.
 44    /// </summary>
 45    public async Task<SystemAlertCounts> GetActiveCountsAsync(CancellationToken cancellationToken)
 46    {
 447        if (cache != null && cache.TryGetValue(CountsCacheKey, out SystemAlertCounts? cached) && cached != null)
 148            return cached;
 49
 350        var now = DateTime.UtcNow;
 351        var rows = await context.SystemAlertIncidents.AsNoTracking()
 352            .Where(x => x.IsActive && x.ConfirmedAtUtc != null && x.AcknowledgedAtUtc == null
 353                        && (x.MutedUntilUtc == null || x.MutedUntilUtc <= now))
 354            .GroupBy(x => x.Severity)
 355            .Select(g => new { Severity = g.Key, Count = g.Count() })
 356            .ToListAsync(cancellationToken);
 2557        int Count(string severity) => rows.Where(x => x.Severity == severity).Sum(x => x.Count);
 358        var counts = new SystemAlertCounts(Count("danger"), Count("warning"), Count("info"));
 359        cache?.Set(CountsCacheKey, counts, TimeSpan.FromSeconds(Math.Max(1, _options.CountsCacheSeconds)));
 360        return counts;
 461    }
 62
 63    /// <summary>
 64    /// Id видимых инцидентов по severity: по ним браузер отличает новые алерты (ещё не просмотренные пользователем)
 65    /// от уже знакомых. Тот же фильтр и тот же кэш-срок, что у счётчиков.
 66    /// </summary>
 67    public async Task<SystemAlertIds> GetActiveIdsAsync(CancellationToken cancellationToken)
 68    {
 069        if (cache != null && cache.TryGetValue(IdsCacheKey, out SystemAlertIds? cached) && cached != null)
 070            return cached;
 71
 072        var now = DateTime.UtcNow;
 073        var rows = await context.SystemAlertIncidents.AsNoTracking()
 074            .Where(x => x.IsActive && x.ConfirmedAtUtc != null && x.AcknowledgedAtUtc == null
 075                        && (x.MutedUntilUtc == null || x.MutedUntilUtc <= now))
 076            .Select(x => new { x.Id, x.Severity })
 077            .ToListAsync(cancellationToken);
 078        List<long> Ids(string severity) => rows.Where(x => x.Severity == severity).Select(x => x.Id).OrderBy(x => x).ToL
 079        var ids = new SystemAlertIds(Ids("danger"), Ids("warning"), Ids("info"));
 080        cache?.Set(IdsCacheKey, ids, TimeSpan.FromSeconds(Math.Max(1, _options.CountsCacheSeconds)));
 081        return ids;
 082    }
 83
 84    private void InvalidateCounts()
 85    {
 4686        cache?.Remove(CountsCacheKey);
 4687        cache?.Remove(IdsCacheKey);
 288    }
 89
 90    public async Task<IReadOnlyList<SystemAlertTransition>> SyncAsync(IEnumerable<SystemAlertFinding> findings, Cancella
 91    {
 3892        var now = DateTime.UtcNow;
 3893        var confirmationChecks = Math.Max(1, _options.ConfirmationChecks);
 11294        var list = findings.GroupBy(x => x.Key, StringComparer.Ordinal).Select(x => x.First()).ToList();
 3895        var active = await context.SystemAlertIncidents.Where(x => x.IsActive).OrderBy(x => x.Id).ToListAsync(cancellati
 3896        var transitions = new List<SystemAlertTransition>();
 3897        var current = new Dictionary<string, SystemAlertIncident>(StringComparer.Ordinal);
 98
 99        // Уникального индекса на (Key, IsActive) нет: при дубле оставляем самый старый, остальные закрываем.
 38100        var byKey = new Dictionary<string, SystemAlertIncident>(StringComparer.Ordinal);
 110101        foreach (var incident in active)
 102        {
 17103            if (byKey.TryAdd(incident.Key, incident)) continue;
 1104            incident.IsActive = false;
 1105            incident.ResolvedAtUtc = now;
 106        }
 107
 150108        foreach (var finding in list)
 109        {
 37110            var title = Truncate(finding.Title, TitleMaxLength);
 37111            var details = Truncate(string.Join("\n", finding.Lines), DetailsMaxLength);
 37112            var url = Truncate(finding.Url, UrlMaxLength);
 37113            if (!byKey.TryGetValue(finding.Key, out var incident))
 114            {
 25115                var confirmed = !finding.Debounced || confirmationChecks <= 1;
 25116                var created = new SystemAlertIncident
 25117                {
 25118                    Key = finding.Key, Domain = finding.Domain, Severity = finding.Severity, Title = title,
 25119                    Details = details, Url = url, FirstObservedAtUtc = now, LastObservedAtUtc = now,
 25120                    OccurrenceCount = 1, ConsecutiveObservations = 1, IsActive = true,
 25121                    ConfirmedAtUtc = confirmed ? now : null
 25122                };
 25123                ApplyOrders(created, finding);
 25124                context.SystemAlertIncidents.Add(created);
 25125                current[finding.Key] = created;
 25126                if (confirmed)
 21127                    transitions.Add(new SystemAlertTransition(finding.Key, finding.Severity, title, finding.Lines, false
 21128                        finding.Domain, finding.Url, finding.Orders?.Count ?? 0));
 21129                continue;
 130            }
 131
 132            // Проверки чаще, чем раз в ConfirmationMinGapSeconds (ручные обновления) не приближают подтверждение:
 133            // debounce должен отсекать сбой во времени, а не по числу кликов.
 12134            if (incident.ConfirmedAtUtc == null && (now - incident.LastObservedAtUtc).TotalSeconds < _options.Confirmati
 135                continue;
 136
 10137            incident.ConsecutiveObservations = incident.ConsecutiveObservations < int.MaxValue ? incident.ConsecutiveObs
 10138            var notify = false;
 10139            if (incident.ConfirmedAtUtc == null)
 140            {
 141                // Проблема «на наблюдении»: открываем только после N проверок подряд, единичный сбой не мигает.
 2142                if (incident.ConsecutiveObservations >= confirmationChecks)
 143                {
 2144                    incident.ConfirmedAtUtc = now;
 145                    // Время начала считаем с первой проверки, а не с подтверждения.
 2146                    notify = true;
 147                }
 148            }
 8149            else if (incident.Severity != finding.Severity)
 150            {
 151                // OccurrenceCount считает смены состояния (открытие, изменение severity), а не проверки джоба.
 3152                incident.OccurrenceCount++;
 3153                if (SeverityRank(finding.Severity) < SeverityRank(incident.Severity))
 3154                    ClearSuppression(incident); // эскалация возвращает инцидент в поле зрения
 3155                notify = true;
 156            }
 5157            else if (incident.MutedUntilUtc <= now)
 158            {
 159                // Заглушка истекла, а проблема осталась — напоминаем один раз.
 1160                notify = true;
 161            }
 162
 10163            if (incident.MutedUntilUtc <= now)
 164            {
 1165                incident.MutedUntilUtc = null;
 1166                incident.MutedBy = null;
 167            }
 168
 30169            incident.Domain = finding.Domain; incident.Severity = finding.Severity; incident.Title = title;
 20170            incident.Details = details; incident.Url = url;
 10171            incident.LastObservedAtUtc = now;
 10172            ApplyOrders(incident, finding);
 10173            current[finding.Key] = incident;
 10174            if (notify && !IsSuppressed(incident, now))
 6175                transitions.Add(new SystemAlertTransition(finding.Key, finding.Severity, title, finding.Lines, false,
 6176                        finding.Domain, finding.Url, finding.Orders?.Count ?? 0));
 177        }
 178
 75179        var keys = list.Select(x => x.Key).ToHashSet(StringComparer.Ordinal);
 100180        foreach (var incident in byKey.Values.Where(x => !keys.Contains(x.Key)))
 181        {
 4182            if (incident.ConfirmedAtUtc == null)
 183            {
 184                // Не дожили до подтверждения: это был единичный сбой, в историю не пишем.
 1185                context.SystemAlertIncidents.Remove(incident);
 1186                continue;
 187            }
 188
 3189            var suppressed = IsSuppressed(incident, now);
 6190            incident.IsActive = false; incident.ResolvedAtUtc = now;
 3191            if (!suppressed)
 2192                transitions.Add(new SystemAlertTransition(incident.Key, incident.Severity, incident.Title, [], true,
 2193                    incident.Domain, incident.Url, incident.OrderCount));
 3194            ClearSuppression(incident);
 195        }
 38196        await context.SaveChangesAsync(cancellationToken);
 38197        InvalidateCounts();
 38198        PublishOrderIndex(list, current);
 38199        return transitions;
 38200    }
 201
 202    private static void ApplyOrders(SystemAlertIncident incident, SystemAlertFinding finding)
 203    {
 35204        incident.OrderCount = finding.Orders?.Count ?? 0;
 35205        incident.OrdersJson = finding.Orders is { Count: > 0 } ? SystemAlertOrderIndex.Serialize(finding.Orders, StoredO
 35206    }
 207
 208    /// <summary>
 209    /// Пересобирает индекс «заказ → алерты» из подтверждённых находок. Ack и mute индекс не скрывают:
 210    /// заказ по-прежнему проблемный, а «тишина» относится к баннеру и чипам.
 211    /// </summary>
 212    private void PublishOrderIndex(IReadOnlyList<SystemAlertFinding> findings, Dictionary<string, SystemAlertIncident> c
 213    {
 69214        if (orderIndex == null) return;
 7215        orderIndex.Replace(findings
 6216            .Where(f => f.Orders is { Count: > 0 } && current.TryGetValue(f.Key, out var incident) && incident.Confirmed
 216217            .SelectMany(f => f.Orders!.Select(o => (new OrderAlertEntry(f.Key, o.Severity ?? f.Severity, f.Domain, f.Tit
 7218    }
 219
 220    /// <summary>После рестарта индекса нет до первой проверки алертов: достраиваем его из сохранённых инцидентов.</summ
 221    public async Task EnsureOrderIndexAsync(CancellationToken cancellationToken)
 222    {
 9223        if (orderIndex == null || orderIndex.IsLoaded) return;
 224
 5225        var incidents = await context.SystemAlertIncidents.AsNoTracking()
 5226            .Where(x => x.IsActive && x.ConfirmedAtUtc != null && x.OrdersJson != null)
 5227            .Select(x => new { x.Key, x.Severity, x.Domain, x.Title, x.OrdersJson })
 5228            .ToListAsync(cancellationToken);
 7229        orderIndex.Replace(incidents.SelectMany(x => SystemAlertOrderIndex.Deserialize(x.OrdersJson)
 158230            .Select(o => (new OrderAlertEntry(x.Key, o.Severity ?? x.Severity, x.Domain, x.Title, o.Reason), o.Id))));
 7231    }
 232
 233    /// <summary>Принять инцидент к сведению: не шумит до восстановления или повышения severity.</summary>
 234    public async Task<bool> AcknowledgeAsync(long id, string? userName, CancellationToken cancellationToken)
 235    {
 5236        var incident = await FindActiveAsync(id, cancellationToken);
 5237        if (incident == null) return false;
 5238        incident.AcknowledgedAtUtc = DateTime.UtcNow;
 5239        incident.AcknowledgedBy = Truncate(userName, 100);
 5240        await context.SaveChangesAsync(cancellationToken);
 5241        InvalidateCounts();
 5242        return true;
 5243    }
 244
 245    /// <summary>Заглушить инцидент на несколько часов (1..<see cref="SystemAlertsOptions.MaxMuteHours"/>).</summary>
 246    public async Task<bool> MuteAsync(long id, int hours, string? userName, CancellationToken cancellationToken)
 247    {
 2248        var incident = await FindActiveAsync(id, cancellationToken);
 2249        if (incident == null) return false;
 2250        var clamped = Math.Clamp(hours, 1, Math.Max(1, _options.MaxMuteHours));
 2251        incident.MutedUntilUtc = DateTime.UtcNow.AddHours(clamped);
 2252        incident.MutedBy = Truncate(userName, 100);
 2253        await context.SaveChangesAsync(cancellationToken);
 2254        InvalidateCounts();
 2255        return true;
 2256    }
 257
 258    /// <summary>Снять подтверждение и заглушку.</summary>
 259    public async Task<bool> RestoreAsync(long id, CancellationToken cancellationToken)
 260    {
 1261        var incident = await FindActiveAsync(id, cancellationToken);
 1262        if (incident == null) return false;
 1263        ClearSuppression(incident);
 1264        await context.SaveChangesAsync(cancellationToken);
 1265        InvalidateCounts();
 1266        return true;
 1267    }
 268
 269    private Task<SystemAlertIncident?> FindActiveAsync(long id, CancellationToken cancellationToken) =>
 8270        context.SystemAlertIncidents.FirstOrDefaultAsync(x => x.Id == id && x.IsActive && x.ConfirmedAtUtc != null, canc
 271
 272    public static bool IsSuppressed(SystemAlertIncident incident, DateTime nowUtc) =>
 9273        incident.AcknowledgedAtUtc != null || incident.MutedUntilUtc > nowUtc;
 274
 275    private static void ClearSuppression(SystemAlertIncident incident)
 276    {
 14277        incident.AcknowledgedAtUtc = null; incident.AcknowledgedBy = null;
 14278        incident.MutedUntilUtc = null; incident.MutedBy = null;
 7279    }
 280
 12281    private static int SeverityRank(string severity) => severity switch { "danger" => 0, "warning" => 1, _ => 2 };
 282
 283    /// <summary>Поля инцидента ограничены схемой: превышение в strict-режиме MariaDB уронило бы весь SaveChanges.</summ
 284    private static string Truncate(string? value, int maxLength) =>
 118285        string.IsNullOrEmpty(value) || value.Length <= maxLength ? value ?? string.Empty : string.Concat(value.AsSpan(0,
 286}
 287
 288public sealed record SystemAlertCounts(int Danger, int Warning, int Info);
 289
 290public sealed record SystemAlertIds(List<long> Danger, List<long> Warning, List<long> Info);
 291
 292public sealed record SystemAlertTransition(string Key, string Severity, string Title, IReadOnlyList<string> Lines, bool 
 293    string Domain = "", string? Url = null, int OrderCount = 0);