From ebf69580682d28874f9be1a49b0309c867f96cb8 Mon Sep 17 00:00:00 2001 From: nolt Date: Tue, 28 Jul 2026 16:34:17 +0200 Subject: [PATCH 01/13] bots: accept party invitations regardless of level gap The reset-aware level gate in BotPartyHandler rejected nearly every party invitation on servers with resets, because it folded reset count into the level scale (one reset ~= 400 points against a 500 cap). A veteran player inviting a freshly generated bot was always over the limit, so the invitation was silently declined. Remove the gate so a bot accepts any inviter who is alive and in the world, matching OpenMU's own party action; the situational safeguards (shopping, revenge, mini game, pending invite, human companion) stay in place. --- docs/Bots.md | 6 ++-- src/GameLogic/Bots/BotPartyHandler.cs | 31 +++++-------------- .../Party/BotPartyHandlerTest.cs | 17 +++++----- 3 files changed, 22 insertions(+), 32 deletions(-) diff --git a/docs/Bots.md b/docs/Bots.md index 53e4d4e..cd88521 100644 --- a/docs/Bots.md +++ b/docs/Bots.md @@ -238,8 +238,10 @@ hunt the leader's maps. The elf heals, the buffs are shared, the party experience bonus applies. Parties re-form every hour. A player may invite a bot into their own party: it accepts after a human-like -pause of a few seconds, provided the level gap is sane and it is not in the -middle of an errand. A living player takes precedence over the bot's own company +pause of a few seconds, as long as it is not in the middle of an errand. There is +no level gate — just like OpenMU's own party action, a bot accepts an inviter of +any level, since it is the player who invites and the bot leaves once it gets +bored. A living player takes precedence over the bot's own company — a bot hunting with other bots leaves them for the inviter, and breaks that bot party up if it was leading it, so a player never has to guess which bot happens to be free. In a party the bot follows its leader, defers a due reset, and diff --git a/src/GameLogic/Bots/BotPartyHandler.cs b/src/GameLogic/Bots/BotPartyHandler.cs index c93e17b..8ce440b 100644 --- a/src/GameLogic/Bots/BotPartyHandler.cs +++ b/src/GameLogic/Bots/BotPartyHandler.cs @@ -11,22 +11,13 @@ using MUnique.OpenMU.GameLogic.Offline; /// ): the invitation is accepted after a short /// human-like delay, and the bot then follows the leader like any party member (see the follow logic /// in ) until it gets bored and politely leaves. Safeguards keep it -/// believable and abuse-free: no grouping across an absurd level gap, no acceptance while the bot is -/// on an errand (shopping trip) or has unfinished business (revenge), and the invitation is -/// re-validated when the delay has passed - the inviter may have joined another party or left. +/// believable and abuse-free: no acceptance while the bot is on an errand (shopping trip) or has +/// unfinished business (revenge), and the invitation is re-validated when the delay has passed - the +/// inviter may have joined another party or left. There is no level gate, matching OpenMU's own party +/// action: it is the player who invites, and the bot leaves again once it gets bored. /// internal static class BotPartyHandler { - /// - /// The maximum difference of the reset-aware effective level (see - /// ) between the bot and the inviter. Within one - /// reset worth of levels plus some slack, hunting together still makes sense for both; grouping a - /// fresh character with a 15-resets veteran would only be a power-leveling service. On servers - /// without the reset feature the plain levels always lie within this bound, matching OpenMU's own - /// party action, which has no level gate at all. - /// - private const int MaxEffectiveLevelGap = 500; - /// Lower bound of the human-like delay before the bot answers an invitation. private static readonly TimeSpan MinAcceptDelay = TimeSpan.FromSeconds(2); @@ -67,7 +58,7 @@ internal static class BotPartyHandler return false; } - if (!IsRequesterEligible(bot, requester)) + if (!IsRequesterEligible(requester)) { return false; } @@ -141,7 +132,7 @@ internal static class BotPartyHandler { // Re-validate: between the invitation and this answer, the bot may have joined a human's party // and the inviter may have died, left the game or joined another party. - if (HasHumanCompanion(bot) || !IsRequesterEligible(bot, requester)) + if (HasHumanCompanion(bot) || !IsRequesterEligible(requester)) { bot.Logger.LogInformation("Bot '{Name}' dropped the party invitation of '{Requester}' - the situation changed.", bot.Name, requester.Name); return; @@ -209,14 +200,8 @@ internal static class BotPartyHandler await party.KickMySelfAsync(bot).ConfigureAwait(false); } - private static bool IsRequesterEligible(OfflinePlayer bot, Player requester) + private static bool IsRequesterEligible(Player requester) { - if (!requester.IsAlive || requester.PlayerState.CurrentState != PlayerState.EnteredWorld) - { - return false; - } - - var levelGap = Math.Abs(BotResetHandler.GetEffectiveLevel(bot) - BotResetHandler.GetEffectiveLevel(requester)); - return levelGap <= MaxEffectiveLevelGap; + return requester.IsAlive && requester.PlayerState.CurrentState == PlayerState.EnteredWorld; } } diff --git a/tests/MUnique.OpenMU.Tests/Party/BotPartyHandlerTest.cs b/tests/MUnique.OpenMU.Tests/Party/BotPartyHandlerTest.cs index 1b79d63..58a0544 100644 --- a/tests/MUnique.OpenMU.Tests/Party/BotPartyHandlerTest.cs +++ b/tests/MUnique.OpenMU.Tests/Party/BotPartyHandlerTest.cs @@ -44,22 +44,25 @@ public class BotPartyHandlerTest } /// - /// An inviter whose effective level is too far from the bot's is declined - the group would only - /// be a power-leveling service. + /// There is no level gate (matching OpenMU's own party action): a bot accepts an inviter of any + /// level, since it is the player who invites and the bot leaves again once it gets bored. The + /// inviter here is a maxed veteran (character level 400 plus the master level cap of 200, i.e. the + /// ceiling of the Season 6 seed) inviting a low-level bot - the widest gap a stock server produces. /// [Test] - public async ValueTask RejectsTooLargeLevelGapAsync() + public async ValueTask AcceptsInviteRegardlessOfLevelGapAsync() { var gameContext = GameContextTestHelper.CreateGameContext(); var bot = await CreateBotAsync(gameContext, "Bot").ConfigureAwait(false); var requester = await CreateHumanAsync(gameContext, "Human").ConfigureAwait(false); - requester.Attributes![Stats.Level] = 700; + requester.Attributes![Stats.Level] = 400; + requester.Attributes![Stats.MasterLevel] = 200; var scheduled = await BotPartyHandler.TryScheduleAcceptAsync(bot, requester, TimeSpan.Zero).ConfigureAwait(false); - Assert.That(scheduled, Is.False); - Assert.That(bot.PendingPartyInvite, Is.Null); - Assert.That(bot.LastPartyRequester, Is.Null); + Assert.That(scheduled, Is.True); + Assert.That(bot.PendingPartyInvite, Is.Not.Null); + Assert.That(bot.LastPartyRequester, Is.SameAs(requester)); } /// From 7671fe2d942fd3338bbf37eab705bf2fd6ae8619 Mon Sep 17 00:00:00 2001 From: nolt Date: Mon, 27 Jul 2026 00:00:56 +0200 Subject: [PATCH 02/13] Serialize player context mutations against the periodic save A player's progress is persisted from two unrelated flows: action handlers triggered by incoming packets (sequential per connection) and the periodic save, which runs on an independent timer. Action handlers mutate tracked entities with plain field and collection writes - for example crafting toggles item.ItemOptions, and item stacking and NPC selling delete item rows - which bypass the persistence context's own lock. When such a mutation runs while SaveChangesAsync enumerates the change tracker, the save throws (collection-modified, or a DbUpdateConcurrency "affected 0 rows" since the context has no concurrency tokens). SaveChanges is atomic, so every following save fails too and the whole session never persists: on relog the player rolls back, losing progress and items. Add a per-player re-entrant persistence lock and acquire it around both the packet-handling funnel and SaveProgressAsync, so a player's mutations and saves can never overlap. The lock is re-entrant per asynchronous flow (an instance AsyncLocal), so an inline save inside an already-serialized handler does not deadlock; and it is per player, so a trade still acquires the trading partner's lock separately. Add regression tests covering mutual exclusion and re-entrancy of the lock. --- src/GameLogic/Player.cs | 87 ++++++++++++- src/GameServer/RemoteView/RemotePlayer.cs | 2 +- .../PersistenceLockTest.cs | 114 ++++++++++++++++++ 3 files changed, 199 insertions(+), 4 deletions(-) create mode 100644 tests/MUnique.OpenMU.Tests/PersistenceLockTest.cs diff --git a/src/GameLogic/Player.cs b/src/GameLogic/Player.cs index 5b9928b..a7f316e 100644 --- a/src/GameLogic/Player.cs +++ b/src/GameLogic/Player.cs @@ -54,6 +54,21 @@ public class Player : AsyncDisposable, IBucketMapObserver, IAttackable, IAttacke private readonly AsyncLock _moveLock = new(); private readonly AsyncLock _experienceLock = new(); + /// + /// Serializes context mutations done by this player's action handlers against the periodic and + /// disconnect progress saves, which run on an independent timer flow. See + /// . + /// + private readonly AsyncLock _persistenceLock = new(); + + /// + /// Tracks, per asynchronous flow, whether is already held, so the + /// lock can be re-entered (Nito's is not reentrant). It is an instance + /// field on purpose: reentrancy must be tracked per player, so a flow holding player A's lock + /// still acquires player B's lock (e.g. during a trade) instead of wrongly skipping it. + /// + private readonly AsyncLocal _persistenceLockHeld = new(); + private readonly Walker _walker; private readonly AppearanceDataAdapter _appearanceData; @@ -1879,12 +1894,78 @@ public class Player : AsyncDisposable, IBucketMapObserver, IAttackable, IAttacke /// Success of the save operation. public async ValueTask SaveProgressAsync(CancellationToken cancellationToken = default) { - if (!this.IsTemplatePlayer) + if (this.IsTemplatePlayer) { - return await this.PersistenceContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + return true; } - return true; + return await this.RunPersistenceExclusiveAsync( + () => this.PersistenceContext.SaveChangesAsync(cancellationToken), + cancellationToken).ConfigureAwait(false); + } + + /// + /// Runs the given operation while holding this player's persistence lock, so that context + /// mutations and progress saves for the player never run concurrently. + /// + /// + /// The periodic progress save () runs on an + /// independent timer flow. Action handlers mutate tracked entities with plain field/collection + /// writes (e.g. crafting toggling item.ItemOptions) which bypass the persistence context's + /// own lock; if such a mutation runs while enumerates the + /// change tracker, the save throws (collection-modified / DbUpdateConcurrency) and every following + /// save fails too, so the whole session is lost on relog. Serializing the packet handler funnel + /// and the save against each other closes that window. The lock is re-entrant per asynchronous + /// flow, so an inline save inside an already-serialized handler does not deadlock. + /// + /// The result type of the operation. + /// The operation to run exclusively. + /// The cancellation token. + /// The result of the operation. + public async ValueTask RunPersistenceExclusiveAsync(Func> operation, CancellationToken cancellationToken = default) + { + if (this._persistenceLockHeld.Value) + { + return await operation().ConfigureAwait(false); + } + + using var l = await this._persistenceLock.LockAsync(cancellationToken).ConfigureAwait(false); + this._persistenceLockHeld.Value = true; + try + { + return await operation().ConfigureAwait(false); + } + finally + { + this._persistenceLockHeld.Value = false; + } + } + + /// + /// Runs the given operation while holding this player's persistence lock. + /// See for the rationale. + /// + /// The operation to run exclusively. + /// The cancellation token. + /// A value task which completes when the operation completed. + public async ValueTask RunPersistenceExclusiveAsync(Func operation, CancellationToken cancellationToken = default) + { + if (this._persistenceLockHeld.Value) + { + await operation().ConfigureAwait(false); + return; + } + + using var l = await this._persistenceLock.LockAsync(cancellationToken).ConfigureAwait(false); + this._persistenceLockHeld.Value = true; + try + { + await operation().ConfigureAwait(false); + } + finally + { + this._persistenceLockHeld.Value = false; + } } /// diff --git a/src/GameServer/RemoteView/RemotePlayer.cs b/src/GameServer/RemoteView/RemotePlayer.cs index 2c0b584..d7687f7 100644 --- a/src/GameServer/RemoteView/RemotePlayer.cs +++ b/src/GameServer/RemoteView/RemotePlayer.cs @@ -151,7 +151,7 @@ public class RemotePlayer : Player, IClientVersionProvider, IHasIpAddress this.Logger.LogDebug("[C->S] {0}", buffer.ToArray().AsString()); } - await this.MainPacketHandler.HandlePacketAsync(this, buffer).ConfigureAwait(false); + await this.RunPersistenceExclusiveAsync(() => this.MainPacketHandler.HandlePacketAsync(this, buffer)).ConfigureAwait(false); } finally { diff --git a/tests/MUnique.OpenMU.Tests/PersistenceLockTest.cs b/tests/MUnique.OpenMU.Tests/PersistenceLockTest.cs new file mode 100644 index 0000000..1ca00dc --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/PersistenceLockTest.cs @@ -0,0 +1,114 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests; + +using System.Threading; + +/// +/// Tests for the per-player persistence lock (), +/// which serializes a player's context mutations against its periodic/disconnect progress saves so they +/// can never run concurrently. Without it, a mutation running during SaveChangesAsync corrupts the +/// change tracker and rolls the whole session back. +/// +[TestFixture] +public class PersistenceLockTest +{ + /// + /// Verifies that concurrent exclusive operations for the same player never overlap. + /// + [Test] + public async Task ConcurrentAccessIsSerializedAsync() + { + var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false); + var concurrent = 0; + var overlapDetected = false; + + async ValueTask BodyAsync() + { + if (Interlocked.Increment(ref concurrent) > 1) + { + overlapDetected = true; + } + + await Task.Delay(1).ConfigureAwait(false); + Interlocked.Decrement(ref concurrent); + } + + var tasks = Enumerable.Range(0, 50) + .Select(_ => player.RunPersistenceExclusiveAsync(BodyAsync).AsTask()) + .ToArray(); + await Task.WhenAll(tasks).ConfigureAwait(false); + + Assert.That(overlapDetected, Is.False, "Two exclusive operations for the same player ran at the same time."); + } + + /// + /// Verifies that re-entering the lock from within an already-held exclusive scope does not deadlock + /// (an inline save inside a packet handler is exactly this case). + /// + [Test] + public async Task ReentrantAccessDoesNotDeadlockAsync() + { + var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false); + var executed = 0; + + var run = player.RunPersistenceExclusiveAsync(async () => + { + Interlocked.Increment(ref executed); + await player.RunPersistenceExclusiveAsync(async () => + { + Interlocked.Increment(ref executed); + await Task.Yield(); + }).ConfigureAwait(false); + }).AsTask(); + + // If reentrancy deadlocked, this would hang; fail fast instead of blocking the suite. + await run.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + + Assert.That(executed, Is.EqualTo(2)); + } + + /// + /// Verifies that a re-entrant exclusive operation still runs while another flow holds the lock: + /// the outer flow keeps the lock, an independent flow must wait, and the re-entrant call inside the + /// outer flow proceeds without waiting for itself. + /// + [Test] + public async Task IndependentFlowWaitsWhileLockIsHeldAsync() + { + var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false); + var otherEntered = false; + var holderHasLock = new TaskCompletionSource(); + var mayRelease = new TaskCompletionSource(); + + // Holder runs on its own flow and keeps the lock until signalled. + var holder = Task.Run(() => player.RunPersistenceExclusiveAsync(async () => + { + holderHasLock.SetResult(); + + // A re-entrant call from the holding flow must NOT block on the lock we already hold. + await player.RunPersistenceExclusiveAsync(() => ValueTask.CompletedTask).ConfigureAwait(false); + + await mayRelease.Task.ConfigureAwait(false); + }).AsTask()); + + await holderHasLock.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + + // Competing flow started from an INDEPENDENT context (does not inherit the reentrancy flag). + var other = Task.Run(() => player.RunPersistenceExclusiveAsync(() => + { + otherEntered = true; + return ValueTask.CompletedTask; + }).AsTask()); + + await Task.Delay(50).ConfigureAwait(false); + Assert.That(otherEntered, Is.False, "An independent flow entered while the lock was held."); + + mayRelease.SetResult(); + await holder.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + await other.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + Assert.That(otherEntered, Is.True, "The competing flow never ran after the lock was released."); + } +} From d3dd57f620fe437268a4b896296f30bf867a1046 Mon Sep 17 00:00:00 2001 From: nolt Date: Mon, 27 Jul 2026 00:39:43 +0200 Subject: [PATCH 03/13] Absorb the remaining off-funnel mutation races against the periodic save The per-player persistence lock serializes the packet handler funnel and the save, but a few structural mutations happen off that funnel: the offline/bot MuHelper loots and maintains its inventory on a 500ms timer, and combat destroys depleted ammunition or a dead pet on the attacker's or a monster's thread. Those can still run while the periodic save enumerates the change tracker and corrupt it. Two additions: - Run the whole offline MuHelper tick under the player's persistence lock. Bots are in the saved player list and loot continuously, so this was the most likely remaining reproducer. The tick has no internal delays, so the lock is held only briefly, and it is the bot's own lock (no cross-player deadlock). - Retry the save a bounded number of times on the transient exceptions a concurrent change-tracker mutation produces. The corruption surfaces as several types depending on where change detection was (a modified collection, a transiently-null key, an out-of-range index), so the retry covers that family rather than a single type. A genuinely persistent error rethrows once the attempts are exhausted. This absorbs the rare, bursty combat sources that no lock is held for. --- src/GameLogic/Offline/OfflinePlayerMuHelper.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/GameLogic/Offline/OfflinePlayerMuHelper.cs b/src/GameLogic/Offline/OfflinePlayerMuHelper.cs index 73bc9a1..c36bb58 100644 --- a/src/GameLogic/Offline/OfflinePlayerMuHelper.cs +++ b/src/GameLogic/Offline/OfflinePlayerMuHelper.cs @@ -141,7 +141,11 @@ public sealed class OfflinePlayerMuHelper : AsyncDisposable { try { - await this.TickAsync(cancellationToken).ConfigureAwait(false); + // Run the whole tick under the player's persistence lock so its structural mutations + // (loot pickup, combat ammo/pet destruction, queued equip/jewel actions) never overlap + // this bot's periodic progress save, which runs on a separate timer. The tick has no + // internal delays, so the lock is held only for its brief duration. + await this._player.RunPersistenceExclusiveAsync(() => this.TickAsync(cancellationToken)).ConfigureAwait(false); this._player.OnAiTickSucceeded(); } catch (OperationCanceledException) From 52950362ff0b35157d6de977d4d9e7e7fb404739 Mon Sep 17 00:00:00 2001 From: nolt Date: Mon, 27 Jul 2026 11:33:47 +0200 Subject: [PATCH 04/13] Document the cross-player persistence lock-ordering invariant The packet handler funnel now holds each player's persistence lock for the whole handler. Acquiring a second player's lock from inside a handler is therefore a lock-ordering hazard; note the invariant on the guard method so a future cross-player save cannot silently open an AB-BA cycle. Documentation only, no behavioural change. --- src/GameLogic/Player.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/GameLogic/Player.cs b/src/GameLogic/Player.cs index a7f316e..6e55ec8 100644 --- a/src/GameLogic/Player.cs +++ b/src/GameLogic/Player.cs @@ -1917,6 +1917,14 @@ public class Player : AsyncDisposable, IBucketMapObserver, IAttackable, IAttacke /// save fails too, so the whole session is lost on relog. Serializing the packet handler funnel /// and the save against each other closes that window. The lock is re-entrant per asynchronous /// flow, so an inline save inside an already-serialized handler does not deadlock. + /// + /// Invariant: never acquire another player's persistence lock (via their + /// or ) from inside a + /// packet handler, which already holds this player's lock, unless a global lock order is enforced. + /// Today only the trade accept does a cross-player save, and it cannot form a cycle because a trade + /// has a single accepting side (so the A-then-B acquisition order has no concurrent B-then-A + /// counterpart). A second cross-player caller with the opposite order could deadlock. + /// /// /// The result type of the operation. /// The operation to run exclusively. From 790a101f2331bc6f6704cd09c1d691cc45c0dfbb Mon Sep 17 00:00:00 2001 From: Acentech Dev Date: Thu, 13 Aug 2026 08:50:53 +0300 Subject: [PATCH 05/13] Cover the configuration change publishing filter with its test The Castle Siege persistence import (#860) brought EntityFrameworkContextBase.PublishesConfigurationChanges into the tree, but not the test which pins its behaviour, and not the guard which keeps the two initialization test fixtures from configuring the connection twice. Add both, unchanged from upstream. --- .../ConfigurationChangePublishingTests.cs | 34 +++++++++++++++++++ .../JsonQueryBuilderTests.cs | 5 ++- 2 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 tests/MUnique.OpenMU.Persistence.Initialization.Tests/ConfigurationChangePublishingTests.cs diff --git a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ConfigurationChangePublishingTests.cs b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ConfigurationChangePublishingTests.cs new file mode 100644 index 0000000..cfbb9c3 --- /dev/null +++ b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ConfigurationChangePublishingTests.cs @@ -0,0 +1,34 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Persistence.Initialization.Tests; + +using MUnique.OpenMU.Persistence.EntityFramework; +using MUnique.OpenMU.Persistence.EntityFramework.Model; + +/// +/// Tests filtering of Entity Framework configuration change notifications. +/// +[TestFixture] +internal class ConfigurationChangePublishingTests +{ + /// + /// Verifies that only configuration entities are published to the configuration change listener. + /// + /// The entity type. + /// Whether changes of this entity type should be published. + [TestCase(typeof(CastleSiegeConfiguration), true)] + [TestCase(typeof(CastleSiegeNpcState), false)] + [TestCase(typeof(Account), false)] + [TestCase(typeof(Guild), false)] + [TestCase(typeof(Friend), false)] + public void OnlyConfigurationEntitiesArePublished(Type entityType, bool shouldPublish) + { + using var context = new EntityDataContext(); + var modelType = context.Model.FindEntityType(entityType); + + Assert.That(modelType, Is.Not.Null); + Assert.That(EntityFrameworkContextBase.PublishesConfigurationChanges(modelType!), Is.EqualTo(shouldPublish)); + } +} diff --git a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/JsonQueryBuilderTests.cs b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/JsonQueryBuilderTests.cs index c320853..5dfdd0f 100644 --- a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/JsonQueryBuilderTests.cs +++ b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/JsonQueryBuilderTests.cs @@ -24,7 +24,10 @@ internal class JsonQueryBuilderTests [OneTimeSetUp] public void Setup() { - ConnectionConfigurator.Initialize(new ConfigFileDatabaseConnectionStringProvider()); + if (!ConnectionConfigurator.IsInitialized) + { + ConnectionConfigurator.Initialize(new ConfigFileDatabaseConnectionStringProvider()); + } } /// From 31a5e64f40e6fb53df935e4bbba5afd387b46852 Mon Sep 17 00:00:00 2001 From: Rhefew Date: Mon, 13 Jul 2026 16:29:21 +0200 Subject: [PATCH 06/13] feat(admin): implement live log viewer and searcher in log files page --- src/Web/AdminPanel/Pages/LogFiles.razor | 327 +++++++++++++++++++++--- 1 file changed, 295 insertions(+), 32 deletions(-) diff --git a/src/Web/AdminPanel/Pages/LogFiles.razor b/src/Web/AdminPanel/Pages/LogFiles.razor index 5904925..2c7541e 100644 --- a/src/Web/AdminPanel/Pages/LogFiles.razor +++ b/src/Web/AdminPanel/Pages/LogFiles.razor @@ -1,57 +1,320 @@ -@page "/logfiles" +@page "/logfiles" @using System.IO @using MUnique.OpenMU.Web.AdminPanel.Properties +@implements IDisposable +@inject IJSRuntime JSRuntime OpenMU: @Resources.LogFiles -
- - - - - - - - - - @foreach (var entry in this._files.OrderByDescending(f => f.LastWriteTime)) - { - - - - - - } - -
@Resources.FileName@Resources.LastUpdate@Resources.Size
- @entry.Name - @entry.LastWriteTime@FormatFileSize(entry.Length)
+ +
+
+
+
+ + + + + + + + + + + @foreach (var entry in this._files.OrderByDescending(f => f.LastWriteTime)) + { + var isSelected = this._selectedFile?.FullName == entry.FullName; + + + + + + + } + +
@Resources.FileName@Resources.LastUpdate@Resources.SizeActions
+ + @entry.LastWriteTime@FormatFileSize(entry.Length) + + + +
+
+
+
+@if (this._selectedFile != null) +{ +
+
+
+ + Log Viewer: + @this._selectedFile.Name + @FormatFileSize(this._selectedFile.Length) +
+
+
+ + +
+ + +
+
+
+
+
+
+ + + @if (!string.IsNullOrEmpty(this._searchText)) + { + + } +
+
+
+ +
+ @if (this._logLines.Count == 0) + { +
No log entries found.
+ } + else + { + var filteredLines = GetFilteredLines(); + @if (filteredLines.Count == 0) + { +
No log entries match your filter.
+ } + else + { + @foreach (var line in filteredLines) + { +
@line
+ } + } + } +
+ +
+
+ Showing @GetFilteredLines().Count of @this._logLines.Count lines (Last 300 lines loaded). +
+ +
+
+
+} + @code { private readonly List _files = new (); + private FileInfo? _selectedFile; + private List _logLines = new (); + private string _searchText = string.Empty; + private bool _liveUpdate = false; + private System.Threading.Timer? _timer; + private bool _shouldScrollToBottom = false; - /// - /// Initializes a new instance of class . - /// - public LogFiles() + /// + protected override void OnInitialized() { - var files = Directory.GetFiles(Path.Combine(Directory.GetCurrentDirectory(), "logs")); - foreach (var filePath in files) + this.RefreshFileList(); + } + + private void RefreshFileList() + { + this._files.Clear(); + var logsPath = Path.Combine(Directory.GetCurrentDirectory(), "logs"); + if (Directory.Exists(logsPath)) { - this._files.Add(new FileInfo(filePath)); + var files = Directory.GetFiles(logsPath); + foreach (var filePath in files) + { + this._files.Add(new FileInfo(filePath)); + } } } + private void SelectFile(FileInfo file) + { + this._selectedFile = file; + this._searchText = string.Empty; + this.RefreshLogLines(); + this._shouldScrollToBottom = true; + this.SetupTimer(); + } + + private void CloseViewer() + { + this._selectedFile = null; + this._searchText = string.Empty; + this._logLines.Clear(); + this._liveUpdate = false; + this.SetupTimer(); + } + + private void ClearSearch() + { + this._searchText = string.Empty; + } + + private void ToggleLiveUpdate(ChangeEventArgs e) + { + this._liveUpdate = (bool)(e.Value ?? false); + this.SetupTimer(); + } + + private void SetupTimer() + { + if (this._liveUpdate && this._selectedFile != null) + { + this._timer ??= new System.Threading.Timer(_ => + { + InvokeAsync(() => + { + this.RefreshLogLines(); + this.StateHasChanged(); + }); + }, null, 0, 2000); + } + else + { + this._timer?.Dispose(); + this._timer = null; + } + } + + private void RefreshLogLines() + { + if (this._selectedFile == null) + { + return; + } + + this._selectedFile = new FileInfo(this._selectedFile.FullName); + this._logLines = this.ReadLastLines(this._selectedFile.FullName, 300); + } + + private List ReadLastLines(string path, int maxLines) + { + var lines = new List(); + try + { + using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + long offset = Math.Max(0, fs.Length - 102400); // Read last 100 KB + fs.Seek(offset, SeekOrigin.Begin); + using var reader = new StreamReader(fs, System.Text.Encoding.UTF8); + + if (offset > 0) + { + // Discard partial line + reader.ReadLine(); + } + + string? line; + while ((line = reader.ReadLine()) != null) + { + lines.Add(line); + } + + if (lines.Count > maxLines) + { + lines = lines.Skip(lines.Count - maxLines).ToList(); + } + } + catch (Exception ex) + { + lines.Add($"Error reading log file: {ex.Message}"); + } + + return lines; + } + + private List GetFilteredLines() + { + if (string.IsNullOrWhiteSpace(this._searchText)) + { + return this._logLines; + } + + return this._logLines + .Where(line => line.Contains(this._searchText, StringComparison.OrdinalIgnoreCase)) + .ToList(); + } + + private string GetLineColorStyle(string line) + { + if (line.Contains("[Error]", StringComparison.OrdinalIgnoreCase) || line.Contains("[Critical]", StringComparison.OrdinalIgnoreCase)) + { + return "color: #ff6b6b; font-weight: bold;"; + } + + if (line.Contains("[Warning]", StringComparison.OrdinalIgnoreCase)) + { + return "color: #feca57;"; + } + + if (line.Contains("[Debug]", StringComparison.OrdinalIgnoreCase)) + { + return "color: #8a8d93; font-style: italic;"; + } + + if (line.Contains("[Information]", StringComparison.OrdinalIgnoreCase)) + { + return "color: #1dd1a1;"; + } + + return "color: #d1d2d6;"; + } + + private async Task ScrollToBottom() + { + try + { + await JSRuntime.InvokeVoidAsync("eval", "var el = document.getElementById('log-terminal'); if (el) { el.scrollTop = el.scrollHeight; }"); + } + catch + { + // Ignore error + } + } + + /// + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (this._shouldScrollToBottom) + { + this._shouldScrollToBottom = false; + await this.ScrollToBottom(); + } + } + + /// + public void Dispose() + { + this._timer?.Dispose(); + } + private string FormatFileSize(long size) { return size switch { - (< 1024 << 10) => $"{Math.Round(size / 1024D, 2)} KiB", + (< 1024 << 10) => $"{Math.Round(size / 1024D, 2)} KiB", (< 1024 << 20) => $"{Math.Round(size * 1D / (1024 << 10), 2)} MiB", (< 1024L << 30) => $"{Math.Round(size * 1D / (1024L << 20), 2)} GiB", _ => $"{size} bytes" - }; + }; } } From f7f6526c1622104dc3146710c053c682b75d5357 Mon Sep 17 00:00:00 2001 From: Rhefew Date: Mon, 13 Jul 2026 16:35:17 +0200 Subject: [PATCH 07/13] feat(admin): layout log files page side-by-side when viewing --- src/Web/AdminPanel/Pages/LogFiles.razor | 169 +++++++++++++----------- 1 file changed, 95 insertions(+), 74 deletions(-) diff --git a/src/Web/AdminPanel/Pages/LogFiles.razor b/src/Web/AdminPanel/Pages/LogFiles.razor index 2c7541e..fde9eed 100644 --- a/src/Web/AdminPanel/Pages/LogFiles.razor +++ b/src/Web/AdminPanel/Pages/LogFiles.razor @@ -9,16 +9,28 @@
-
+ +
-
+
+
+ Log Files + +
+
+
- - - + @if (this._selectedFile == null) + { + + + } + @@ -27,13 +39,20 @@ var isSelected = this._selectedFile?.FullName == entry.FullName; - - - + + } + } - + @@ -53,7 +53,7 @@ } @@ -73,19 +73,19 @@
- Log Viewer: + @Resources.LogViewer: @this._selectedFile.Name
- +
@@ -94,7 +94,7 @@
- + @if (!string.IsNullOrEmpty(this._searchText)) { @@ -106,31 +106,27 @@
@if (this._logLines.Count == 0) { -
No log entries found.
+
@Resources.NoLogEntriesFound
+ } + else if (this._filteredLines.Count == 0) + { +
@Resources.NoLogEntriesMatchFilter
} else { - var filteredLines = this.GetFilteredLines(); - @if (filteredLines.Count == 0) + @foreach (var line in this._filteredLines) { -
No log entries match your filter.
- } - else - { - @foreach (var line in filteredLines) - { -
@line
- } +
@line
} }
- Showing @this.GetFilteredLines().Count of @this._logLines.Count lines (Last 300 lines loaded). + @string.Format(Resources.ShowingXOfYLines, this._filteredLines.Count, this._logLines.Count, MaxLogLinesToRead)
@@ -140,18 +136,39 @@
@code { + private const int MaxLogLinesToRead = 300; + private const long LogReadBufferSizeBytes = 102400; // 100 KB + private const int LiveUpdateIntervalMs = 2000; + private readonly List _files = new(); private FileInfo? _selectedFile; + private long _lastFileLength; + private DateTime _lastFileWriteTime; private List _logLines = new(); + private List _filteredLines = new(); private string _searchText = string.Empty; private bool _liveUpdate; private System.Threading.Timer? _timer; private bool _shouldScrollToBottom; + private bool _disposed; + private IJSObjectReference? _jsModule; /// - public void Dispose() + public async ValueTask DisposeAsync() { + this._disposed = true; this._timer?.Dispose(); + if (this._jsModule != null) + { + try + { + await this._jsModule.DisposeAsync(); + } + catch + { + // Ignore JS module disposal errors + } + } } /// @@ -163,10 +180,22 @@ /// protected override async Task OnAfterRenderAsync(bool firstRender) { + if (firstRender) + { + try + { + this._jsModule = await this.JSRuntime.InvokeAsync("import", "./_content/MUnique.OpenMU.Web.AdminPanel/Pages/LogFiles.razor.js"); + } + catch + { + // Fallback gracefully if JS module import fails + } + } + if (this._shouldScrollToBottom) { this._shouldScrollToBottom = false; - await this.ScrollToBottomAsync().ConfigureAwait(false); + await this.ScrollToBottomAsync(); } } @@ -187,7 +216,7 @@ try { using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - long offset = Math.Max(0, fs.Length - 102400); // Read last 100 KB + long offset = Math.Max(0, fs.Length - LogReadBufferSizeBytes); fs.Seek(offset, SeekOrigin.Begin); using var reader = new StreamReader(fs, System.Text.Encoding.UTF8); @@ -259,6 +288,7 @@ { this._selectedFile = file; this._searchText = string.Empty; + this._lastFileLength = -1; this.RefreshLogLines(); this._shouldScrollToBottom = true; this.SetupTimer(); @@ -269,6 +299,7 @@ this._selectedFile = null; this._searchText = string.Empty; this._logLines.Clear(); + this._filteredLines.Clear(); this._liveUpdate = false; this.SetupTimer(); } @@ -276,6 +307,13 @@ private void ClearSearch() { this._searchText = string.Empty; + this.UpdateFilteredLines(); + } + + private void OnSearchInput(ChangeEventArgs e) + { + this._searchText = e.Value?.ToString() ?? string.Empty; + this.UpdateFilteredLines(); } private void ToggleLiveUpdate(ChangeEventArgs e) @@ -290,12 +328,26 @@ { this._timer ??= new System.Threading.Timer(_ => { + if (this._disposed) + { + return; + } + this.InvokeAsync(() => { - this.RefreshLogLines(); - this.StateHasChanged(); + if (this._disposed || this._selectedFile == null) + { + return; + } + + var updatedInfo = new FileInfo(this._selectedFile.FullName); + if (updatedInfo.Length != this._lastFileLength || updatedInfo.LastWriteTimeUtc != this._lastFileWriteTime) + { + this.RefreshLogLines(); + this.StateHasChanged(); + } }); - }, null, 0, 2000); + }, null, 0, LiveUpdateIntervalMs); } else { @@ -311,32 +363,40 @@ return; } - this._selectedFile = new FileInfo(this._selectedFile.FullName); - this._logLines = ReadLastLines(this._selectedFile.FullName, 300); - this._shouldScrollToBottom = true; + var fileInfo = new FileInfo(this._selectedFile.FullName); + this._selectedFile = fileInfo; + this._lastFileLength = fileInfo.Length; + this._lastFileWriteTime = fileInfo.LastWriteTimeUtc; + this._logLines = ReadLastLines(fileInfo.FullName, MaxLogLinesToRead); + this.UpdateFilteredLines(); } - private List GetFilteredLines() + private void UpdateFilteredLines() { if (string.IsNullOrWhiteSpace(this._searchText)) { - return this._logLines; + this._filteredLines = this._logLines; + } + else + { + this._filteredLines = this._logLines + .Where(line => line.Contains(this._searchText, StringComparison.OrdinalIgnoreCase)) + .ToList(); } - - return this._logLines - .Where(line => line.Contains(this._searchText, StringComparison.OrdinalIgnoreCase)) - .ToList(); } private async Task ScrollToBottomAsync() { - try + if (this._jsModule != null) { - await this.JSRuntime.InvokeVoidAsync("eval", "var el = document.getElementById('log-terminal'); if (el) { el.scrollTop = el.scrollHeight; }").ConfigureAwait(false); - } - catch - { - // Ignore error + try + { + await this._jsModule.InvokeVoidAsync("scrollToBottom", "log-terminal"); + } + catch + { + // Ignore JS call errors + } } } -} +} \ No newline at end of file diff --git a/src/Web/AdminPanel/Pages/LogFiles.razor.js b/src/Web/AdminPanel/Pages/LogFiles.razor.js new file mode 100644 index 0000000..4b2efcd --- /dev/null +++ b/src/Web/AdminPanel/Pages/LogFiles.razor.js @@ -0,0 +1,14 @@ +export function scrollToBottom(elementId) { + const el = document.getElementById(elementId); + if (el) { + el.scrollTop = el.scrollHeight; + } +} + +export function isScrolledToBottom(elementId) { + const el = document.getElementById(elementId); + if (el) { + return Math.abs(el.scrollHeight - el.clientHeight - el.scrollTop) < 50; + } + return true; +} diff --git a/src/Web/AdminPanel/Properties/Resources.Designer.cs b/src/Web/AdminPanel/Properties/Resources.Designer.cs index 0b344f8..0310f80 100644 --- a/src/Web/AdminPanel/Properties/Resources.Designer.cs +++ b/src/Web/AdminPanel/Properties/Resources.Designer.cs @@ -1,4 +1,4 @@ -//------------------------------------------------------------------------------ +//------------------------------------------------------------------------------ // // This code was generated by a tool. // Runtime Version:4.0.30319.42000 @@ -1646,5 +1646,95 @@ namespace MUnique.OpenMU.Web.AdminPanel.Properties { return ResourceManager.GetString("YesCreateTestAccounts", resourceCulture); } } + + /// + /// Looks up a localized string similar to Log Viewer. + /// + public static string LogViewer { + get { + return ResourceManager.GetString("LogViewer", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Live. + /// + public static string Live { + get { + return ResourceManager.GetString("Live", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Close. + /// + public static string Close { + get { + return ResourceManager.GetString("Close", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Filter log entries.... + /// + public static string FilterLogEntries { + get { + return ResourceManager.GetString("FilterLogEntries", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No log entries found.. + /// + public static string NoLogEntriesFound { + get { + return ResourceManager.GetString("NoLogEntriesFound", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No log entries match your filter.. + /// + public static string NoLogEntriesMatchFilter { + get { + return ResourceManager.GetString("NoLogEntriesMatchFilter", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Showing {0} of {1} lines (Last {2} lines loaded).. + /// + public static string ShowingXOfYLines { + get { + return ResourceManager.GetString("ShowingXOfYLines", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Scroll to Bottom. + /// + public static string ScrollToBottom { + get { + return ResourceManager.GetString("ScrollToBottom", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Reload File List. + /// + public static string ReloadFileList { + get { + return ResourceManager.GetString("ReloadFileList", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Download File. + /// + public static string DownloadFile { + get { + return ResourceManager.GetString("DownloadFile", resourceCulture); + } + } } } diff --git a/src/Web/AdminPanel/Properties/Resources.resx b/src/Web/AdminPanel/Properties/Resources.resx index 000bb99..50a2350 100644 --- a/src/Web/AdminPanel/Properties/Resources.resx +++ b/src/Web/AdminPanel/Properties/Resources.resx @@ -612,4 +612,40 @@ Target + + Actions + + + Log Viewer + + + Live + + + Refresh + + + Close + + + Filter log entries... + + + No log entries found. + + + No log entries match your filter. + + + Showing {0} of {1} lines (Last {2} lines loaded). + + + Scroll to Bottom + + + Reload File List + + + Download File + \ No newline at end of file From 79922204c556b6ce31652dab3e42b1e765dc08df Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 19:53:13 +0000 Subject: [PATCH 11/13] Fix build and behavior issues in the admin panel log viewer - Resources.resx: close the unterminated DownloadFile data element, which made the file invalid XML, and remove the duplicated Actions and Refresh entries which already exist. - Resources.Designer.cs: restore the UTF-8 BOM and put the new properties into the alphabetical order the strongly typed resource builder produces, so the file matches its generated form again. - LogFiles.razor: import the collocated script from ./Pages/LogFiles.razor.js. The _content/{PackageId} prefix only applies to razor class libraries, so the import failed for this web application and the module was never loaded. - LogFiles.razor: follow the new entries in live mode again by using the isScrolledToBottom helper, so the terminal scrolls along unless the user scrolled up to read the history. - LogFiles.razor: only catch the expected javascript interop exceptions and log a failing module import instead of swallowing it silently. - LogFiles.razor: restore the BOM and the trailing newline. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Vjs6n29WzQx8pGg3KJPGXk (cherry picked from commit 7be969ea7b2a9b7441a8c5a65cc8a0928009d595) --- src/Web/AdminPanel/Pages/LogFiles.razor | 72 +++++-- .../Properties/Resources.Designer.cs | 182 +++++++++--------- src/Web/AdminPanel/Properties/Resources.resx | 8 +- 3 files changed, 144 insertions(+), 118 deletions(-) diff --git a/src/Web/AdminPanel/Pages/LogFiles.razor b/src/Web/AdminPanel/Pages/LogFiles.razor index c79ef4b..49ecc4a 100644 --- a/src/Web/AdminPanel/Pages/LogFiles.razor +++ b/src/Web/AdminPanel/Pages/LogFiles.razor @@ -1,9 +1,11 @@ @page "/logfiles" @using System.IO +@using Microsoft.Extensions.Logging @using MUnique.OpenMU.Web.AdminPanel.Properties @implements IAsyncDisposable @inject IJSRuntime JSRuntime +@inject ILogger Logger OpenMU: @Resources.LogFiles @@ -103,7 +105,7 @@ -
+
@if (this._logLines.Count == 0) {
@Resources.NoLogEntriesFound
@@ -139,6 +141,7 @@ private const int MaxLogLinesToRead = 300; private const long LogReadBufferSizeBytes = 102400; // 100 KB private const int LiveUpdateIntervalMs = 2000; + private const string TerminalElementId = "log-terminal"; private readonly List _files = new(); private FileInfo? _selectedFile; @@ -164,9 +167,9 @@ { await this._jsModule.DisposeAsync(); } - catch + catch (JSDisconnectedException) { - // Ignore JS module disposal errors + // The circuit is already gone, so the module is disposed anyway. } } } @@ -184,11 +187,16 @@ { try { - this._jsModule = await this.JSRuntime.InvokeAsync("import", "./_content/MUnique.OpenMU.Web.AdminPanel/Pages/LogFiles.razor.js"); + this._jsModule = await this.JSRuntime.InvokeAsync("import", "./Pages/LogFiles.razor.js"); } - catch + catch (JSException ex) { - // Fallback gracefully if JS module import fails + // Without the module, the viewer still works - only the automatic scrolling is unavailable. + this.Logger.LogWarning(ex, "Could not load the log viewer javascript module."); + } + catch (JSDisconnectedException) + { + // The circuit is gone; nothing to do. } } @@ -333,7 +341,7 @@ return; } - this.InvokeAsync(() => + this.InvokeAsync(async () => { if (this._disposed || this._selectedFile == null) { @@ -341,11 +349,16 @@ } var updatedInfo = new FileInfo(this._selectedFile.FullName); - if (updatedInfo.Length != this._lastFileLength || updatedInfo.LastWriteTimeUtc != this._lastFileWriteTime) + if (updatedInfo.Length == this._lastFileLength && updatedInfo.LastWriteTimeUtc == this._lastFileWriteTime) { - this.RefreshLogLines(); - this.StateHasChanged(); + return; } + + // Only follow the new entries when the user didn't scroll up to read the history. + var isFollowing = await this.IsScrolledToBottomAsync(); + this.RefreshLogLines(); + this._shouldScrollToBottom = isFollowing; + this.StateHasChanged(); }); }, null, 0, LiveUpdateIntervalMs); } @@ -387,16 +400,35 @@ private async Task ScrollToBottomAsync() { - if (this._jsModule != null) + if (this._jsModule is null) { - try - { - await this._jsModule.InvokeVoidAsync("scrollToBottom", "log-terminal"); - } - catch - { - // Ignore JS call errors - } + return; + } + + try + { + await this._jsModule.InvokeVoidAsync("scrollToBottom", TerminalElementId); + } + catch (JSDisconnectedException) + { + // The circuit is gone; nothing to do. } } -} \ No newline at end of file + + private async ValueTask IsScrolledToBottomAsync() + { + if (this._jsModule is null) + { + return true; + } + + try + { + return await this._jsModule.InvokeAsync("isScrolledToBottom", TerminalElementId); + } + catch (JSDisconnectedException) + { + return false; + } + } +} diff --git a/src/Web/AdminPanel/Properties/Resources.Designer.cs b/src/Web/AdminPanel/Properties/Resources.Designer.cs index 0310f80..1b469d4 100644 --- a/src/Web/AdminPanel/Properties/Resources.Designer.cs +++ b/src/Web/AdminPanel/Properties/Resources.Designer.cs @@ -1,4 +1,4 @@ -//------------------------------------------------------------------------------ +//------------------------------------------------------------------------------ // // This code was generated by a tool. // Runtime Version:4.0.30319.42000 @@ -249,6 +249,15 @@ namespace MUnique.OpenMU.Web.AdminPanel.Properties { } } + /// + /// Looks up a localized string similar to Close. + /// + public static string Close { + get { + return ResourceManager.GetString("Close", resourceCulture); + } + } + /// /// Looks up a localized string similar to Command. /// @@ -468,6 +477,15 @@ namespace MUnique.OpenMU.Web.AdminPanel.Properties { } } + /// + /// Looks up a localized string similar to Download File. + /// + public static string DownloadFile { + get { + return ResourceManager.GetString("DownloadFile", resourceCulture); + } + } + /// /// Looks up a localized string similar to Drop item groups. /// @@ -558,6 +576,15 @@ namespace MUnique.OpenMU.Web.AdminPanel.Properties { } } + /// + /// Looks up a localized string similar to Filter log entries.... + /// + public static string FilterLogEntries { + get { + return ResourceManager.GetString("FilterLogEntries", resourceCulture); + } + } + /// /// Looks up a localized string similar to Finished! Have fun :). /// @@ -774,6 +801,15 @@ namespace MUnique.OpenMU.Web.AdminPanel.Properties { } } + /// + /// Looks up a localized string similar to Live. + /// + public static string Live { + get { + return ResourceManager.GetString("Live", resourceCulture); + } + } + /// /// Looks up a localized string similar to Live Map. /// @@ -819,6 +855,15 @@ namespace MUnique.OpenMU.Web.AdminPanel.Properties { } } + /// + /// Looks up a localized string similar to Log Viewer. + /// + public static string LogViewer { + get { + return ResourceManager.GetString("LogViewer", resourceCulture); + } + } + /// /// Looks up a localized string similar to Major. /// @@ -972,6 +1017,24 @@ namespace MUnique.OpenMU.Web.AdminPanel.Properties { } } + /// + /// Looks up a localized string similar to No log entries found.. + /// + public static string NoLogEntriesFound { + get { + return ResourceManager.GetString("NoLogEntriesFound", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No log entries match your filter.. + /// + public static string NoLogEntriesMatchFilter { + get { + return ResourceManager.GetString("NoLogEntriesMatchFilter", resourceCulture); + } + } + /// /// Looks up a localized string similar to This command has no parameters.. /// @@ -1197,6 +1260,15 @@ namespace MUnique.OpenMU.Web.AdminPanel.Properties { } } + /// + /// Looks up a localized string similar to Reload File List. + /// + public static string ReloadFileList { + get { + return ResourceManager.GetString("ReloadFileList", resourceCulture); + } + } + /// /// Looks up a localized string similar to Remove. /// @@ -1260,6 +1332,15 @@ namespace MUnique.OpenMU.Web.AdminPanel.Properties { } } + /// + /// Looks up a localized string similar to Scroll to Bottom. + /// + public static string ScrollToBottom { + get { + return ResourceManager.GetString("ScrollToBottom", resourceCulture); + } + } + /// /// Looks up a localized string similar to Search. /// @@ -1377,6 +1458,15 @@ namespace MUnique.OpenMU.Web.AdminPanel.Properties { } } + /// + /// Looks up a localized string similar to Showing {0} of {1} lines (Last {2} lines loaded).. + /// + public static string ShowingXOfYLines { + get { + return ResourceManager.GetString("ShowingXOfYLines", resourceCulture); + } + } + /// /// Looks up a localized string similar to Size. /// @@ -1646,95 +1736,5 @@ namespace MUnique.OpenMU.Web.AdminPanel.Properties { return ResourceManager.GetString("YesCreateTestAccounts", resourceCulture); } } - - /// - /// Looks up a localized string similar to Log Viewer. - /// - public static string LogViewer { - get { - return ResourceManager.GetString("LogViewer", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Live. - /// - public static string Live { - get { - return ResourceManager.GetString("Live", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Close. - /// - public static string Close { - get { - return ResourceManager.GetString("Close", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Filter log entries.... - /// - public static string FilterLogEntries { - get { - return ResourceManager.GetString("FilterLogEntries", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to No log entries found.. - /// - public static string NoLogEntriesFound { - get { - return ResourceManager.GetString("NoLogEntriesFound", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to No log entries match your filter.. - /// - public static string NoLogEntriesMatchFilter { - get { - return ResourceManager.GetString("NoLogEntriesMatchFilter", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Showing {0} of {1} lines (Last {2} lines loaded).. - /// - public static string ShowingXOfYLines { - get { - return ResourceManager.GetString("ShowingXOfYLines", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Scroll to Bottom. - /// - public static string ScrollToBottom { - get { - return ResourceManager.GetString("ScrollToBottom", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Reload File List. - /// - public static string ReloadFileList { - get { - return ResourceManager.GetString("ReloadFileList", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Download File. - /// - public static string DownloadFile { - get { - return ResourceManager.GetString("DownloadFile", resourceCulture); - } - } } } diff --git a/src/Web/AdminPanel/Properties/Resources.resx b/src/Web/AdminPanel/Properties/Resources.resx index 50a2350..06f87cc 100644 --- a/src/Web/AdminPanel/Properties/Resources.resx +++ b/src/Web/AdminPanel/Properties/Resources.resx @@ -1,4 +1,4 @@ - +
diff --git a/src/Dapr/GameServer.Host/_Imports.razor b/src/Dapr/GameServer.Host/_Imports.razor index 4a18a36..61d3ed5 100644 --- a/src/Dapr/GameServer.Host/_Imports.razor +++ b/src/Dapr/GameServer.Host/_Imports.razor @@ -9,9 +9,6 @@ @using Microsoft.AspNetCore.Components.Web.Virtualization @using Microsoft.JSInterop -@using Blazored.Toast -@using Blazored.Toast.Services - @using BlazorInputFile @using MUnique.OpenMU.Web.Shared diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 91b3051..5c2708a 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -6,7 +6,6 @@ - diff --git a/src/Persistence/EntityFramework/EntityFrameworkContextBase.cs b/src/Persistence/EntityFramework/EntityFrameworkContextBase.cs index ff16a74..459443d 100644 --- a/src/Persistence/EntityFramework/EntityFrameworkContextBase.cs +++ b/src/Persistence/EntityFramework/EntityFrameworkContextBase.cs @@ -92,69 +92,6 @@ internal class EntityFrameworkContextBase : IContext } } - /// - /// Determines whether the exception is a transient conflict caused by a concurrent entity mutation - /// racing this save, and is therefore worth retrying. - /// - /// The exception thrown by the save. - /// true if the save should be retried. - private static bool IsTransientConcurrencyConflict(Exception exception) - { - // A concurrent entity mutation racing this save corrupts the change tracker mid-enumeration. - // Depending on exactly where change detection was, it surfaces as one of several types - a - // modified collection (InvalidOperationException), a transiently-null internal key - // (ArgumentNullException/NullReferenceException), or an out-of-range index. All are transient: - // the racing mutation is a single quick operation, so a bounded retry lands on a stable moment. - // A genuinely persistent error of the same type is not masked - it rethrows once the retries - // are exhausted. The deterministic serialization (per-player persistence lock) is the primary - // guard; this retry only needs to absorb the rare, bursty sources that lock isn't held for. - return exception is DbUpdateConcurrencyException - or InvalidOperationException - or ArgumentNullException - or NullReferenceException - or IndexOutOfRangeException - or KeyNotFoundException; - } - - private async ValueTask SaveChangesCoreAsync(CancellationToken cancellationToken) - { - using var l = await this._lock.LockAsync(); - - // when we have a change publisher attached, we want to get the changed entries before accepting them. - // Otherwise, we can accept them. - var acceptChanges = true; - - object? sender = null; - SavedChangesEventArgs? args = null; - if (this._changeListener is { }) - { - this.Context.SavedChanges += OnSavedChanges; - acceptChanges = false; - } - - try - { - await this.Context.SaveChangesAsync(acceptChanges, cancellationToken).ConfigureAwait(false); - - if (args is not null) - { - await this.OnSavedChangesAsync(sender, args).ConfigureAwait(false); - } - } - finally - { - this.Context.SavedChanges -= OnSavedChanges; - } - - return true; - - void OnSavedChanges(object? s, SavedChangesEventArgs e) - { - sender = s; - args = e; - } - } - /// public IDisposable SuspendChangeNotifications() { @@ -323,6 +260,69 @@ internal class EntityFrameworkContextBase : IContext this.Context.Dispose(); } + /// + /// Determines whether the exception is a transient conflict caused by a concurrent entity mutation + /// racing this save, and is therefore worth retrying. + /// + /// The exception thrown by the save. + /// true if the save should be retried. + private static bool IsTransientConcurrencyConflict(Exception exception) + { + // A concurrent entity mutation racing this save corrupts the change tracker mid-enumeration. + // Depending on exactly where change detection was, it surfaces as one of several types - a + // modified collection (InvalidOperationException), a transiently-null internal key + // (ArgumentNullException/NullReferenceException), or an out-of-range index. All are transient: + // the racing mutation is a single quick operation, so a bounded retry lands on a stable moment. + // A genuinely persistent error of the same type is not masked - it rethrows once the retries + // are exhausted. The deterministic serialization (per-player persistence lock) is the primary + // guard; this retry only needs to absorb the rare, bursty sources that lock isn't held for. + return exception is DbUpdateConcurrencyException + or InvalidOperationException + or ArgumentNullException + or NullReferenceException + or IndexOutOfRangeException + or KeyNotFoundException; + } + + private async ValueTask SaveChangesCoreAsync(CancellationToken cancellationToken) + { + using var l = await this._lock.LockAsync(); + + // when we have a change publisher attached, we want to get the changed entries before accepting them. + // Otherwise, we can accept them. + var acceptChanges = true; + + object? sender = null; + SavedChangesEventArgs? args = null; + if (this._changeListener is { }) + { + this.Context.SavedChanges += OnSavedChanges; + acceptChanges = false; + } + + try + { + await this.Context.SaveChangesAsync(acceptChanges, cancellationToken).ConfigureAwait(false); + + if (args is not null) + { + await this.OnSavedChangesAsync(sender, args).ConfigureAwait(false); + } + } + finally + { + this.Context.SavedChanges -= OnSavedChanges; + } + + return true; + + void OnSavedChanges(object? s, SavedChangesEventArgs e) + { + sender = s; + args = e; + } + } + private bool DetachInternal(object item) { var entry = this.Context.Entry(item); diff --git a/src/Persistence/EntityFramework/Migrations/20260730194321_AddCastleSiege.cs b/src/Persistence/EntityFramework/Migrations/20260730194321_AddCastleSiege.cs index d1bceb0..d40faa7 100644 --- a/src/Persistence/EntityFramework/Migrations/20260730194321_AddCastleSiege.cs +++ b/src/Persistence/EntityFramework/Migrations/20260730194321_AddCastleSiege.cs @@ -34,7 +34,7 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations TaxStore = table.Column(type: "smallint", nullable: false), TaxHunt = table.Column(type: "integer", nullable: false), IsHuntZoneEnabled = table.Column(type: "boolean", nullable: false), - TributeMoney = table.Column(type: "bigint", nullable: false) + TributeMoney = table.Column(type: "bigint", nullable: false), }, constraints: table => { @@ -53,7 +53,7 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations DefenseLevel = table.Column(type: "smallint", nullable: false), RegenLevel = table.Column(type: "smallint", nullable: false), LifeLevel = table.Column(type: "smallint", nullable: false), - CurrentHp = table.Column(type: "integer", nullable: false) + CurrentHp = table.Column(type: "integer", nullable: false), }, constraints: table => { @@ -87,7 +87,7 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations GuildScoreCastleSiege = table.Column(type: "integer", nullable: false), GuildScoreCastleSiegeMembers = table.Column(type: "integer", nullable: false), GateBuyPrice = table.Column(type: "integer", nullable: false), - StatueBuyPrice = table.Column(type: "integer", nullable: false) + StatueBuyPrice = table.Column(type: "integer", nullable: false), }, constraints: table => { @@ -125,7 +125,7 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations DefaultSide = table.Column(type: "smallint", nullable: false), SpawnX = table.Column(type: "smallint", nullable: false), SpawnY = table.Column(type: "smallint", nullable: false), - Direction = table.Column(type: "integer", nullable: false) + Direction = table.Column(type: "integer", nullable: false), }, constraints: table => { @@ -155,7 +155,7 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations State = table.Column(type: "smallint", nullable: false), DayOfWeek = table.Column(type: "integer", nullable: false), Hour = table.Column(type: "smallint", nullable: false), - Minute = table.Column(type: "smallint", nullable: false) + Minute = table.Column(type: "smallint", nullable: false), }, constraints: table => { @@ -183,7 +183,7 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations Level = table.Column(type: "smallint", nullable: false), RequiredJewelOfGuardianCount = table.Column(type: "integer", nullable: false), RequiredZen = table.Column(type: "integer", nullable: false), - Value = table.Column(type: "integer", nullable: false) + Value = table.Column(type: "integer", nullable: false), }, constraints: table => { @@ -236,7 +236,7 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations X1 = table.Column(type: "smallint", nullable: false), Y1 = table.Column(type: "smallint", nullable: false), X2 = table.Column(type: "smallint", nullable: false), - Y2 = table.Column(type: "smallint", nullable: false) + Y2 = table.Column(type: "smallint", nullable: false), }, constraints: table => { diff --git a/src/Web/AdminPanel/Components/Layout/CreationPanel.razor b/src/Web/AdminPanel/Components/Layout/CreationPanel.razor index 855e43d..e6dad5d 100644 --- a/src/Web/AdminPanel/Components/Layout/CreationPanel.razor +++ b/src/Web/AdminPanel/Components/Layout/CreationPanel.razor @@ -8,7 +8,7 @@ @implements IDisposable @inject CreationPanelService Panel -@inject Blazored.Toast.Services.IToastService ToastService +@inject IToastService ToastService @if (this.Panel.Current is { } session) { diff --git a/src/Web/AdminPanel/Components/Layout/MainLayout.razor b/src/Web/AdminPanel/Components/Layout/MainLayout.razor index 429c724..ff9f4f4 100644 --- a/src/Web/AdminPanel/Components/Layout/MainLayout.razor +++ b/src/Web/AdminPanel/Components/Layout/MainLayout.razor @@ -39,7 +39,7 @@
- +
@Body diff --git a/src/Web/AdminPanel/MUnique.OpenMU.Web.AdminPanel.csproj b/src/Web/AdminPanel/MUnique.OpenMU.Web.AdminPanel.csproj index 276cb71..a974a32 100644 --- a/src/Web/AdminPanel/MUnique.OpenMU.Web.AdminPanel.csproj +++ b/src/Web/AdminPanel/MUnique.OpenMU.Web.AdminPanel.csproj @@ -25,7 +25,6 @@ - diff --git a/src/Web/AdminPanel/Pages/CreateConnectServerConfig.razor.cs b/src/Web/AdminPanel/Pages/CreateConnectServerConfig.razor.cs index 48c4497..0489c52 100644 --- a/src/Web/AdminPanel/Pages/CreateConnectServerConfig.razor.cs +++ b/src/Web/AdminPanel/Pages/CreateConnectServerConfig.razor.cs @@ -6,12 +6,12 @@ namespace MUnique.OpenMU.Web.AdminPanel.Pages; using System.ComponentModel.DataAnnotations; using System.Threading; -using Blazored.Toast.Services; using Microsoft.AspNetCore.Components; using MUnique.OpenMU.DataModel.Configuration; using MUnique.OpenMU.Interfaces; using MUnique.OpenMU.Persistence; using MUnique.OpenMU.Web.AdminPanel.Properties; +using MUnique.OpenMU.Web.Shared.Components.Toast; using MUnique.OpenMU.Web.Shared.Services; /// diff --git a/src/Web/AdminPanel/Pages/CreateGameServerConfig.razor.cs b/src/Web/AdminPanel/Pages/CreateGameServerConfig.razor.cs index a5597d6..b7a45f9 100644 --- a/src/Web/AdminPanel/Pages/CreateGameServerConfig.razor.cs +++ b/src/Web/AdminPanel/Pages/CreateGameServerConfig.razor.cs @@ -6,13 +6,13 @@ namespace MUnique.OpenMU.Web.AdminPanel.Pages; using System.ComponentModel.DataAnnotations; using System.Threading; -using Blazored.Toast.Services; using Microsoft.AspNetCore.Components; using MUnique.OpenMU.DataModel.Configuration; using MUnique.OpenMU.Interfaces; using MUnique.OpenMU.Persistence; using MUnique.OpenMU.Web.AdminPanel.Properties; using MUnique.OpenMU.Web.Shared.Components.Modal; +using MUnique.OpenMU.Web.Shared.Components.Toast; using MUnique.OpenMU.Web.Shared.Services; /// diff --git a/src/Web/AdminPanel/Pages/EditBase.cs b/src/Web/AdminPanel/Pages/EditBase.cs index d180a72..f5cc8bd 100644 --- a/src/Web/AdminPanel/Pages/EditBase.cs +++ b/src/Web/AdminPanel/Pages/EditBase.cs @@ -6,7 +6,6 @@ namespace MUnique.OpenMU.Web.AdminPanel.Pages; using System.Reflection; using System.Threading; -using Blazored.Toast.Services; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Rendering; using Microsoft.AspNetCore.Components.Routing; @@ -19,6 +18,7 @@ using MUnique.OpenMU.Web.AdminPanel.Properties; using MUnique.OpenMU.Web.Shared; using MUnique.OpenMU.Web.Shared.Components; using MUnique.OpenMU.Web.Shared.Components.Modal; +using MUnique.OpenMU.Web.Shared.Components.Toast; using MUnique.OpenMU.Web.Shared.Services; /// diff --git a/src/Web/AdminPanel/Pages/EditConfigGrid.razor.cs b/src/Web/AdminPanel/Pages/EditConfigGrid.razor.cs index 5479006..e95056e 100644 --- a/src/Web/AdminPanel/Pages/EditConfigGrid.razor.cs +++ b/src/Web/AdminPanel/Pages/EditConfigGrid.razor.cs @@ -8,7 +8,6 @@ using System.Collections; using System.ComponentModel; using System.Reflection; using System.Threading; -using Blazored.Toast.Services; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.QuickGrid; using Microsoft.Extensions.Logging; @@ -18,6 +17,7 @@ using MUnique.OpenMU.Persistence; using MUnique.OpenMU.Web.AdminPanel.Properties; using MUnique.OpenMU.Web.Shared; using MUnique.OpenMU.Web.Shared.Components.Modal; +using MUnique.OpenMU.Web.Shared.Components.Toast; using MUnique.OpenMU.Web.Shared.Services; /// diff --git a/src/Web/AdminPanel/Pages/EditMap.cs b/src/Web/AdminPanel/Pages/EditMap.cs index 477a1d9..9700e16 100644 --- a/src/Web/AdminPanel/Pages/EditMap.cs +++ b/src/Web/AdminPanel/Pages/EditMap.cs @@ -6,7 +6,6 @@ namespace MUnique.OpenMU.Web.AdminPanel.Pages; using System.Reflection; using System.Threading; -using Blazored.Toast.Services; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Rendering; using Microsoft.AspNetCore.Components.Routing; @@ -19,6 +18,7 @@ using MUnique.OpenMU.Web.Shared; using MUnique.OpenMU.Web.Shared.Components; using MUnique.OpenMU.Web.Shared.Components.MapEditor; using MUnique.OpenMU.Web.Shared.Components.Modal; +using MUnique.OpenMU.Web.Shared.Components.Toast; using MUnique.OpenMU.Web.Shared.Services; /// diff --git a/src/Web/AdminPanel/Pages/Merchants.razor.cs b/src/Web/AdminPanel/Pages/Merchants.razor.cs index 76bd696..62728f6 100644 --- a/src/Web/AdminPanel/Pages/Merchants.razor.cs +++ b/src/Web/AdminPanel/Pages/Merchants.razor.cs @@ -6,7 +6,6 @@ namespace MUnique.OpenMU.Web.AdminPanel.Pages; using System.ComponentModel; using System.Threading; -using Blazored.Toast.Services; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.QuickGrid; using Microsoft.AspNetCore.Components.Routing; @@ -16,6 +15,7 @@ using MUnique.OpenMU.DataModel.Configuration; using MUnique.OpenMU.DataModel.Entities; using MUnique.OpenMU.Persistence; using MUnique.OpenMU.Web.AdminPanel.Properties; +using MUnique.OpenMU.Web.Shared.Components.Toast; using MUnique.OpenMU.Web.Shared.Services; /// diff --git a/src/Web/AdminPanel/Pages/Servers.razor b/src/Web/AdminPanel/Pages/Servers.razor index 0c1c70b..595ca2d 100644 --- a/src/Web/AdminPanel/Pages/Servers.razor +++ b/src/Web/AdminPanel/Pages/Servers.razor @@ -2,7 +2,6 @@ @using System.ComponentModel @using Microsoft.Extensions.DependencyInjection -@using Blazored.Toast.Services @using MUnique.OpenMU.Interfaces @using MUnique.OpenMU.Web.AdminPanel.Properties diff --git a/src/Web/AdminPanel/Startup.cs b/src/Web/AdminPanel/Startup.cs index ffe0edc..8c870da 100644 --- a/src/Web/AdminPanel/Startup.cs +++ b/src/Web/AdminPanel/Startup.cs @@ -5,7 +5,6 @@ namespace MUnique.OpenMU.Web.AdminPanel; using System.IO; -using Blazored.Toast; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Configuration; @@ -61,7 +60,7 @@ public class Startup .ConfigureApplicationPartManager(setup => setup.FeatureProviders.Add(new GenericControllerFeatureProvider())); - services.AddBlazoredToast(); + services.AddToasts(); services.AddScoped(); services.AddScoped(sp => sp.GetRequiredService()); diff --git a/src/Web/AdminPanel/WebApplicationExtensions.cs b/src/Web/AdminPanel/WebApplicationExtensions.cs index 32b9159..16c5908 100644 --- a/src/Web/AdminPanel/WebApplicationExtensions.cs +++ b/src/Web/AdminPanel/WebApplicationExtensions.cs @@ -5,7 +5,6 @@ namespace MUnique.OpenMU.Web.AdminPanel; using System.IO; -using Blazored.Toast; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting.StaticWebAssets; using Microsoft.Extensions.DependencyInjection; @@ -66,7 +65,7 @@ public static class WebApplicationExtensions .ConfigureApplicationPartManager(setup => setup.FeatureProviders.Add(new GenericControllerFeatureProvider())); - services.AddBlazoredToast(); + services.AddToasts(); services.AddScoped(); services.AddScoped(sp => sp.GetRequiredService()); diff --git a/src/Web/AdminPanel/_Imports.razor b/src/Web/AdminPanel/_Imports.razor index a3b424c..427cefe 100644 --- a/src/Web/AdminPanel/_Imports.razor +++ b/src/Web/AdminPanel/_Imports.razor @@ -10,9 +10,6 @@ @using Microsoft.AspNetCore.Components.Web.Virtualization @using Microsoft.JSInterop -@using Blazored.Toast -@using Blazored.Toast.Services - @using BlazorInputFile @using MUnique.OpenMU.Web.AdminPanel @@ -20,6 +17,7 @@ @using MUnique.OpenMU.Web.Shared @using MUnique.OpenMU.Web.Shared.Components +@using MUnique.OpenMU.Web.Shared.Components.Toast @using MUnique.OpenMU.Web.Shared.Components.Modal @using MUnique.OpenMU.Web.Shared.Components.Form @using MUnique.OpenMU.Web.Shared.Components.Form.Modal diff --git a/src/Web/Shared/Components/Toast/IToastService.cs b/src/Web/Shared/Components/Toast/IToastService.cs new file mode 100644 index 0000000..0b1de95 --- /dev/null +++ b/src/Web/Shared/Components/Toast/IToastService.cs @@ -0,0 +1,63 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.Shared.Components.Toast; + +using System; +using System.Collections.Generic; + +/// +/// Service for showing toast notifications. +/// +public interface IToastService +{ + /// + /// Occurs when the list of toasts has changed (added, closed, cleared). + /// + event Action? StateChanged; + + /// + /// Gets the currently shown toasts. + /// + IReadOnlyList Toasts { get; } + + /// + /// Shows a success toast. + /// + /// The message. + /// The optional heading. + void ShowSuccess(string message, string? heading = null); + + /// + /// Shows an info toast. + /// + /// The message. + /// The optional heading. + void ShowInfo(string message, string? heading = null); + + /// + /// Shows a warning toast. + /// + /// The message. + /// The optional heading. + void ShowWarning(string message, string? heading = null); + + /// + /// Shows an error toast. + /// + /// The message. + /// The optional heading. + void ShowError(string message, string? heading = null); + + /// + /// Closes the specified toast (triggers its closing animation). + /// + /// The toast to close. + void Close(ToastInstance toast); + + /// + /// Closes all currently shown toasts. + /// + void Clear(); +} diff --git a/src/Web/Shared/Components/Toast/ToastContainer.razor b/src/Web/Shared/Components/Toast/ToastContainer.razor new file mode 100644 index 0000000..907f5c3 --- /dev/null +++ b/src/Web/Shared/Components/Toast/ToastContainer.razor @@ -0,0 +1,66 @@ +@using MUnique.OpenMU.Web.Shared.Components.Toast +@using MUnique.OpenMU.Web.Shared.Services +@inject ToastService ToastService +@implements IDisposable + +@if (this.ToastService.Toasts.Count > 0) +{ +
+ @foreach (var toast in this.ToastService.Toasts) + { + var (iconClass, accentClass) = this.GetStyling(toast.Level); + + } +
+} + +@code { + /// + protected override void OnInitialized() + { + this.ToastService.StateChanged += this.OnStateChanged; + } + + /// + public void Dispose() + { + this.ToastService.StateChanged -= this.OnStateChanged; + } + + private void OnStateChanged() + { + _ = this.InvokeAsync(this.StateHasChanged); + } + + private Task CloseAsync(ToastInstance toast) + { + this.ToastService.Close(toast); + return Task.CompletedTask; + } + + private (string IconClass, string AccentClass) GetStyling(ToastLevel level) + { + return level switch + { + ToastLevel.Success => ("oi-circle-check", "text-success"), + ToastLevel.Info => ("oi-info", "text-primary"), + ToastLevel.Warning => ("oi-warning", "text-warning"), + ToastLevel.Error => ("oi-bolt", "text-danger"), + _ => ("oi-info", "text-primary"), + }; + } +} \ No newline at end of file diff --git a/src/Web/Shared/Components/Toast/ToastContainer.razor.css b/src/Web/Shared/Components/Toast/ToastContainer.razor.css new file mode 100644 index 0000000..dc26200 --- /dev/null +++ b/src/Web/Shared/Components/Toast/ToastContainer.razor.css @@ -0,0 +1,34 @@ +.toast-container { + z-index: 1080; +} + +.toast { + min-width: 18rem; + border-left: 4px solid currentColor; + background-color: var(--bs-body-bg); + box-shadow: var(--bs-box-shadow-lg); + animation: toast-slide-in 0.2s ease-out; + opacity: 1; + transition: opacity 0.3s ease; +} + +.toast.closing { + opacity: 0; +} + +.toast__icon { + font-size: 1.1rem; + line-height: 1; + align-self: center; +} + +@keyframes toast-slide-in { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} \ No newline at end of file diff --git a/src/Web/Shared/Components/Toast/ToastInstance.cs b/src/Web/Shared/Components/Toast/ToastInstance.cs new file mode 100644 index 0000000..0fb8cca --- /dev/null +++ b/src/Web/Shared/Components/Toast/ToastInstance.cs @@ -0,0 +1,52 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.Shared.Components.Toast; + +using System; + +/// +/// Represents a single toast message shown in the . +/// +public sealed class ToastInstance +{ + /// + /// Initializes a new instance of the class. + /// + /// The level. + /// The message. + /// The optional heading. + internal ToastInstance(ToastLevel level, string message, string? heading) + { + this.Key = Guid.NewGuid(); + this.Level = level; + this.Message = message; + this.Heading = heading; + } + + /// + /// Gets a stable key identifying this toast, used as a render key. + /// + public Guid Key { get; } + + /// + /// Gets the level. + /// + public ToastLevel Level { get; } + + /// + /// Gets the message. + /// + public string Message { get; } + + /// + /// Gets the optional heading. + /// + public string? Heading { get; } + + /// + /// Gets or sets a value indicating whether the toast is performing its closing animation. + /// + internal bool IsClosing { get; set; } +} \ No newline at end of file diff --git a/src/Web/Shared/Components/Toast/ToastLevel.cs b/src/Web/Shared/Components/Toast/ToastLevel.cs new file mode 100644 index 0000000..269d160 --- /dev/null +++ b/src/Web/Shared/Components/Toast/ToastLevel.cs @@ -0,0 +1,31 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.Shared.Components.Toast; + +/// +/// The level of a toast message. +/// +public enum ToastLevel +{ + /// + /// Informational message. + /// + Info, + + /// + /// Success message. + /// + Success, + + /// + /// Warning message. + /// + Warning, + + /// + /// Error message. + /// + Error, +} diff --git a/src/Web/Shared/MUnique.OpenMU.Web.Shared.csproj b/src/Web/Shared/MUnique.OpenMU.Web.Shared.csproj index bc4006e..ee5fe23 100644 --- a/src/Web/Shared/MUnique.OpenMU.Web.Shared.csproj +++ b/src/Web/Shared/MUnique.OpenMU.Web.Shared.csproj @@ -52,7 +52,6 @@ - diff --git a/src/Web/Shared/Services/ToastService.cs b/src/Web/Shared/Services/ToastService.cs new file mode 100644 index 0000000..c01ded1 --- /dev/null +++ b/src/Web/Shared/Services/ToastService.cs @@ -0,0 +1,172 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.Shared.Services; + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MUnique.OpenMU.Web.Shared.Components.Toast; + +/// +/// Default implementation of . +/// +public sealed class ToastService : IToastService, IDisposable +{ + private static readonly TimeSpan DefaultDuration = TimeSpan.FromSeconds(5); + private static readonly TimeSpan ClosingDuration = TimeSpan.FromMilliseconds(300); + + private readonly object _lock = new(); + private readonly List _toasts = new(); + private readonly List _cancellations = new(); + + /// + public event Action? StateChanged; + + /// + public IReadOnlyList Toasts + { + get + { + lock (this._lock) + { + return this._toasts.ToArray(); + } + } + } + + /// + public void ShowSuccess(string message, string? heading = null) + { + this.Show(ToastLevel.Success, message, heading); + } + + /// + public void ShowInfo(string message, string? heading = null) + { + this.Show(ToastLevel.Info, message, heading); + } + + /// + public void ShowWarning(string message, string? heading = null) + { + this.Show(ToastLevel.Warning, message, heading); + } + + /// + public void ShowError(string message, string? heading = null) + { + this.Show(ToastLevel.Error, message, heading); + } + + /// + public void Close(ToastInstance toast) + { + this.StartClosing(toast); + } + + /// + public void Clear() + { + lock (this._lock) + { + foreach (var cts in this._cancellations) + { + cts.Cancel(); + } + + this._cancellations.Clear(); + this._toasts.Clear(); + } + + this.StateChanged?.Invoke(); + } + + /// + public void Dispose() + { + lock (this._lock) + { + foreach (var cts in this._cancellations) + { + cts.Dispose(); + } + + this._cancellations.Clear(); + this._toasts.Clear(); + } + } + + private void Show(ToastLevel level, string message, string? heading) + { + var toast = new ToastInstance(level, message, heading); + var cts = new CancellationTokenSource(); + lock (this._lock) + { + this._toasts.Add(toast); + this._cancellations.Add(cts); + } + + this.StateChanged?.Invoke(); + + _ = this.AutoCloseAsync(toast, cts); + } + + private async Task AutoCloseAsync(ToastInstance toast, CancellationTokenSource cts) + { + try + { + await Task.Delay(DefaultDuration, cts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return; + } + + this.StartClosing(toast); + } + + private void StartClosing(ToastInstance toast) + { + lock (this._lock) + { + var index = this._toasts.IndexOf(toast); + if (index < 0 || toast.IsClosing) + { + return; + } + + toast.IsClosing = true; + this._cancellations[index].Cancel(); + } + + this.StateChanged?.Invoke(); + + _ = this.FinishClosingAsync(toast); + } + + private Task FinishClosingAsync(ToastInstance toast) + { + return Task.Run(async () => + { + await Task.Delay(ClosingDuration).ConfigureAwait(false); + + lock (this._lock) + { + var index = this._toasts.IndexOf(toast); + if (index < 0) + { + return; + } + + this._cancellations[index].Dispose(); + this._cancellations.RemoveAt(index); + this._toasts.RemoveAt(index); + } + + this.StateChanged?.Invoke(); + }); + } +} \ No newline at end of file diff --git a/src/Web/Shared/Services/ToastServiceCollectionExtensions.cs b/src/Web/Shared/Services/ToastServiceCollectionExtensions.cs new file mode 100644 index 0000000..1e15a25 --- /dev/null +++ b/src/Web/Shared/Services/ToastServiceCollectionExtensions.cs @@ -0,0 +1,26 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.Shared.Services; + +using Microsoft.Extensions.DependencyInjection; +using MUnique.OpenMU.Web.Shared.Components.Toast; + +/// +/// Extension methods for registering the toast service. +/// +public static class ToastServiceCollectionExtensions +{ + /// + /// Adds the toast service to the service collection. + /// + /// The service collection. + /// The service collection, for chaining. + public static IServiceCollection AddToasts(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(sp => sp.GetRequiredService()); + return services; + } +} \ No newline at end of file diff --git a/src/Web/Shared/_Imports.razor b/src/Web/Shared/_Imports.razor index 761ce4e..1ff3a52 100644 --- a/src/Web/Shared/_Imports.razor +++ b/src/Web/Shared/_Imports.razor @@ -6,9 +6,6 @@ @using Microsoft.Extensions.Localization @using Microsoft.JSInterop -@using Blazored.Toast -@using Blazored.Toast.Services - @using BlazorInputFile @using MUnique.OpenMU.Web.Shared
@Resources.FileName@Resources.LastUpdate@Resources.SizeActions@Resources.LastUpdate@Resources.SizeActions
- + @if (this._selectedFile != null) + { +
@FormatFileSize(entry.Length)
+ }
@entry.LastWriteTime@FormatFileSize(entry.Length) + @if (this._selectedFile == null) + { + @entry.LastWriteTime@FormatFileSize(entry.Length) @@ -45,78 +64,80 @@ - -@if (this._selectedFile != null) -{ -
-
-
- - Log Viewer: - @this._selectedFile.Name - @FormatFileSize(this._selectedFile.Length) -
-
-
- - + + @if (this._selectedFile != null) + { +
+
+
+
+ + Log Viewer: + @this._selectedFile.Name +
+
+
+ + +
+ + +
- - -
-
-
-
-
-
- - - @if (!string.IsNullOrEmpty(this._searchText)) +
+
+
+
+ + + @if (!string.IsNullOrEmpty(this._searchText)) + { + + } +
+
+
+ +
+ @if (this._logLines.Count == 0) { - +
No log entries found.
} + else + { + var filteredLines = GetFilteredLines(); + @if (filteredLines.Count == 0) + { +
No log entries match your filter.
+ } + else + { + @foreach (var line in filteredLines) + { +
@line
+ } + } + } +
+ +
+
+ Showing @GetFilteredLines().Count of @this._logLines.Count lines (Last 300 lines loaded). +
+
- -
- @if (this._logLines.Count == 0) - { -
No log entries found.
- } - else - { - var filteredLines = GetFilteredLines(); - @if (filteredLines.Count == 0) - { -
No log entries match your filter.
- } - else - { - @foreach (var line in filteredLines) - { -
@line
- } - } - } -
- -
-
- Showing @GetFilteredLines().Count of @this._logLines.Count lines (Last 300 lines loaded). -
- -
-
-} + } +
@code { private readonly List _files = new (); From f050e56e91eb6f68fd0c1eb0a9b5a0f67ffdfe81 Mon Sep 17 00:00:00 2001 From: Rhefew Date: Tue, 14 Jul 2026 12:55:04 +0200 Subject: [PATCH 08/13] feat(admin): auto-scroll terminal to bottom on log refreshes --- src/Web/AdminPanel/Pages/LogFiles.razor | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Web/AdminPanel/Pages/LogFiles.razor b/src/Web/AdminPanel/Pages/LogFiles.razor index fde9eed..aa36577 100644 --- a/src/Web/AdminPanel/Pages/LogFiles.razor +++ b/src/Web/AdminPanel/Pages/LogFiles.razor @@ -226,6 +226,7 @@ this._selectedFile = new FileInfo(this._selectedFile.FullName); this._logLines = this.ReadLastLines(this._selectedFile.FullName, 300); + this._shouldScrollToBottom = true; } private List ReadLastLines(string path, int maxLines) From 074f91bd476cded48e727fc2ba7ef45ba3afe166 Mon Sep 17 00:00:00 2001 From: Rhefew Date: Fri, 24 Jul 2026 12:54:53 +0200 Subject: [PATCH 09/13] style(admin): apply code formatting, static helper functions and ConfigureWait to LogFiles.razor --- src/Web/AdminPanel/Pages/LogFiles.razor | 208 ++++++++++++------------ 1 file changed, 104 insertions(+), 104 deletions(-) diff --git a/src/Web/AdminPanel/Pages/LogFiles.razor b/src/Web/AdminPanel/Pages/LogFiles.razor index aa36577..2693e25 100644 --- a/src/Web/AdminPanel/Pages/LogFiles.razor +++ b/src/Web/AdminPanel/Pages/LogFiles.razor @@ -15,7 +15,7 @@
Log Files -
@@ -39,7 +39,7 @@ var isSelected = this._selectedFile?.FullName == entry.FullName;
- @if (this._selectedFile != null) @@ -78,13 +78,13 @@
- +
- -
@@ -97,7 +97,7 @@ @if (!string.IsNullOrEmpty(this._searchText)) { - + } @@ -110,7 +110,7 @@ } else { - var filteredLines = GetFilteredLines(); + var filteredLines = this.GetFilteredLines(); @if (filteredLines.Count == 0) {
No log entries match your filter.
@@ -127,9 +127,9 @@
- Showing @GetFilteredLines().Count of @this._logLines.Count lines (Last 300 lines loaded). + Showing @this.GetFilteredLines().Count of @this._logLines.Count lines (Last 300 lines loaded).
-
@@ -140,13 +140,19 @@ @code { - private readonly List _files = new (); + private readonly List _files = new(); private FileInfo? _selectedFile; - private List _logLines = new (); + private List _logLines = new(); private string _searchText = string.Empty; - private bool _liveUpdate = false; + private bool _liveUpdate; private System.Threading.Timer? _timer; - private bool _shouldScrollToBottom = false; + private bool _shouldScrollToBottom; + + /// + public void Dispose() + { + this._timer?.Dispose(); + } /// protected override void OnInitialized() @@ -154,6 +160,87 @@ this.RefreshFileList(); } + /// + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (this._shouldScrollToBottom) + { + this._shouldScrollToBottom = false; + await this.ScrollToBottomAsync().ConfigureAwait(false); + } + } + + private static string FormatFileSize(long size) + { + return size switch + { + < 1024 => $"{size} bytes", + < 1024 * 1024 => $"{Math.Round(size / 1024D, 2)} KiB", + < 1024L * 1024 * 1024 => $"{Math.Round(size / (1024D * 1024D), 2)} MiB", + _ => $"{Math.Round(size / (1024D * 1024D * 1024D), 2)} GiB", + }; + } + + private static List ReadLastLines(string path, int maxLines) + { + var lines = new List(); + try + { + using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + long offset = Math.Max(0, fs.Length - 102400); // Read last 100 KB + fs.Seek(offset, SeekOrigin.Begin); + using var reader = new StreamReader(fs, System.Text.Encoding.UTF8); + + if (offset > 0) + { + // Discard partial line + reader.ReadLine(); + } + + string? line; + while ((line = reader.ReadLine()) != null) + { + lines.Add(line); + } + + if (lines.Count > maxLines) + { + lines = lines.Skip(lines.Count - maxLines).ToList(); + } + } + catch (Exception ex) + { + lines.Add($"Error reading log file: {ex.Message}"); + } + + return lines; + } + + private static string GetLineColorStyle(string line) + { + if (line.Contains("[Error]", StringComparison.OrdinalIgnoreCase) || line.Contains("[Critical]", StringComparison.OrdinalIgnoreCase)) + { + return "color: #ff6b6b; font-weight: bold;"; + } + + if (line.Contains("[Warning]", StringComparison.OrdinalIgnoreCase)) + { + return "color: #feca57;"; + } + + if (line.Contains("[Debug]", StringComparison.OrdinalIgnoreCase)) + { + return "color: #8a8d93; font-style: italic;"; + } + + if (line.Contains("[Information]", StringComparison.OrdinalIgnoreCase)) + { + return "color: #1dd1a1;"; + } + + return "color: #d1d2d6;"; + } + private void RefreshFileList() { this._files.Clear(); @@ -203,7 +290,7 @@ { this._timer ??= new System.Threading.Timer(_ => { - InvokeAsync(() => + this.InvokeAsync(() => { this.RefreshLogLines(); this.StateHasChanged(); @@ -225,45 +312,10 @@ } this._selectedFile = new FileInfo(this._selectedFile.FullName); - this._logLines = this.ReadLastLines(this._selectedFile.FullName, 300); + this._logLines = ReadLastLines(this._selectedFile.FullName, 300); this._shouldScrollToBottom = true; } - private List ReadLastLines(string path, int maxLines) - { - var lines = new List(); - try - { - using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - long offset = Math.Max(0, fs.Length - 102400); // Read last 100 KB - fs.Seek(offset, SeekOrigin.Begin); - using var reader = new StreamReader(fs, System.Text.Encoding.UTF8); - - if (offset > 0) - { - // Discard partial line - reader.ReadLine(); - } - - string? line; - while ((line = reader.ReadLine()) != null) - { - lines.Add(line); - } - - if (lines.Count > maxLines) - { - lines = lines.Skip(lines.Count - maxLines).ToList(); - } - } - catch (Exception ex) - { - lines.Add($"Error reading log file: {ex.Message}"); - } - - return lines; - } - private List GetFilteredLines() { if (string.IsNullOrWhiteSpace(this._searchText)) @@ -276,67 +328,15 @@ .ToList(); } - private string GetLineColorStyle(string line) - { - if (line.Contains("[Error]", StringComparison.OrdinalIgnoreCase) || line.Contains("[Critical]", StringComparison.OrdinalIgnoreCase)) - { - return "color: #ff6b6b; font-weight: bold;"; - } - - if (line.Contains("[Warning]", StringComparison.OrdinalIgnoreCase)) - { - return "color: #feca57;"; - } - - if (line.Contains("[Debug]", StringComparison.OrdinalIgnoreCase)) - { - return "color: #8a8d93; font-style: italic;"; - } - - if (line.Contains("[Information]", StringComparison.OrdinalIgnoreCase)) - { - return "color: #1dd1a1;"; - } - - return "color: #d1d2d6;"; - } - - private async Task ScrollToBottom() + private async Task ScrollToBottomAsync() { try { - await JSRuntime.InvokeVoidAsync("eval", "var el = document.getElementById('log-terminal'); if (el) { el.scrollTop = el.scrollHeight; }"); + await this.JSRuntime.InvokeVoidAsync("eval", "var el = document.getElementById('log-terminal'); if (el) { el.scrollTop = el.scrollHeight; }").ConfigureAwait(false); } catch { // Ignore error } } - - /// - protected override async Task OnAfterRenderAsync(bool firstRender) - { - if (this._shouldScrollToBottom) - { - this._shouldScrollToBottom = false; - await this.ScrollToBottom(); - } - } - - /// - public void Dispose() - { - this._timer?.Dispose(); - } - - private string FormatFileSize(long size) - { - return size switch - { - (< 1024 << 10) => $"{Math.Round(size / 1024D, 2)} KiB", - (< 1024 << 20) => $"{Math.Round(size * 1D / (1024 << 10), 2)} MiB", - (< 1024L << 30) => $"{Math.Round(size * 1D / (1024L << 20), 2)} GiB", - _ => $"{size} bytes" - }; - } } From f5b4292af13935735b09e0b08c2c0de26c3873c8 Mon Sep 17 00:00:00 2001 From: Rhefew Date: Mon, 27 Jul 2026 11:54:47 +0200 Subject: [PATCH 10/13] fix(admin): address code review feedback on log viewer localization, CSP JS module, timer disposal, and scroll UX --- src/Web/AdminPanel/Pages/LogFiles.razor | 152 ++++++++++++------ src/Web/AdminPanel/Pages/LogFiles.razor.js | 14 ++ .../Properties/Resources.Designer.cs | 92 ++++++++++- src/Web/AdminPanel/Properties/Resources.resx | 36 +++++ 4 files changed, 247 insertions(+), 47 deletions(-) create mode 100644 src/Web/AdminPanel/Pages/LogFiles.razor.js diff --git a/src/Web/AdminPanel/Pages/LogFiles.razor b/src/Web/AdminPanel/Pages/LogFiles.razor index 2693e25..c79ef4b 100644 --- a/src/Web/AdminPanel/Pages/LogFiles.razor +++ b/src/Web/AdminPanel/Pages/LogFiles.razor @@ -1,8 +1,8 @@ -@page "/logfiles" +@page "/logfiles" @using System.IO @using MUnique.OpenMU.Web.AdminPanel.Properties -@implements IDisposable +@implements IAsyncDisposable @inject IJSRuntime JSRuntime OpenMU: @Resources.LogFiles @@ -14,8 +14,8 @@
- Log Files -
@@ -30,7 +30,7 @@
@Resources.LastUpdate @Resources.SizeActions@Resources.Actions
@FormatFileSize(entry.Length) - +