diff --git a/Core/Resgrid.Config/NoticeConfig.cs b/Core/Resgrid.Config/NoticeConfig.cs
index 2a6f71c23..569428296 100644
--- a/Core/Resgrid.Config/NoticeConfig.cs
+++ b/Core/Resgrid.Config/NoticeConfig.cs
@@ -10,6 +10,15 @@ public static class NoticeConfig
///
public static string LoginPageNotice = "";
+ ///
+ /// The login page notice to display, preferring and falling back to the
+ /// legacy key so existing on-prem configs keep working.
+ ///
+ public static string EffectiveLoginPageNotice =>
+ !string.IsNullOrWhiteSpace(LoginPageNotice)
+ ? LoginPageNotice
+ : SystemBehaviorConfig.LoginPageNotice;
+
///
/// The dashboard toast notice
///
diff --git a/Core/Resgrid.Config/SystemBehaviorConfig.cs b/Core/Resgrid.Config/SystemBehaviorConfig.cs
index a886eb1aa..9ab85cb6b 100644
--- a/Core/Resgrid.Config/SystemBehaviorConfig.cs
+++ b/Core/Resgrid.Config/SystemBehaviorConfig.cs
@@ -195,7 +195,9 @@ public static class SystemBehaviorConfig
public static string SiteKey = "";
///
- /// 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.
///
public static string LoginPageNotice = "";
diff --git a/Core/Resgrid.Config/TtsConfig.cs b/Core/Resgrid.Config/TtsConfig.cs
index eeeb27f94..6c9b8feb5 100644
--- a/Core/Resgrid.Config/TtsConfig.cs
+++ b/Core/Resgrid.Config/TtsConfig.cs
@@ -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 = "";
+
+ ///
+ /// Age, in hours, at which an orphaned entry under 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
+ /// so in-flight jobs are never collected.
+ ///
+ public static int TempDirectorySweepHours = 6;
public static string CachePrefix = "tts2";
public static int NormalizedSampleRate = 8000;
public static int NormalizedChannels = 1;
diff --git a/Tests/Resgrid.Tests/Web/Services/DispatchListHelperTests.cs b/Tests/Resgrid.Tests/Web/Services/DispatchListHelperTests.cs
new file mode 100644
index 000000000..e52c895bf
--- /dev/null
+++ b/Tests/Resgrid.Tests/Web/Services/DispatchListHelperTests.cs
@@ -0,0 +1,74 @@
+using System;
+using System.Collections.Generic;
+using FluentAssertions;
+using NUnit.Framework;
+using Resgrid.Web.Helpers;
+
+namespace Resgrid.Tests.Web.Services
+{
+ ///
+ /// 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.
+ ///
+ [TestFixture]
+ public class DispatchListHelperTests
+ {
+ private static readonly Dictionary Roles = new Dictionary(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();
+ }
+ }
+}
diff --git a/Tests/Resgrid.Tests/Web/Tts/TempDirectorySweepHostedServiceTests.cs b/Tests/Resgrid.Tests/Web/Tts/TempDirectorySweepHostedServiceTests.cs
new file mode 100644
index 000000000..483011b19
--- /dev/null
+++ b/Tests/Resgrid.Tests/Web/Tts/TempDirectorySweepHostedServiceTests.cs
@@ -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();
+
+ 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 logger = null)
+ {
+ return new TempDirectorySweepHostedService(
+ Options.Create(new TtsOptions
+ {
+ TempDirectory = _tempRoot,
+ TempDirectorySweepHours = 6
+ }),
+ logger ?? NullLogger.Instance);
+ }
+
+ private sealed class RecordingLogger : ILogger
+ {
+ public List Entries { get; } = new();
+
+ public IDisposable BeginScope(TState state)
+ {
+ return NullScope.Instance;
+ }
+
+ public bool IsEnabled(LogLevel logLevel)
+ {
+ return true;
+ }
+
+ public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func 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()
+ {
+ }
+ }
+ }
+ }
+}
diff --git a/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs
index a16d1d8d2..766253aa4 100644
--- a/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs
+++ b/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs
@@ -765,7 +765,8 @@ public async Task> 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))
@@ -782,7 +783,8 @@ public async Task> 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))
@@ -799,7 +801,8 @@ public async Task> 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))
@@ -1063,10 +1066,13 @@ public async Task> 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
{
diff --git a/Web/Resgrid.Web.Services/Helpers/DispatchListHelper.cs b/Web/Resgrid.Web.Services/Helpers/DispatchListHelper.cs
new file mode 100644
index 000000000..50760eb7a
--- /dev/null
+++ b/Web/Resgrid.Web.Services/Helpers/DispatchListHelper.cs
@@ -0,0 +1,58 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using Resgrid.Framework;
+
+namespace Resgrid.Web.Helpers
+{
+ ///
+ /// Parsing for the pipe delimited DispatchList string clients send when creating or editing a call.
+ ///
+ public static class DispatchListHelper
+ {
+ ///
+ /// 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.
+ ///
+ /// The already split DispatchList entries.
+ /// The entry prefix to collect, i.e. "G:", "R:" or "U:".
+ /// Looks up an id for an entry that isn't numeric, null when there's no match.
+ /// The distinct ids found for that prefix, in the order they appeared.
+ public static List ResolveIds(IEnumerable dispatchList, string prefix, Func resolveByName)
+ {
+ var ids = new List();
+
+ 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;
+ }
+ }
+}
diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml
index 69fd4ac7c..d58d201bf 100644
--- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml
+++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml
@@ -13453,5 +13453,22 @@
Respects the provided cancellation token for timeout control.
+
+
+ Parsing for the pipe delimited DispatchList string clients send when creating or editing a call.
+
+
+
+
+ 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.
+
+ The already split DispatchList entries.
+ The entry prefix to collect, i.e. "G:", "R:" or "U:".
+ Looks up an id for an entry that isn't numeric, null when there's no match.
+ The distinct ids found for that prefix, in the order they appeared.
+
diff --git a/Web/Resgrid.Web.Tts/Configuration/ServiceCollectionExtensions.cs b/Web/Resgrid.Web.Tts/Configuration/ServiceCollectionExtensions.cs
index 18fd58c84..9a9be64da 100644
--- a/Web/Resgrid.Web.Tts/Configuration/ServiceCollectionExtensions.cs
+++ b/Web/Resgrid.Web.Tts/Configuration/ServiceCollectionExtensions.cs
@@ -58,6 +58,7 @@ private static void ApplyTtsOptions(TtsOptions options)
options.PiperModelDirectory = string.IsNullOrWhiteSpace(TtsConfig.PiperModelDirectory) ? options.PiperModelDirectory : TtsConfig.PiperModelDirectory;
options.FfmpegExecutable = string.IsNullOrWhiteSpace(TtsConfig.FfmpegExecutable) ? options.FfmpegExecutable : TtsConfig.FfmpegExecutable;
options.TempDirectory = string.IsNullOrWhiteSpace(TtsConfig.TempDirectory) ? options.TempDirectory : TtsConfig.TempDirectory;
+ options.TempDirectorySweepHours = TtsConfig.TempDirectorySweepHours;
options.CachePrefix = string.IsNullOrWhiteSpace(TtsConfig.CachePrefix) ? options.CachePrefix : TtsConfig.CachePrefix;
options.NormalizedSampleRate = TtsConfig.NormalizedSampleRate;
options.NormalizedChannels = TtsConfig.NormalizedChannels;
diff --git a/Web/Resgrid.Web.Tts/Configuration/TtsOptions.cs b/Web/Resgrid.Web.Tts/Configuration/TtsOptions.cs
index 4360d8525..00ea49a99 100644
--- a/Web/Resgrid.Web.Tts/Configuration/TtsOptions.cs
+++ b/Web/Resgrid.Web.Tts/Configuration/TtsOptions.cs
@@ -38,6 +38,16 @@ public sealed class TtsOptions
[Required]
public string TempDirectory { get; set; } = Path.Combine(Path.GetTempPath(), "resgrid-tts");
+ ///
+ /// Age, in hours, at which an orphaned entry under is
+ /// deleted, and the interval the sweep runs on. Synthesis working directories are
+ /// removed in a finally block, so anything this old was left by a hard kill or a
+ /// delete that failed. Must stay well above
+ /// so an in-flight job is never collected.
+ ///
+ [Range(1, 168)]
+ public int TempDirectorySweepHours { get; set; } = 6;
+
[Required]
public string CachePrefix { get; set; } = "tts2";
diff --git a/Web/Resgrid.Web.Tts/Program.cs b/Web/Resgrid.Web.Tts/Program.cs
index f805197f4..4db77e554 100644
--- a/Web/Resgrid.Web.Tts/Program.cs
+++ b/Web/Resgrid.Web.Tts/Program.cs
@@ -116,6 +116,7 @@ await context.HttpContext.Response.WriteAsJsonAsync(
builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddHostedService();
+builder.Services.AddHostedService();
var app = builder.Build();
diff --git a/Web/Resgrid.Web.Tts/Services/TempDirectorySweepHostedService.cs b/Web/Resgrid.Web.Tts/Services/TempDirectorySweepHostedService.cs
new file mode 100644
index 000000000..6f3694155
--- /dev/null
+++ b/Web/Resgrid.Web.Tts/Services/TempDirectorySweepHostedService.cs
@@ -0,0 +1,120 @@
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Options;
+using Resgrid.Web.Tts.Configuration;
+
+namespace Resgrid.Web.Tts.Services
+{
+ ///
+ /// Deletes orphaned synthesis working directories under the TTS temp directory.
+ /// removes its own per-job directory in a finally
+ /// block, so leftovers only appear when the process is hard-killed mid-synthesis
+ /// (OOMKill, SIGKILL) or when the delete itself failed and was swallowed as a warning.
+ /// Nothing else reclaims those, and the temp volume is a fixed-size emptyDir, so they
+ /// are swept at startup and then on a schedule.
+ ///
+ public sealed class TempDirectorySweepHostedService : BackgroundService
+ {
+ private readonly TtsOptions _options;
+ private readonly ILogger _logger;
+
+ public TempDirectorySweepHostedService(
+ IOptions options,
+ ILogger logger)
+ {
+ _options = options.Value;
+ _logger = logger;
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ // Yield before the first sweep so host startup isn't blocked on directory IO.
+ await Task.Yield();
+
+ SweepOnce();
+
+ using var timer = new PeriodicTimer(TimeSpan.FromHours(_options.TempDirectorySweepHours));
+
+ try
+ {
+ while (await timer.WaitForNextTickAsync(stoppingToken))
+ {
+ SweepOnce();
+ }
+ }
+ catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
+ {
+ _logger.LogInformation("TTS temp directory sweep stopped.");
+ }
+ }
+
+ ///
+ /// Removes every entry directly under the temp root last written more than
+ /// TempDirectorySweepHours ago, and returns how many were deleted. Individual
+ /// failures are logged and skipped so one undeletable entry can't stop the sweep.
+ ///
+ public int SweepOnce()
+ {
+ var tempRoot = Path.GetFullPath(string.IsNullOrWhiteSpace(_options.TempDirectory)
+ ? Path.GetTempPath()
+ : _options.TempDirectory);
+
+ if (!Directory.Exists(tempRoot))
+ {
+ return 0;
+ }
+
+ var cutoff = DateTime.UtcNow - TimeSpan.FromHours(_options.TempDirectorySweepHours);
+ FileSystemInfo[] entries;
+
+ try
+ {
+ // Materialized rather than streamed: the loop below deletes as it goes.
+ entries = new DirectoryInfo(tempRoot).GetFileSystemInfos();
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ _logger.LogWarning(ex, "Could not enumerate the TTS temp directory {TempRoot} for sweeping.", tempRoot);
+ return 0;
+ }
+
+ var removed = 0;
+
+ foreach (var entry in entries)
+ {
+ if (entry.LastWriteTimeUtc >= cutoff)
+ {
+ continue;
+ }
+
+ try
+ {
+ if (entry is DirectoryInfo directory)
+ {
+ directory.Delete(recursive: true);
+ }
+ else
+ {
+ entry.Delete();
+ }
+
+ removed++;
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ _logger.LogWarning(ex, "Failed to sweep orphaned TTS temp entry {Entry}.", entry.FullName);
+ }
+ }
+
+ if (removed > 0)
+ {
+ _logger.LogInformation(
+ "Swept {RemovedCount} orphaned TTS temp entries older than {MaxAgeHours}h from {TempRoot}.",
+ removed,
+ _options.TempDirectorySweepHours,
+ tempRoot);
+ }
+
+ return removed;
+ }
+ }
+}
diff --git a/Web/Resgrid.Web.Tts/k8s/deployment.yaml b/Web/Resgrid.Web.Tts/k8s/deployment.yaml
index bec1f6172..4ad2515b8 100644
--- a/Web/Resgrid.Web.Tts/k8s/deployment.yaml
+++ b/Web/Resgrid.Web.Tts/k8s/deployment.yaml
@@ -25,6 +25,7 @@ data:
RESGRID__TtsConfig__PiperModelDirectory: /usr/local/share/piper-voices
RESGRID__TtsConfig__FfmpegExecutable: /usr/bin/ffmpeg
RESGRID__TtsConfig__TempDirectory: /tmp/resgrid-tts
+ RESGRID__TtsConfig__TempDirectorySweepHours: "6"
RESGRID__TtsConfig__CachePrefix: tts
RESGRID__TtsConfig__PlaybackMemoryCacheMinutes: "60"
RESGRID__TtsConfig__PlaybackCacheControlSeconds: "86400"
diff --git a/Web/Resgrid.Web/Controllers/AccountController.cs b/Web/Resgrid.Web/Controllers/AccountController.cs
index 0199932c2..08617c4a8 100644
--- a/Web/Resgrid.Web/Controllers/AccountController.cs
+++ b/Web/Resgrid.Web/Controllers/AccountController.cs
@@ -101,7 +101,7 @@ public async Task LogOn(string returnUrl = null)
//RemoveCookies();
ViewData["ReturnUrl"] = returnUrl;
- ViewData["LoginNotice"] = SystemBehaviorConfig.LoginPageNotice;
+ ViewData["LoginNotice"] = NoticeConfig.EffectiveLoginPageNotice;
return View();
}