Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Core/Resgrid.Config/NoticeConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@ public static class NoticeConfig
/// </summary>
public static string LoginPageNotice = "";

/// <summary>
/// The login page notice to display, preferring <see cref="LoginPageNotice"/> and falling back to the
/// legacy <see cref="SystemBehaviorConfig.LoginPageNotice"/> key so existing on-prem configs keep working.
/// </summary>
public static string EffectiveLoginPageNotice =>
!string.IsNullOrWhiteSpace(LoginPageNotice)
? LoginPageNotice
: SystemBehaviorConfig.LoginPageNotice;

/// <summary>
/// The dashboard toast notice
/// </summary>
Expand Down
4 changes: 3 additions & 1 deletion Core/Resgrid.Config/SystemBehaviorConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,9 @@ public static class SystemBehaviorConfig
public static string SiteKey = "";

/// <summary>
/// A notice to display on the login page
/// A notice to display on the login page.
/// Legacy key, kept so existing on-prem configs keep working. New configs should set
/// NoticeConfig.LoginPageNotice instead; read through NoticeConfig.EffectiveLoginPageNotice.
/// </summary>
public static string LoginPageNotice = "";

Expand Down
8 changes: 8 additions & 0 deletions Core/Resgrid.Config/TtsConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ public static class TtsConfig
public static string PiperModelDirectory = "/usr/local/share/piper-voices";
public static string FfmpegExecutable = "ffmpeg";
public static string TempDirectory = "";

/// <summary>
/// Age, in hours, at which an orphaned entry under <see cref="TempDirectory"/> is
/// swept, and the interval the sweep runs on. Only entries left behind by a hard
/// kill or a failed delete get this old; keep it well above
/// <see cref="GenerationTimeoutSeconds"/> so in-flight jobs are never collected.
/// </summary>
public static int TempDirectorySweepHours = 6;
public static string CachePrefix = "tts2";
public static int NormalizedSampleRate = 8000;
public static int NormalizedChannels = 1;
Expand Down
74 changes: 74 additions & 0 deletions Tests/Resgrid.Tests/Web/Services/DispatchListHelperTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
using System;
using System.Collections.Generic;
using FluentAssertions;
using NUnit.Framework;
using Resgrid.Web.Helpers;

namespace Resgrid.Tests.Web.Services
{
/// <summary>
/// The DispatchList is client supplied text, and a call that reaches the API with one bad entry still
/// has to dispatch everything else in it. Prod hit this with a Spanish department whose client sent
/// role names ("R:PARAMÉDICO") instead of role ids, which threw and dropped every role on the call.
/// </summary>
[TestFixture]
public class DispatchListHelperTests
{
private static readonly Dictionary<string, int> Roles = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
{
{ "Paramédico", 12 },
{ "Comandante", 34 }
};

private static int? ResolveRole(string name) => Roles.TryGetValue(name, out var id) ? id : (int?)null;

private static string[] Split(string dispatchList) => dispatchList.Split('|');

[Test]
public void Numeric_entries_are_returned_for_their_prefix_only()
{
var ids = DispatchListHelper.ResolveIds(Split("P:abc|G:1|R:12|U:7|R:34"), "R:", ResolveRole);

ids.Should().Equal(12, 34);
}

[Test]
public void A_name_is_resolved_when_the_entry_isnt_numeric()
{
var ids = DispatchListHelper.ResolveIds(Split("R:PARAMÉDICO"), "R:", ResolveRole);

ids.Should().Equal(12);
}

[Test]
public void One_unresolvable_entry_does_not_drop_the_rest()
{
var ids = DispatchListHelper.ResolveIds(Split("R:12|R:NOT A ROLE|R:34"), "R:", ResolveRole);

ids.Should().Equal(12, 34);
}

[Test]
public void Duplicates_and_empty_entries_are_ignored()
{
var ids = DispatchListHelper.ResolveIds(Split("R:12|R:|R:PARAMÉDICO|R: 12 ||R:34"), "R:", ResolveRole);

ids.Should().Equal(12, 34);
}

[Test]
public void An_id_wins_over_a_name_lookup()
{
var ids = DispatchListHelper.ResolveIds(Split("R:99"), "R:", _ => 12);

ids.Should().Equal(99);
}

[Test]
public void No_matching_prefix_returns_an_empty_list()
{
DispatchListHelper.ResolveIds(Split("G:1|U:2"), "R:", ResolveRole).Should().BeEmpty();
DispatchListHelper.ResolveIds(null, "R:", ResolveRole).Should().BeEmpty();
}
}
}
147 changes: 147 additions & 0 deletions Tests/Resgrid.Tests/Web/Tts/TempDirectorySweepHostedServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
using System;
using System.Collections.Generic;
using System.IO;
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NUnit.Framework;
using Resgrid.Web.Tts.Configuration;
using Resgrid.Web.Tts.Services;

namespace Resgrid.Tests.Web.Tts
{
[TestFixture]
public class TempDirectorySweepHostedServiceTests
{
private string _tempRoot;

[SetUp]
public void SetUp()
{
_tempRoot = Path.Combine(Path.GetTempPath(), $"resgrid-tts-sweep-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(_tempRoot);
}

[TearDown]
public void TearDown()
{
if (Directory.Exists(_tempRoot))
{
Directory.Delete(_tempRoot, recursive: true);
}
}

[Test]
public void sweep_once_should_delete_orphaned_working_directories_older_than_the_max_age()
{
var orphan = CreateWorkingDirectory("aged", DateTime.UtcNow.AddHours(-7));

var removed = CreateService().SweepOnce();

removed.Should().Be(1);
Directory.Exists(orphan).Should().BeFalse();
}

[Test]
public void sweep_once_should_leave_directories_newer_than_the_max_age_alone()
{
// An in-flight synthesis writes into a directory whose mtime is minutes old at
// most; the sweep must never collect one out from under a running job.
var inFlight = CreateWorkingDirectory("in-flight", DateTime.UtcNow.AddMinutes(-5));

var removed = CreateService().SweepOnce();

removed.Should().Be(0);
Directory.Exists(inFlight).Should().BeTrue();
}

[Test]
public void sweep_once_should_delete_stale_loose_files()
{
var stalePath = Path.Combine(_tempRoot, "stale.wav");
File.WriteAllBytes(stalePath, new byte[] { 1 });
File.SetLastWriteTimeUtc(stalePath, DateTime.UtcNow.AddHours(-7));

var removed = CreateService().SweepOnce();

removed.Should().Be(1);
File.Exists(stalePath).Should().BeFalse();
}

[Test]
public void sweep_once_should_return_zero_when_the_temp_root_does_not_exist()
{
Directory.Delete(_tempRoot, recursive: true);

CreateService().SweepOnce().Should().Be(0);
}

[Test]
public void sweep_once_should_log_the_number_of_entries_it_removed()
{
CreateWorkingDirectory("aged-one", DateTime.UtcNow.AddHours(-7));
CreateWorkingDirectory("aged-two", DateTime.UtcNow.AddHours(-8));
CreateWorkingDirectory("fresh", DateTime.UtcNow);
var logger = new RecordingLogger<TempDirectorySweepHostedService>();

var removed = CreateService(logger).SweepOnce();

removed.Should().Be(2);
logger.Entries.Should().ContainSingle(x =>
x.Level == LogLevel.Information &&
x.Message.Contains("Swept 2 orphaned TTS temp entries"));
}

private string CreateWorkingDirectory(string name, DateTime lastWriteUtc)
{
var path = Path.Combine(_tempRoot, name);
Directory.CreateDirectory(path);
File.WriteAllBytes(Path.Combine(path, "raw.wav"), new byte[] { 1 });
Directory.SetLastWriteTimeUtc(path, lastWriteUtc);
return path;
}

private TempDirectorySweepHostedService CreateService(ILogger<TempDirectorySweepHostedService> logger = null)
{
return new TempDirectorySweepHostedService(
Options.Create(new TtsOptions
{
TempDirectory = _tempRoot,
TempDirectorySweepHours = 6
}),
logger ?? NullLogger<TempDirectorySweepHostedService>.Instance);
}

private sealed class RecordingLogger<T> : ILogger<T>
{
public List<LogEntry> Entries { get; } = new();

public IDisposable BeginScope<TState>(TState state)
{
return NullScope.Instance;
}

public bool IsEnabled(LogLevel logLevel)
{
return true;
}

public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func<TState, Exception, string> formatter)
{
Entries.Add(new LogEntry(logLevel, exception, formatter(state, exception)));
}

public sealed record LogEntry(LogLevel Level, Exception Exception, string Message);

private sealed class NullScope : IDisposable
{
public static readonly NullScope Instance = new();

public void Dispose()
{
}
}
}
}
}
20 changes: 13 additions & 7 deletions Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -765,7 +765,8 @@ public async Task<ActionResult<SaveCallResult>> SaveCall([FromBody] NewCallInput

try
{
var groupsToDispatch = dispatch.Where(x => x.StartsWith("G:")).Select(y => int.Parse(y.Replace("G:", "")));
var groupsToDispatch = DispatchListHelper.ResolveIds(dispatch,"G:",
name => groups.FirstOrDefault(x => string.Equals(x.Name?.Trim(), name, StringComparison.OrdinalIgnoreCase))?.DepartmentGroupId);
foreach (var group in groupsToDispatch)
{
if (groups.Any(x => x.DepartmentGroupId == group))
Expand All @@ -782,7 +783,8 @@ public async Task<ActionResult<SaveCallResult>> SaveCall([FromBody] NewCallInput

try
{
var rolesToDispatch = dispatch.Where(x => x.StartsWith("R:")).Select(y => int.Parse(y.Replace("R:", "")));
var rolesToDispatch = DispatchListHelper.ResolveIds(dispatch,"R:",
name => roles.FirstOrDefault(x => string.Equals(x.Name?.Trim(), name, StringComparison.OrdinalIgnoreCase))?.PersonnelRoleId);
foreach (var role in rolesToDispatch)
{
if (roles.Any(x => x.PersonnelRoleId == role))
Expand All @@ -799,7 +801,8 @@ public async Task<ActionResult<SaveCallResult>> SaveCall([FromBody] NewCallInput

try
{
var unitsToDispatch = dispatch.Where(x => x.StartsWith("U:")).Select(y => int.Parse(y.Replace("U:", "")));
var unitsToDispatch = DispatchListHelper.ResolveIds(dispatch,"U:",
name => units.FirstOrDefault(x => string.Equals(x.Name?.Trim(), name, StringComparison.OrdinalIgnoreCase))?.UnitId);
foreach (var unit in unitsToDispatch)
{
if (units.Any(x => x.UnitId == unit))
Expand Down Expand Up @@ -1063,10 +1066,13 @@ public async Task<ActionResult<EditCallResult>> EditCall([FromBody] EditCallInpu
else
{
var dispatch = editCallInput.DispatchList.Split(char.Parse("|"));
var usersToDispatch = dispatch.Where(x => x.StartsWith("P:")).Select(y => y.Replace("P:", ""));
var groupsToDispatch = dispatch.Where(x => x.StartsWith("G:")).Select(y => int.Parse(y.Replace("G:", "")));
var rolesToDispatch = dispatch.Where(x => x.StartsWith("R:")).Select(y => int.Parse(y.Replace("R:", "")));
var unitsToDispatch = dispatch.Where(x => x.StartsWith("U:")).Select(y => int.Parse(y.Replace("U:", "")));
var usersToDispatch = dispatch.Where(x => x.StartsWith("P:")).Select(y => y.Replace("P:", "")).ToList();
var groupsToDispatch = DispatchListHelper.ResolveIds(dispatch,"G:",
name => groups.FirstOrDefault(x => string.Equals(x.Name?.Trim(), name, StringComparison.OrdinalIgnoreCase))?.DepartmentGroupId);
var rolesToDispatch = DispatchListHelper.ResolveIds(dispatch,"R:",
name => roles.FirstOrDefault(x => string.Equals(x.Name?.Trim(), name, StringComparison.OrdinalIgnoreCase))?.PersonnelRoleId);
var unitsToDispatch = DispatchListHelper.ResolveIds(dispatch,"U:",
name => units.FirstOrDefault(x => string.Equals(x.Name?.Trim(), name, StringComparison.OrdinalIgnoreCase))?.UnitId);

try
{
Expand Down
58 changes: 58 additions & 0 deletions Web/Resgrid.Web.Services/Helpers/DispatchListHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Resgrid.Framework;

namespace Resgrid.Web.Helpers
{
/// <summary>
/// Parsing for the pipe delimited DispatchList string clients send when creating or editing a call.
/// </summary>
public static class DispatchListHelper
{
/// <summary>
/// Pulls the ids out of a DispatchList for a single prefix ("G:", "R:" or "U:"). Clients are supposed
/// to send ids, but some send the display name instead (i.e. "R:PARAMÉDICO"), and one unparsable entry
/// used to throw out of the int.Parse projection and silently drop every id sharing that prefix. Each
/// entry is parsed on its own now and falls back to a name lookup before it's discarded.
/// </summary>
/// <param name="dispatchList">The already split DispatchList entries.</param>
/// <param name="prefix">The entry prefix to collect, i.e. "G:", "R:" or "U:".</param>
/// <param name="resolveByName">Looks up an id for an entry that isn't numeric, null when there's no match.</param>
/// <returns>The distinct ids found for that prefix, in the order they appeared.</returns>
public static List<int> ResolveIds(IEnumerable<string> dispatchList, string prefix, Func<string, int?> resolveByName)
{
var ids = new List<int>();

if (dispatchList == null)
return ids;

foreach (var entry in dispatchList.Where(x => !string.IsNullOrWhiteSpace(x) && x.StartsWith(prefix)))
{
var value = entry.Substring(prefix.Length).Trim();

if (string.IsNullOrWhiteSpace(value))
continue;

if (!int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var id))
{
var resolved = resolveByName?.Invoke(value);

if (!resolved.HasValue)
{
Logging.LogWarning($"Discarding dispatch list entry '{entry}', it's neither an id nor a known name.");
continue;
}

id = resolved.Value;
}

if (!ids.Contains(id))
ids.Add(id);
}

return ids;
}
}
}
17 changes: 17 additions & 0 deletions Web/Resgrid.Web.Services/Resgrid.Web.Services.xml
Original file line number Diff line number Diff line change
Expand Up @@ -13453,5 +13453,22 @@
Respects the provided cancellation token for timeout control.
</summary>
</member>
<member name="T:Resgrid.Web.Helpers.DispatchListHelper">
<summary>
Parsing for the pipe delimited DispatchList string clients send when creating or editing a call.
</summary>
</member>
<member name="M:Resgrid.Web.Helpers.DispatchListHelper.ResolveIds(System.Collections.Generic.IEnumerable{System.String},System.String,System.Func{System.String,System.Nullable{System.Int32}})">
<summary>
Pulls the ids out of a DispatchList for a single prefix ("G:", "R:" or "U:"). Clients are supposed
to send ids, but some send the display name instead (i.e. "R:PARAMÉDICO"), and one unparsable entry
used to throw out of the int.Parse projection and silently drop every id sharing that prefix. Each
entry is parsed on its own now and falls back to a name lookup before it's discarded.
</summary>
<param name="dispatchList">The already split DispatchList entries.</param>
<param name="prefix">The entry prefix to collect, i.e. "G:", "R:" or "U:".</param>
<param name="resolveByName">Looks up an id for an entry that isn't numeric, null when there's no match.</param>
<returns>The distinct ids found for that prefix, in the order they appeared.</returns>
</member>
</members>
</doc>
Loading
Loading