Merge branch 'integrate/openmu-20260813a'
OpenMU upstream Dalga A: d4ca915c4..a7412572c aralığındaki düşük riskli sekiz PR. #858, #854, #845 ve #864 uygulandı; #754 ve #860'ın gövdesi zaten ağaçtaydı; #859 boş çıktığı için atlandı. Ayrıca #860'ın eksik kalan testi ve #845'in düşürdüğü chat command kaynak dizeleri tamamlandı. 1.295 test başarılı, 6 atlandı, 0 hata.
This commit is contained in:
@@ -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.
|
experience bonus applies. Parties re-form every hour.
|
||||||
|
|
||||||
A player may invite a bot into their own party: it accepts after a human-like
|
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
|
pause of a few seconds, as long as it is not in the middle of an errand. There is
|
||||||
middle of an errand. A living player takes precedence over the bot's own company
|
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
|
— 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
|
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
|
to be free. In a party the bot follows its leader, defers a due reset, and
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
@inherits LayoutComponentBase
|
@inherits LayoutComponentBase
|
||||||
|
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<!--
|
|
||||||
<div class="sidebar">
|
|
||||||
<NavMenu />
|
|
||||||
</div>
|
|
||||||
<BlazoredToasts />
|
|
||||||
-->
|
|
||||||
<main>
|
<main>
|
||||||
<div class="top-row px-4">
|
<div class="top-row px-4">
|
||||||
<BreadcrumbNavigation />
|
<BreadcrumbNavigation />
|
||||||
|
|||||||
@@ -9,9 +9,6 @@
|
|||||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||||
@using Microsoft.JSInterop
|
@using Microsoft.JSInterop
|
||||||
|
|
||||||
@using Blazored.Toast
|
|
||||||
@using Blazored.Toast.Services
|
|
||||||
|
|
||||||
@using BlazorInputFile
|
@using BlazorInputFile
|
||||||
|
|
||||||
@using MUnique.OpenMU.Web.Shared
|
@using MUnique.OpenMU.Web.Shared
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
<PackageVersion Include="BCrypt.Net-Next" Version="4.0.3" />
|
<PackageVersion Include="BCrypt.Net-Next" Version="4.0.3" />
|
||||||
<PackageVersion Include="BenchmarkDotNet" Version="0.15.8" />
|
<PackageVersion Include="BenchmarkDotNet" Version="0.15.8" />
|
||||||
<PackageVersion Include="BlazorInputFile" Version="0.2.0" />
|
<PackageVersion Include="BlazorInputFile" Version="0.2.0" />
|
||||||
<PackageVersion Include="Blazored.Toast" Version="4.2.1" />
|
|
||||||
<PackageVersion Include="BuildWebCompiler2022" Version="1.14.15" />
|
<PackageVersion Include="BuildWebCompiler2022" Version="1.14.15" />
|
||||||
<PackageVersion Include="DG.AdvancedDataGridView" Version="1.2.30115.18" />
|
<PackageVersion Include="DG.AdvancedDataGridView" Version="1.2.30115.18" />
|
||||||
<PackageVersion Include="Dapr.AspNetCore" Version="1.16.1" />
|
<PackageVersion Include="Dapr.AspNetCore" Version="1.16.1" />
|
||||||
|
|||||||
@@ -11,22 +11,13 @@ using MUnique.OpenMU.GameLogic.Offline;
|
|||||||
/// <see cref="BotMuHelperSettings.AutoAcceptAnyone"/>): the invitation is accepted after a short
|
/// <see cref="BotMuHelperSettings.AutoAcceptAnyone"/>): 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
|
/// human-like delay, and the bot then follows the leader like any party member (see the follow logic
|
||||||
/// in <see cref="BotNavigator"/>) until it gets bored and politely leaves. Safeguards keep it
|
/// in <see cref="BotNavigator"/>) 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
|
/// believable and abuse-free: no acceptance while the bot is on an errand (shopping trip) or has
|
||||||
/// on an errand (shopping trip) or has unfinished business (revenge), and the invitation is
|
/// unfinished business (revenge), and the invitation is re-validated when the delay has passed - the
|
||||||
/// re-validated when the delay has passed - the inviter may have joined another party or left.
|
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static class BotPartyHandler
|
internal static class BotPartyHandler
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// The maximum difference of the reset-aware effective level (see
|
|
||||||
/// <see cref="BotResetHandler.GetEffectiveLevel"/>) 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.
|
|
||||||
/// </summary>
|
|
||||||
private const int MaxEffectiveLevelGap = 500;
|
|
||||||
|
|
||||||
/// <summary>Lower bound of the human-like delay before the bot answers an invitation.</summary>
|
/// <summary>Lower bound of the human-like delay before the bot answers an invitation.</summary>
|
||||||
private static readonly TimeSpan MinAcceptDelay = TimeSpan.FromSeconds(2);
|
private static readonly TimeSpan MinAcceptDelay = TimeSpan.FromSeconds(2);
|
||||||
|
|
||||||
@@ -67,7 +58,7 @@ internal static class BotPartyHandler
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!IsRequesterEligible(bot, requester))
|
if (!IsRequesterEligible(requester))
|
||||||
{
|
{
|
||||||
return false;
|
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
|
// 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.
|
// 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);
|
bot.Logger.LogInformation("Bot '{Name}' dropped the party invitation of '{Requester}' - the situation changed.", bot.Name, requester.Name);
|
||||||
return;
|
return;
|
||||||
@@ -209,14 +200,8 @@ internal static class BotPartyHandler
|
|||||||
await party.KickMySelfAsync(bot).ConfigureAwait(false);
|
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 requester.IsAlive && requester.PlayerState.CurrentState == PlayerState.EnteredWorld;
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
var levelGap = Math.Abs(BotResetHandler.GetEffectiveLevel(bot) - BotResetHandler.GetEffectiveLevel(requester));
|
|
||||||
return levelGap <= MaxEffectiveLevelGap;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,7 +141,11 @@ public sealed class OfflinePlayerMuHelper : AsyncDisposable
|
|||||||
{
|
{
|
||||||
try
|
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();
|
this._player.OnAiTickSucceeded();
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
|
|||||||
@@ -54,6 +54,21 @@ public class Player : AsyncDisposable, IBucketMapObserver, IAttackable, IAttacke
|
|||||||
private readonly AsyncLock _moveLock = new();
|
private readonly AsyncLock _moveLock = new();
|
||||||
private readonly AsyncLock _experienceLock = new();
|
private readonly AsyncLock _experienceLock = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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
|
||||||
|
/// <see cref="RunPersistenceExclusiveAsync{T}"/>.
|
||||||
|
/// </summary>
|
||||||
|
private readonly AsyncLock _persistenceLock = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tracks, per asynchronous flow, whether <see cref="_persistenceLock"/> is already held, so the
|
||||||
|
/// lock can be re-entered (Nito's <see cref="AsyncLock"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
private readonly AsyncLocal<bool> _persistenceLockHeld = new();
|
||||||
|
|
||||||
private readonly Walker _walker;
|
private readonly Walker _walker;
|
||||||
|
|
||||||
private readonly AppearanceDataAdapter _appearanceData;
|
private readonly AppearanceDataAdapter _appearanceData;
|
||||||
@@ -1879,12 +1894,86 @@ public class Player : AsyncDisposable, IBucketMapObserver, IAttackable, IAttacke
|
|||||||
/// <returns>Success of the save operation.</returns>
|
/// <returns>Success of the save operation.</returns>
|
||||||
public async ValueTask<bool> SaveProgressAsync(CancellationToken cancellationToken = default)
|
public async ValueTask<bool> 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs the given operation while holding this player's persistence lock, so that context
|
||||||
|
/// mutations and progress saves for the player never run concurrently.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The periodic progress save (<see cref="PlugIns.PeriodicSaveProgressPlugIn"/>) runs on an
|
||||||
|
/// independent timer flow. Action handlers mutate tracked entities with plain field/collection
|
||||||
|
/// writes (e.g. crafting toggling <c>item.ItemOptions</c>) which bypass the persistence context's
|
||||||
|
/// own lock; if such a mutation runs while <see cref="IContext.SaveChangesAsync"/> 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.
|
||||||
|
/// <para>
|
||||||
|
/// Invariant: never acquire another player's persistence lock (via their
|
||||||
|
/// <see cref="SaveProgressAsync"/> or <see cref="RunPersistenceExclusiveAsync{T}"/>) 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.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <typeparam name="T">The result type of the operation.</typeparam>
|
||||||
|
/// <param name="operation">The operation to run exclusively.</param>
|
||||||
|
/// <param name="cancellationToken">The cancellation token.</param>
|
||||||
|
/// <returns>The result of the operation.</returns>
|
||||||
|
public async ValueTask<T> RunPersistenceExclusiveAsync<T>(Func<ValueTask<T>> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs the given operation while holding this player's persistence lock.
|
||||||
|
/// See <see cref="RunPersistenceExclusiveAsync{T}"/> for the rationale.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="operation">The operation to run exclusively.</param>
|
||||||
|
/// <param name="cancellationToken">The cancellation token.</param>
|
||||||
|
/// <returns>A value task which completes when the operation completed.</returns>
|
||||||
|
public async ValueTask RunPersistenceExclusiveAsync(Func<ValueTask> 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ public class RemotePlayer : Player, IClientVersionProvider, IHasIpAddress
|
|||||||
this.Logger.LogDebug("[C->S] {0}", buffer.ToArray().AsString());
|
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
|
finally
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -92,69 +92,6 @@ internal class EntityFrameworkContextBase : IContext
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Determines whether the exception is a transient conflict caused by a concurrent entity mutation
|
|
||||||
/// racing this save, and is therefore worth retrying.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="exception">The exception thrown by the save.</param>
|
|
||||||
/// <returns><c>true</c> if the save should be retried.</returns>
|
|
||||||
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<bool> 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public IDisposable SuspendChangeNotifications()
|
public IDisposable SuspendChangeNotifications()
|
||||||
{
|
{
|
||||||
@@ -323,6 +260,69 @@ internal class EntityFrameworkContextBase : IContext
|
|||||||
this.Context.Dispose();
|
this.Context.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines whether the exception is a transient conflict caused by a concurrent entity mutation
|
||||||
|
/// racing this save, and is therefore worth retrying.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="exception">The exception thrown by the save.</param>
|
||||||
|
/// <returns><c>true</c> if the save should be retried.</returns>
|
||||||
|
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<bool> 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)
|
private bool DetachInternal(object item)
|
||||||
{
|
{
|
||||||
var entry = this.Context.Entry(item);
|
var entry = this.Context.Entry(item);
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
|
|||||||
TaxStore = table.Column<byte>(type: "smallint", nullable: false),
|
TaxStore = table.Column<byte>(type: "smallint", nullable: false),
|
||||||
TaxHunt = table.Column<int>(type: "integer", nullable: false),
|
TaxHunt = table.Column<int>(type: "integer", nullable: false),
|
||||||
IsHuntZoneEnabled = table.Column<bool>(type: "boolean", nullable: false),
|
IsHuntZoneEnabled = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
TributeMoney = table.Column<long>(type: "bigint", nullable: false)
|
TributeMoney = table.Column<long>(type: "bigint", nullable: false),
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
@@ -53,7 +53,7 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
|
|||||||
DefenseLevel = table.Column<byte>(type: "smallint", nullable: false),
|
DefenseLevel = table.Column<byte>(type: "smallint", nullable: false),
|
||||||
RegenLevel = table.Column<byte>(type: "smallint", nullable: false),
|
RegenLevel = table.Column<byte>(type: "smallint", nullable: false),
|
||||||
LifeLevel = table.Column<byte>(type: "smallint", nullable: false),
|
LifeLevel = table.Column<byte>(type: "smallint", nullable: false),
|
||||||
CurrentHp = table.Column<int>(type: "integer", nullable: false)
|
CurrentHp = table.Column<int>(type: "integer", nullable: false),
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
@@ -87,7 +87,7 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
|
|||||||
GuildScoreCastleSiege = table.Column<int>(type: "integer", nullable: false),
|
GuildScoreCastleSiege = table.Column<int>(type: "integer", nullable: false),
|
||||||
GuildScoreCastleSiegeMembers = table.Column<int>(type: "integer", nullable: false),
|
GuildScoreCastleSiegeMembers = table.Column<int>(type: "integer", nullable: false),
|
||||||
GateBuyPrice = table.Column<int>(type: "integer", nullable: false),
|
GateBuyPrice = table.Column<int>(type: "integer", nullable: false),
|
||||||
StatueBuyPrice = table.Column<int>(type: "integer", nullable: false)
|
StatueBuyPrice = table.Column<int>(type: "integer", nullable: false),
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
@@ -125,7 +125,7 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
|
|||||||
DefaultSide = table.Column<byte>(type: "smallint", nullable: false),
|
DefaultSide = table.Column<byte>(type: "smallint", nullable: false),
|
||||||
SpawnX = table.Column<byte>(type: "smallint", nullable: false),
|
SpawnX = table.Column<byte>(type: "smallint", nullable: false),
|
||||||
SpawnY = table.Column<byte>(type: "smallint", nullable: false),
|
SpawnY = table.Column<byte>(type: "smallint", nullable: false),
|
||||||
Direction = table.Column<int>(type: "integer", nullable: false)
|
Direction = table.Column<int>(type: "integer", nullable: false),
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
@@ -155,7 +155,7 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
|
|||||||
State = table.Column<byte>(type: "smallint", nullable: false),
|
State = table.Column<byte>(type: "smallint", nullable: false),
|
||||||
DayOfWeek = table.Column<int>(type: "integer", nullable: false),
|
DayOfWeek = table.Column<int>(type: "integer", nullable: false),
|
||||||
Hour = table.Column<byte>(type: "smallint", nullable: false),
|
Hour = table.Column<byte>(type: "smallint", nullable: false),
|
||||||
Minute = table.Column<byte>(type: "smallint", nullable: false)
|
Minute = table.Column<byte>(type: "smallint", nullable: false),
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
@@ -183,7 +183,7 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
|
|||||||
Level = table.Column<byte>(type: "smallint", nullable: false),
|
Level = table.Column<byte>(type: "smallint", nullable: false),
|
||||||
RequiredJewelOfGuardianCount = table.Column<int>(type: "integer", nullable: false),
|
RequiredJewelOfGuardianCount = table.Column<int>(type: "integer", nullable: false),
|
||||||
RequiredZen = table.Column<int>(type: "integer", nullable: false),
|
RequiredZen = table.Column<int>(type: "integer", nullable: false),
|
||||||
Value = table.Column<int>(type: "integer", nullable: false)
|
Value = table.Column<int>(type: "integer", nullable: false),
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
@@ -236,7 +236,7 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
|
|||||||
X1 = table.Column<byte>(type: "smallint", nullable: false),
|
X1 = table.Column<byte>(type: "smallint", nullable: false),
|
||||||
Y1 = table.Column<byte>(type: "smallint", nullable: false),
|
Y1 = table.Column<byte>(type: "smallint", nullable: false),
|
||||||
X2 = table.Column<byte>(type: "smallint", nullable: false),
|
X2 = table.Column<byte>(type: "smallint", nullable: false),
|
||||||
Y2 = table.Column<byte>(type: "smallint", nullable: false)
|
Y2 = table.Column<byte>(type: "smallint", nullable: false),
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
@implements IDisposable
|
@implements IDisposable
|
||||||
@inject CreationPanelService Panel
|
@inject CreationPanelService Panel
|
||||||
@inject Blazored.Toast.Services.IToastService ToastService
|
@inject IToastService ToastService
|
||||||
|
|
||||||
@if (this.Panel.Current is { } session)
|
@if (this.Panel.Current is { } session)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<BlazoredToasts />
|
<ToastContainer />
|
||||||
|
|
||||||
<article class="content px-4 py-3">
|
<article class="content px-4 py-3">
|
||||||
@Body
|
@Body
|
||||||
|
|||||||
@@ -25,7 +25,6 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="BCrypt.Net-Next" />
|
<PackageReference Include="BCrypt.Net-Next" />
|
||||||
<PackageReference Include="Blazored.Toast" />
|
|
||||||
<PackageReference Include="BlazorInputFile" />
|
<PackageReference Include="BlazorInputFile" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Components.QuickGrid" />
|
<PackageReference Include="Microsoft.AspNetCore.Components.QuickGrid" />
|
||||||
<PackageReference Include="Nito.AsyncEx" />
|
<PackageReference Include="Nito.AsyncEx" />
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ namespace MUnique.OpenMU.Web.AdminPanel.Pages;
|
|||||||
|
|
||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using Blazored.Toast.Services;
|
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
using MUnique.OpenMU.DataModel.Configuration;
|
using MUnique.OpenMU.DataModel.Configuration;
|
||||||
using MUnique.OpenMU.Interfaces;
|
using MUnique.OpenMU.Interfaces;
|
||||||
using MUnique.OpenMU.Persistence;
|
using MUnique.OpenMU.Persistence;
|
||||||
using MUnique.OpenMU.Web.AdminPanel.Properties;
|
using MUnique.OpenMU.Web.AdminPanel.Properties;
|
||||||
|
using MUnique.OpenMU.Web.Shared.Components.Toast;
|
||||||
using MUnique.OpenMU.Web.Shared.Services;
|
using MUnique.OpenMU.Web.Shared.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ namespace MUnique.OpenMU.Web.AdminPanel.Pages;
|
|||||||
|
|
||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using Blazored.Toast.Services;
|
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
using MUnique.OpenMU.DataModel.Configuration;
|
using MUnique.OpenMU.DataModel.Configuration;
|
||||||
using MUnique.OpenMU.Interfaces;
|
using MUnique.OpenMU.Interfaces;
|
||||||
using MUnique.OpenMU.Persistence;
|
using MUnique.OpenMU.Persistence;
|
||||||
using MUnique.OpenMU.Web.AdminPanel.Properties;
|
using MUnique.OpenMU.Web.AdminPanel.Properties;
|
||||||
using MUnique.OpenMU.Web.Shared.Components.Modal;
|
using MUnique.OpenMU.Web.Shared.Components.Modal;
|
||||||
|
using MUnique.OpenMU.Web.Shared.Components.Toast;
|
||||||
using MUnique.OpenMU.Web.Shared.Services;
|
using MUnique.OpenMU.Web.Shared.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ namespace MUnique.OpenMU.Web.AdminPanel.Pages;
|
|||||||
|
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using Blazored.Toast.Services;
|
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
using Microsoft.AspNetCore.Components.Rendering;
|
using Microsoft.AspNetCore.Components.Rendering;
|
||||||
using Microsoft.AspNetCore.Components.Routing;
|
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;
|
||||||
using MUnique.OpenMU.Web.Shared.Components;
|
using MUnique.OpenMU.Web.Shared.Components;
|
||||||
using MUnique.OpenMU.Web.Shared.Components.Modal;
|
using MUnique.OpenMU.Web.Shared.Components.Modal;
|
||||||
|
using MUnique.OpenMU.Web.Shared.Components.Toast;
|
||||||
using MUnique.OpenMU.Web.Shared.Services;
|
using MUnique.OpenMU.Web.Shared.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ using System.Collections;
|
|||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using Blazored.Toast.Services;
|
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
using Microsoft.AspNetCore.Components.QuickGrid;
|
using Microsoft.AspNetCore.Components.QuickGrid;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
@@ -18,6 +17,7 @@ using MUnique.OpenMU.Persistence;
|
|||||||
using MUnique.OpenMU.Web.AdminPanel.Properties;
|
using MUnique.OpenMU.Web.AdminPanel.Properties;
|
||||||
using MUnique.OpenMU.Web.Shared;
|
using MUnique.OpenMU.Web.Shared;
|
||||||
using MUnique.OpenMU.Web.Shared.Components.Modal;
|
using MUnique.OpenMU.Web.Shared.Components.Modal;
|
||||||
|
using MUnique.OpenMU.Web.Shared.Components.Toast;
|
||||||
using MUnique.OpenMU.Web.Shared.Services;
|
using MUnique.OpenMU.Web.Shared.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ namespace MUnique.OpenMU.Web.AdminPanel.Pages;
|
|||||||
|
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using Blazored.Toast.Services;
|
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
using Microsoft.AspNetCore.Components.Rendering;
|
using Microsoft.AspNetCore.Components.Rendering;
|
||||||
using Microsoft.AspNetCore.Components.Routing;
|
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;
|
||||||
using MUnique.OpenMU.Web.Shared.Components.MapEditor;
|
using MUnique.OpenMU.Web.Shared.Components.MapEditor;
|
||||||
using MUnique.OpenMU.Web.Shared.Components.Modal;
|
using MUnique.OpenMU.Web.Shared.Components.Modal;
|
||||||
|
using MUnique.OpenMU.Web.Shared.Components.Toast;
|
||||||
using MUnique.OpenMU.Web.Shared.Services;
|
using MUnique.OpenMU.Web.Shared.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -1,57 +1,434 @@
|
|||||||
@page "/logfiles"
|
@page "/logfiles"
|
||||||
|
|
||||||
@using System.IO
|
@using System.IO
|
||||||
|
@using Microsoft.Extensions.Logging
|
||||||
@using MUnique.OpenMU.Web.AdminPanel.Properties
|
@using MUnique.OpenMU.Web.AdminPanel.Properties
|
||||||
|
@implements IAsyncDisposable
|
||||||
|
@inject IJSRuntime JSRuntime
|
||||||
|
@inject ILogger<LogFiles> Logger
|
||||||
|
|
||||||
<PageTitle>OpenMU: @Resources.LogFiles</PageTitle>
|
<PageTitle>OpenMU: @Resources.LogFiles</PageTitle>
|
||||||
<Breadcrumb IsFirstFromRoot="true" Caption="@Resources.LogFiles"/>
|
<Breadcrumb IsFirstFromRoot="true" Caption="@Resources.LogFiles"/>
|
||||||
<div>
|
|
||||||
<table class="table table-striped table-hover">
|
<div class="row">
|
||||||
<thead>
|
<!-- Left Column: File List -->
|
||||||
<tr>
|
<div class="@(this._selectedFile != null ? "col-lg-3 col-md-4" : "col-12") transition-all">
|
||||||
<th>@Resources.FileName</th>
|
<div class="card shadow-sm border-0 mb-4">
|
||||||
<th>@Resources.LastUpdate</th>
|
<div class="card-header bg-light py-2 px-3">
|
||||||
<th>@Resources.Size</th>
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
</tr>
|
<strong class="m-0">@Resources.LogFiles</strong>
|
||||||
</thead>
|
<button type="button" class="btn btn-sm btn-outline-secondary py-0 px-2" @onclick="this.RefreshFileList" title="@Resources.ReloadFileList">
|
||||||
<tbody>
|
<span class="oi oi-reload" style="font-size: 11px;"></span>
|
||||||
@foreach (var entry in this._files.OrderByDescending(f => f.LastWriteTime))
|
</button>
|
||||||
{
|
</div>
|
||||||
<tr>
|
</div>
|
||||||
<td>
|
<div class="card-body p-0" style="max-height: 620px; overflow-y: auto;">
|
||||||
<a href="logs/@entry.Name">@entry.Name</a>
|
<table class="table table-striped table-hover mb-0">
|
||||||
</td>
|
<thead class="table-light">
|
||||||
<td>@entry.LastWriteTime</td>
|
<tr>
|
||||||
<td>@FormatFileSize(entry.Length)</td>
|
<th>@Resources.FileName</th>
|
||||||
</tr>
|
@if (this._selectedFile == null)
|
||||||
}
|
{
|
||||||
</tbody>
|
<th>@Resources.LastUpdate</th>
|
||||||
</table>
|
<th>@Resources.Size</th>
|
||||||
|
}
|
||||||
|
<th class="text-end px-3">@Resources.Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var entry in this._files.OrderByDescending(f => f.LastWriteTime))
|
||||||
|
{
|
||||||
|
var isSelected = this._selectedFile?.FullName == entry.FullName;
|
||||||
|
<tr class="@(isSelected ? "table-info" : "")">
|
||||||
|
<td>
|
||||||
|
<button type="button" class="btn btn-link p-0 text-start font-monospace text-decoration-none fw-bold text-truncate" style="max-width: @(this._selectedFile != null ? "140px" : "100%");" @onclick="() => this.SelectFile(entry)" title="@entry.Name">
|
||||||
|
<span class="oi oi-terminal me-1 @(isSelected ? "text-primary" : "text-muted")"></span>@entry.Name
|
||||||
|
</button>
|
||||||
|
@if (this._selectedFile != null)
|
||||||
|
{
|
||||||
|
<div class="text-muted" style="font-size: 11px;">@FormatFileSize(entry.Length)</div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
@if (this._selectedFile == null)
|
||||||
|
{
|
||||||
|
<td>@entry.LastWriteTime</td>
|
||||||
|
<td>@FormatFileSize(entry.Length)</td>
|
||||||
|
}
|
||||||
|
<td class="text-end px-3">
|
||||||
|
<a href="logs/@entry.Name" download class="btn btn-sm btn-outline-secondary py-0 px-2" title="@Resources.DownloadFile">
|
||||||
|
<span class="oi oi-data-transfer-download" aria-hidden="true"></span>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Column: Log Viewer Terminal -->
|
||||||
|
@if (this._selectedFile != null)
|
||||||
|
{
|
||||||
|
<div class="col-lg-9 col-md-8">
|
||||||
|
<div class="card border-secondary shadow-lg mb-4">
|
||||||
|
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center py-2 px-3">
|
||||||
|
<div class="d-flex align-items-center text-truncate me-2">
|
||||||
|
<span class="oi oi-terminal text-info me-2" aria-hidden="true"></span>
|
||||||
|
<span class="me-2 d-none d-sm-inline">@Resources.LogViewer:</span>
|
||||||
|
<span class="badge bg-secondary font-monospace text-truncate" style="max-width: 250px;">@this._selectedFile.Name</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-2 gap-sm-3 flex-shrink-0">
|
||||||
|
<div class="form-check form-switch m-0 d-flex align-items-center gap-2">
|
||||||
|
<input class="form-check-input cursor-pointer" type="checkbox" id="liveUpdateSwitch" @onchange="this.ToggleLiveUpdate" checked="@this._liveUpdate">
|
||||||
|
<label class="form-check-label text-light select-none cursor-pointer" for="liveUpdateSwitch" style="font-size: 13px;">@Resources.Live</label>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-outline-info d-flex align-items-center gap-1 py-1" @onclick="this.RefreshLogLines">
|
||||||
|
<span class="oi oi-reload" style="font-size: 11px;"></span> @Resources.Refresh
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger d-flex align-items-center gap-1 py-1" @onclick="this.CloseViewer">
|
||||||
|
<span class="oi oi-x" style="font-size: 11px;"></span> @Resources.Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body bg-dark p-3" style="background-color: #121214 !important;">
|
||||||
|
<div class="row g-2 mb-3">
|
||||||
|
<div class="col">
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text bg-secondary text-white border-0"><span class="oi oi-magnifying-glass" aria-hidden="true"></span></span>
|
||||||
|
<input type="text" class="form-control bg-secondary text-white border-0" placeholder="@Resources.FilterLogEntries" value="@this._searchText" @oninput="this.OnSearchInput" style="background-color: #2b2b30 !important; color: #fff !important;" />
|
||||||
|
@if (!string.IsNullOrEmpty(this._searchText))
|
||||||
|
{
|
||||||
|
<button class="btn btn-secondary border-0" @onclick="this.ClearSearch"><span class="oi oi-x" aria-hidden="true"></span></button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="@TerminalElementId" class="p-3 rounded" style="height: 480px; overflow-y: auto; font-family: 'Consolas', 'Liberation Mono', Menlo, Courier, monospace; font-size: 13px; line-height: 1.5; white-space: pre-wrap; background-color: #0c0c0d !important; border: 1px solid #2d2d30;">
|
||||||
|
@if (this._logLines.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="text-muted text-center py-5">@Resources.NoLogEntriesFound</div>
|
||||||
|
}
|
||||||
|
else if (this._filteredLines.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="text-muted text-center py-5">@Resources.NoLogEntriesMatchFilter</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@foreach (var line in this._filteredLines)
|
||||||
|
{
|
||||||
|
<div style="@GetLineColorStyle(line)">@line</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mt-2 text-muted" style="font-size: 12px;">
|
||||||
|
<div>
|
||||||
|
@string.Format(Resources.ShowingXOfYLines, this._filteredLines.Count, this._logLines.Count, MaxLogLinesToRead)
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary py-1 px-2" style="font-size: 12px; color: #a0a0a8;" @onclick="this.ScrollToBottomAsync">
|
||||||
|
<span class="oi oi-arrow-bottom" aria-hidden="true"></span> @Resources.ScrollToBottom
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private readonly List<FileInfo> _files = new ();
|
private const int MaxLogLinesToRead = 300;
|
||||||
|
private const long LogReadBufferSizeBytes = 102400; // 100 KB
|
||||||
|
private const int LiveUpdateIntervalMs = 2000;
|
||||||
|
private const string TerminalElementId = "log-terminal";
|
||||||
|
|
||||||
/// <summary>
|
private readonly List<FileInfo> _files = new();
|
||||||
/// Initializes a new instance of class <see cref="LogFiles"/>.
|
private FileInfo? _selectedFile;
|
||||||
/// </summary>
|
private long _lastFileLength;
|
||||||
public LogFiles()
|
private DateTime _lastFileWriteTime;
|
||||||
|
private List<string> _logLines = new();
|
||||||
|
private List<string> _filteredLines = new();
|
||||||
|
private string _searchText = string.Empty;
|
||||||
|
private bool _liveUpdate;
|
||||||
|
private System.Threading.Timer? _timer;
|
||||||
|
private bool _shouldScrollToBottom;
|
||||||
|
private bool _disposed;
|
||||||
|
private IJSObjectReference? _jsModule;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
var files = Directory.GetFiles(Path.Combine(Directory.GetCurrentDirectory(), "logs"));
|
this._disposed = true;
|
||||||
foreach (var filePath in files)
|
this._timer?.Dispose();
|
||||||
|
if (this._jsModule != null)
|
||||||
{
|
{
|
||||||
this._files.Add(new FileInfo(filePath));
|
try
|
||||||
|
{
|
||||||
|
await this._jsModule.DisposeAsync();
|
||||||
|
}
|
||||||
|
catch (JSDisconnectedException)
|
||||||
|
{
|
||||||
|
// The circuit is already gone, so the module is disposed anyway.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private string FormatFileSize(long size)
|
/// <inheritdoc />
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
this.RefreshFileList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (firstRender)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
this._jsModule = await this.JSRuntime.InvokeAsync<IJSObjectReference>("import", "./Pages/LogFiles.razor.js");
|
||||||
|
}
|
||||||
|
catch (JSException ex)
|
||||||
|
{
|
||||||
|
// 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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._shouldScrollToBottom)
|
||||||
|
{
|
||||||
|
this._shouldScrollToBottom = false;
|
||||||
|
await this.ScrollToBottomAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatFileSize(long size)
|
||||||
{
|
{
|
||||||
return size switch
|
return size switch
|
||||||
{
|
{
|
||||||
(< 1024 << 10) => $"{Math.Round(size / 1024D, 2)} KiB",
|
< 1024 => $"{size} bytes",
|
||||||
(< 1024 << 20) => $"{Math.Round(size * 1D / (1024 << 10), 2)} MiB",
|
< 1024 * 1024 => $"{Math.Round(size / 1024D, 2)} KiB",
|
||||||
(< 1024L << 30) => $"{Math.Round(size * 1D / (1024L << 20), 2)} GiB",
|
< 1024L * 1024 * 1024 => $"{Math.Round(size / (1024D * 1024D), 2)} MiB",
|
||||||
_ => $"{size} bytes"
|
_ => $"{Math.Round(size / (1024D * 1024D * 1024D), 2)} GiB",
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> ReadLastLines(string path, int maxLines)
|
||||||
|
{
|
||||||
|
var lines = new List<string>();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||||
|
long offset = Math.Max(0, fs.Length - LogReadBufferSizeBytes);
|
||||||
|
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();
|
||||||
|
var logsPath = Path.Combine(Directory.GetCurrentDirectory(), "logs");
|
||||||
|
if (Directory.Exists(logsPath))
|
||||||
|
{
|
||||||
|
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._lastFileLength = -1;
|
||||||
|
this.RefreshLogLines();
|
||||||
|
this._shouldScrollToBottom = true;
|
||||||
|
this.SetupTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CloseViewer()
|
||||||
|
{
|
||||||
|
this._selectedFile = null;
|
||||||
|
this._searchText = string.Empty;
|
||||||
|
this._logLines.Clear();
|
||||||
|
this._filteredLines.Clear();
|
||||||
|
this._liveUpdate = false;
|
||||||
|
this.SetupTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
this._liveUpdate = (bool)(e.Value ?? false);
|
||||||
|
this.SetupTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetupTimer()
|
||||||
|
{
|
||||||
|
if (this._liveUpdate && this._selectedFile != null)
|
||||||
|
{
|
||||||
|
this._timer ??= new System.Threading.Timer(_ =>
|
||||||
|
{
|
||||||
|
if (this._disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.InvokeAsync(async () =>
|
||||||
|
{
|
||||||
|
if (this._disposed || this._selectedFile == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var updatedInfo = new FileInfo(this._selectedFile.FullName);
|
||||||
|
if (updatedInfo.Length == this._lastFileLength && updatedInfo.LastWriteTimeUtc == this._lastFileWriteTime)
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
this._timer?.Dispose();
|
||||||
|
this._timer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshLogLines()
|
||||||
|
{
|
||||||
|
if (this._selectedFile == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 void UpdateFilteredLines()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(this._searchText))
|
||||||
|
{
|
||||||
|
this._filteredLines = this._logLines;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
this._filteredLines = this._logLines
|
||||||
|
.Where(line => line.Contains(this._searchText, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ScrollToBottomAsync()
|
||||||
|
{
|
||||||
|
if (this._jsModule is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await this._jsModule.InvokeVoidAsync("scrollToBottom", TerminalElementId);
|
||||||
|
}
|
||||||
|
catch (JSDisconnectedException)
|
||||||
|
{
|
||||||
|
// The circuit is gone; nothing to do.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ValueTask<bool> IsScrolledToBottomAsync()
|
||||||
|
{
|
||||||
|
if (this._jsModule is null)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await this._jsModule.InvokeAsync<bool>("isScrolledToBottom", TerminalElementId);
|
||||||
|
}
|
||||||
|
catch (JSDisconnectedException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
14
src/Web/AdminPanel/Pages/LogFiles.razor.js
Normal file
14
src/Web/AdminPanel/Pages/LogFiles.razor.js
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -6,7 +6,6 @@ namespace MUnique.OpenMU.Web.AdminPanel.Pages;
|
|||||||
|
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using Blazored.Toast.Services;
|
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
using Microsoft.AspNetCore.Components.QuickGrid;
|
using Microsoft.AspNetCore.Components.QuickGrid;
|
||||||
using Microsoft.AspNetCore.Components.Routing;
|
using Microsoft.AspNetCore.Components.Routing;
|
||||||
@@ -16,6 +15,7 @@ using MUnique.OpenMU.DataModel.Configuration;
|
|||||||
using MUnique.OpenMU.DataModel.Entities;
|
using MUnique.OpenMU.DataModel.Entities;
|
||||||
using MUnique.OpenMU.Persistence;
|
using MUnique.OpenMU.Persistence;
|
||||||
using MUnique.OpenMU.Web.AdminPanel.Properties;
|
using MUnique.OpenMU.Web.AdminPanel.Properties;
|
||||||
|
using MUnique.OpenMU.Web.Shared.Components.Toast;
|
||||||
using MUnique.OpenMU.Web.Shared.Services;
|
using MUnique.OpenMU.Web.Shared.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
@using System.ComponentModel
|
@using System.ComponentModel
|
||||||
@using Microsoft.Extensions.DependencyInjection
|
@using Microsoft.Extensions.DependencyInjection
|
||||||
@using Blazored.Toast.Services
|
|
||||||
@using MUnique.OpenMU.Interfaces
|
@using MUnique.OpenMU.Interfaces
|
||||||
@using MUnique.OpenMU.Web.AdminPanel.Properties
|
@using MUnique.OpenMU.Web.AdminPanel.Properties
|
||||||
|
|
||||||
|
|||||||
90
src/Web/AdminPanel/Properties/Resources.Designer.cs
generated
90
src/Web/AdminPanel/Properties/Resources.Designer.cs
generated
@@ -249,6 +249,15 @@ namespace MUnique.OpenMU.Web.AdminPanel.Properties {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up a localized string similar to Close.
|
||||||
|
/// </summary>
|
||||||
|
public static string Close {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("Close", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Looks up a localized string similar to Command.
|
/// Looks up a localized string similar to Command.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -468,6 +477,15 @@ namespace MUnique.OpenMU.Web.AdminPanel.Properties {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up a localized string similar to Download File.
|
||||||
|
/// </summary>
|
||||||
|
public static string DownloadFile {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("DownloadFile", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Looks up a localized string similar to Drop item groups.
|
/// Looks up a localized string similar to Drop item groups.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -558,6 +576,15 @@ namespace MUnique.OpenMU.Web.AdminPanel.Properties {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up a localized string similar to Filter log entries....
|
||||||
|
/// </summary>
|
||||||
|
public static string FilterLogEntries {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("FilterLogEntries", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Looks up a localized string similar to Finished! Have fun :).
|
/// Looks up a localized string similar to Finished! Have fun :).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -774,6 +801,15 @@ namespace MUnique.OpenMU.Web.AdminPanel.Properties {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up a localized string similar to Live.
|
||||||
|
/// </summary>
|
||||||
|
public static string Live {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("Live", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Looks up a localized string similar to Live Map.
|
/// Looks up a localized string similar to Live Map.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -819,6 +855,15 @@ namespace MUnique.OpenMU.Web.AdminPanel.Properties {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up a localized string similar to Log Viewer.
|
||||||
|
/// </summary>
|
||||||
|
public static string LogViewer {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("LogViewer", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Looks up a localized string similar to Major.
|
/// Looks up a localized string similar to Major.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -972,6 +1017,24 @@ namespace MUnique.OpenMU.Web.AdminPanel.Properties {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up a localized string similar to No log entries found..
|
||||||
|
/// </summary>
|
||||||
|
public static string NoLogEntriesFound {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("NoLogEntriesFound", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up a localized string similar to No log entries match your filter..
|
||||||
|
/// </summary>
|
||||||
|
public static string NoLogEntriesMatchFilter {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("NoLogEntriesMatchFilter", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Looks up a localized string similar to This command has no parameters..
|
/// Looks up a localized string similar to This command has no parameters..
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -1197,6 +1260,15 @@ namespace MUnique.OpenMU.Web.AdminPanel.Properties {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up a localized string similar to Reload File List.
|
||||||
|
/// </summary>
|
||||||
|
public static string ReloadFileList {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("ReloadFileList", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Looks up a localized string similar to Remove.
|
/// Looks up a localized string similar to Remove.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -1260,6 +1332,15 @@ namespace MUnique.OpenMU.Web.AdminPanel.Properties {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up a localized string similar to Scroll to Bottom.
|
||||||
|
/// </summary>
|
||||||
|
public static string ScrollToBottom {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("ScrollToBottom", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Looks up a localized string similar to Search.
|
/// Looks up a localized string similar to Search.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -1377,6 +1458,15 @@ namespace MUnique.OpenMU.Web.AdminPanel.Properties {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up a localized string similar to Showing {0} of {1} lines (Last {2} lines loaded)..
|
||||||
|
/// </summary>
|
||||||
|
public static string ShowingXOfYLines {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("ShowingXOfYLines", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Looks up a localized string similar to Size.
|
/// Looks up a localized string similar to Size.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<root>
|
<root>
|
||||||
<!--
|
<!--
|
||||||
Microsoft ResX Schema
|
Microsoft ResX Schema
|
||||||
@@ -612,4 +612,70 @@
|
|||||||
<data name="Target" xml:space="preserve">
|
<data name="Target" xml:space="preserve">
|
||||||
<value>Target</value>
|
<value>Target</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="LogViewer" xml:space="preserve">
|
||||||
|
<value>Log Viewer</value>
|
||||||
|
</data>
|
||||||
|
<data name="Live" xml:space="preserve">
|
||||||
|
<value>Live</value>
|
||||||
|
</data>
|
||||||
|
<data name="Close" xml:space="preserve">
|
||||||
|
<value>Close</value>
|
||||||
|
</data>
|
||||||
|
<data name="FilterLogEntries" xml:space="preserve">
|
||||||
|
<value>Filter log entries...</value>
|
||||||
|
</data>
|
||||||
|
<data name="NoLogEntriesFound" xml:space="preserve">
|
||||||
|
<value>No log entries found.</value>
|
||||||
|
</data>
|
||||||
|
<data name="NoLogEntriesMatchFilter" xml:space="preserve">
|
||||||
|
<value>No log entries match your filter.</value>
|
||||||
|
</data>
|
||||||
|
<data name="ShowingXOfYLines" xml:space="preserve">
|
||||||
|
<value>Showing {0} of {1} lines (Last {2} lines loaded).</value>
|
||||||
|
</data>
|
||||||
|
<data name="ScrollToBottom" xml:space="preserve">
|
||||||
|
<value>Scroll to Bottom</value>
|
||||||
|
</data>
|
||||||
|
<data name="ReloadFileList" xml:space="preserve">
|
||||||
|
<value>Reload File List</value>
|
||||||
|
</data>
|
||||||
|
<data name="DownloadFile" xml:space="preserve">
|
||||||
|
<value>Download File</value>
|
||||||
|
</data>
|
||||||
|
<data name="ChatCommands" xml:space="preserve">
|
||||||
|
<value>Chat commands</value>
|
||||||
|
</data>
|
||||||
|
<data name="CommandColumn" xml:space="preserve">
|
||||||
|
<value>Command</value>
|
||||||
|
</data>
|
||||||
|
<data name="CommandDescription" xml:space="preserve">
|
||||||
|
<value>Description</value>
|
||||||
|
</data>
|
||||||
|
<data name="CommandUsage" xml:space="preserve">
|
||||||
|
<value>Usage</value>
|
||||||
|
</data>
|
||||||
|
<data name="MinimumCharacterStatus" xml:space="preserve">
|
||||||
|
<value>Required status</value>
|
||||||
|
</data>
|
||||||
|
<data name="NoParameters" xml:space="preserve">
|
||||||
|
<value>This command has no parameters.</value>
|
||||||
|
</data>
|
||||||
|
<data name="ParameterName" xml:space="preserve">
|
||||||
|
<value>Name</value>
|
||||||
|
</data>
|
||||||
|
<data name="ParameterShortName" xml:space="preserve">
|
||||||
|
<value>Short name</value>
|
||||||
|
</data>
|
||||||
|
<data name="ParameterType" xml:space="preserve">
|
||||||
|
<value>Type</value>
|
||||||
|
</data>
|
||||||
|
<data name="ParameterValidValues" xml:space="preserve">
|
||||||
|
<value>Valid values</value>
|
||||||
|
</data>
|
||||||
|
<data name="ParametersOf" xml:space="preserve">
|
||||||
|
<value>Parameters of {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="Required" xml:space="preserve">
|
||||||
|
<value>Required</value>
|
||||||
|
</data>
|
||||||
</root>
|
</root>
|
||||||
@@ -5,7 +5,6 @@
|
|||||||
namespace MUnique.OpenMU.Web.AdminPanel;
|
namespace MUnique.OpenMU.Web.AdminPanel;
|
||||||
|
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using Blazored.Toast;
|
|
||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Microsoft.AspNetCore.Hosting;
|
using Microsoft.AspNetCore.Hosting;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
@@ -61,7 +60,7 @@ public class Startup
|
|||||||
.ConfigureApplicationPartManager(setup =>
|
.ConfigureApplicationPartManager(setup =>
|
||||||
setup.FeatureProviders.Add(new GenericControllerFeatureProvider()));
|
setup.FeatureProviders.Add(new GenericControllerFeatureProvider()));
|
||||||
|
|
||||||
services.AddBlazoredToast();
|
services.AddToasts();
|
||||||
services.AddScoped<ModalService>();
|
services.AddScoped<ModalService>();
|
||||||
services.AddScoped<IModalService>(sp => sp.GetRequiredService<ModalService>());
|
services.AddScoped<IModalService>(sp => sp.GetRequiredService<ModalService>());
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
namespace MUnique.OpenMU.Web.AdminPanel;
|
namespace MUnique.OpenMU.Web.AdminPanel;
|
||||||
|
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using Blazored.Toast;
|
|
||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Microsoft.AspNetCore.Hosting.StaticWebAssets;
|
using Microsoft.AspNetCore.Hosting.StaticWebAssets;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
@@ -66,7 +65,7 @@ public static class WebApplicationExtensions
|
|||||||
.ConfigureApplicationPartManager(setup =>
|
.ConfigureApplicationPartManager(setup =>
|
||||||
setup.FeatureProviders.Add(new GenericControllerFeatureProvider()));
|
setup.FeatureProviders.Add(new GenericControllerFeatureProvider()));
|
||||||
|
|
||||||
services.AddBlazoredToast();
|
services.AddToasts();
|
||||||
|
|
||||||
services.AddScoped<ModalService>();
|
services.AddScoped<ModalService>();
|
||||||
services.AddScoped<IModalService>(sp => sp.GetRequiredService<ModalService>());
|
services.AddScoped<IModalService>(sp => sp.GetRequiredService<ModalService>());
|
||||||
|
|||||||
@@ -10,9 +10,6 @@
|
|||||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||||
@using Microsoft.JSInterop
|
@using Microsoft.JSInterop
|
||||||
|
|
||||||
@using Blazored.Toast
|
|
||||||
@using Blazored.Toast.Services
|
|
||||||
|
|
||||||
@using BlazorInputFile
|
@using BlazorInputFile
|
||||||
|
|
||||||
@using MUnique.OpenMU.Web.AdminPanel
|
@using MUnique.OpenMU.Web.AdminPanel
|
||||||
@@ -20,6 +17,7 @@
|
|||||||
|
|
||||||
@using MUnique.OpenMU.Web.Shared
|
@using MUnique.OpenMU.Web.Shared
|
||||||
@using MUnique.OpenMU.Web.Shared.Components
|
@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.Modal
|
||||||
@using MUnique.OpenMU.Web.Shared.Components.Form
|
@using MUnique.OpenMU.Web.Shared.Components.Form
|
||||||
@using MUnique.OpenMU.Web.Shared.Components.Form.Modal
|
@using MUnique.OpenMU.Web.Shared.Components.Form.Modal
|
||||||
|
|||||||
63
src/Web/Shared/Components/Toast/IToastService.cs
Normal file
63
src/Web/Shared/Components/Toast/IToastService.cs
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
// <copyright file="IToastService.cs" company="MUnique">
|
||||||
|
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
|
||||||
|
// </copyright>
|
||||||
|
|
||||||
|
namespace MUnique.OpenMU.Web.Shared.Components.Toast;
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service for showing toast notifications.
|
||||||
|
/// </summary>
|
||||||
|
public interface IToastService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Occurs when the list of toasts has changed (added, closed, cleared).
|
||||||
|
/// </summary>
|
||||||
|
event Action? StateChanged;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the currently shown toasts.
|
||||||
|
/// </summary>
|
||||||
|
IReadOnlyList<ToastInstance> Toasts { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shows a success toast.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="message">The message.</param>
|
||||||
|
/// <param name="heading">The optional heading.</param>
|
||||||
|
void ShowSuccess(string message, string? heading = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shows an info toast.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="message">The message.</param>
|
||||||
|
/// <param name="heading">The optional heading.</param>
|
||||||
|
void ShowInfo(string message, string? heading = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shows a warning toast.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="message">The message.</param>
|
||||||
|
/// <param name="heading">The optional heading.</param>
|
||||||
|
void ShowWarning(string message, string? heading = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shows an error toast.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="message">The message.</param>
|
||||||
|
/// <param name="heading">The optional heading.</param>
|
||||||
|
void ShowError(string message, string? heading = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Closes the specified toast (triggers its closing animation).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="toast">The toast to close.</param>
|
||||||
|
void Close(ToastInstance toast);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Closes all currently shown toasts.
|
||||||
|
/// </summary>
|
||||||
|
void Clear();
|
||||||
|
}
|
||||||
66
src/Web/Shared/Components/Toast/ToastContainer.razor
Normal file
66
src/Web/Shared/Components/Toast/ToastContainer.razor
Normal file
@@ -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)
|
||||||
|
{
|
||||||
|
<div class="toast-container position-fixed top-0 end-0 p-3" aria-live="polite" aria-atomic="true">
|
||||||
|
@foreach (var toast in this.ToastService.Toasts)
|
||||||
|
{
|
||||||
|
var (iconClass, accentClass) = this.GetStyling(toast.Level);
|
||||||
|
<div @key="toast.Key" class="toast @accentClass @(toast.IsClosing ? "closing" : "show")" role="alert" aria-live="assertive" aria-atomic="true">
|
||||||
|
<div class="d-flex">
|
||||||
|
<div class="toast-body d-flex align-items-start">
|
||||||
|
<span class="oi @iconClass toast__icon" aria-hidden="true"></span>
|
||||||
|
<div class="ms-2 w-100">
|
||||||
|
@if (toast.Heading is { } heading)
|
||||||
|
{
|
||||||
|
<strong class="d-block">@heading</strong>
|
||||||
|
}
|
||||||
|
<div>@toast.Message</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-close me-2 m-auto" data-bs-dismiss="toast" aria-label="Close" @onclick="() => this.CloseAsync(toast)"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
this.ToastService.StateChanged += this.OnStateChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/Web/Shared/Components/Toast/ToastContainer.razor.css
Normal file
34
src/Web/Shared/Components/Toast/ToastContainer.razor.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
52
src/Web/Shared/Components/Toast/ToastInstance.cs
Normal file
52
src/Web/Shared/Components/Toast/ToastInstance.cs
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
// <copyright file="ToastInstance.cs" company="MUnique">
|
||||||
|
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
|
||||||
|
// </copyright>
|
||||||
|
|
||||||
|
namespace MUnique.OpenMU.Web.Shared.Components.Toast;
|
||||||
|
|
||||||
|
using System;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a single toast message shown in the <see cref="ToastContainer"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ToastInstance
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="ToastInstance"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="level">The level.</param>
|
||||||
|
/// <param name="message">The message.</param>
|
||||||
|
/// <param name="heading">The optional heading.</param>
|
||||||
|
internal ToastInstance(ToastLevel level, string message, string? heading)
|
||||||
|
{
|
||||||
|
this.Key = Guid.NewGuid();
|
||||||
|
this.Level = level;
|
||||||
|
this.Message = message;
|
||||||
|
this.Heading = heading;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a stable key identifying this toast, used as a render key.
|
||||||
|
/// </summary>
|
||||||
|
public Guid Key { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the level.
|
||||||
|
/// </summary>
|
||||||
|
public ToastLevel Level { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the message.
|
||||||
|
/// </summary>
|
||||||
|
public string Message { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the optional heading.
|
||||||
|
/// </summary>
|
||||||
|
public string? Heading { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether the toast is performing its closing animation.
|
||||||
|
/// </summary>
|
||||||
|
internal bool IsClosing { get; set; }
|
||||||
|
}
|
||||||
31
src/Web/Shared/Components/Toast/ToastLevel.cs
Normal file
31
src/Web/Shared/Components/Toast/ToastLevel.cs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// <copyright file="ToastLevel.cs" company="MUnique">
|
||||||
|
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
|
||||||
|
// </copyright>
|
||||||
|
|
||||||
|
namespace MUnique.OpenMU.Web.Shared.Components.Toast;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The level of a toast message.
|
||||||
|
/// </summary>
|
||||||
|
public enum ToastLevel
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Informational message.
|
||||||
|
/// </summary>
|
||||||
|
Info,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Success message.
|
||||||
|
/// </summary>
|
||||||
|
Success,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Warning message.
|
||||||
|
/// </summary>
|
||||||
|
Warning,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Error message.
|
||||||
|
/// </summary>
|
||||||
|
Error,
|
||||||
|
}
|
||||||
@@ -52,7 +52,6 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="BCrypt.Net-Next" />
|
<PackageReference Include="BCrypt.Net-Next" />
|
||||||
<PackageReference Include="Blazored.Toast" />
|
|
||||||
<PackageReference Include="BlazorInputFile" />
|
<PackageReference Include="BlazorInputFile" />
|
||||||
<PackageReference Include="Mapster" />
|
<PackageReference Include="Mapster" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Components.QuickGrid" />
|
<PackageReference Include="Microsoft.AspNetCore.Components.QuickGrid" />
|
||||||
|
|||||||
172
src/Web/Shared/Services/ToastService.cs
Normal file
172
src/Web/Shared/Services/ToastService.cs
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
// <copyright file="ToastService.cs" company="MUnique">
|
||||||
|
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
|
||||||
|
// </copyright>
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Default implementation of <see cref="IToastService"/>.
|
||||||
|
/// </summary>
|
||||||
|
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<ToastInstance> _toasts = new();
|
||||||
|
private readonly List<CancellationTokenSource> _cancellations = new();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public event Action? StateChanged;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IReadOnlyList<ToastInstance> Toasts
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (this._lock)
|
||||||
|
{
|
||||||
|
return this._toasts.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void ShowSuccess(string message, string? heading = null)
|
||||||
|
{
|
||||||
|
this.Show(ToastLevel.Success, message, heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void ShowInfo(string message, string? heading = null)
|
||||||
|
{
|
||||||
|
this.Show(ToastLevel.Info, message, heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void ShowWarning(string message, string? heading = null)
|
||||||
|
{
|
||||||
|
this.Show(ToastLevel.Warning, message, heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void ShowError(string message, string? heading = null)
|
||||||
|
{
|
||||||
|
this.Show(ToastLevel.Error, message, heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Close(ToastInstance toast)
|
||||||
|
{
|
||||||
|
this.StartClosing(toast);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Clear()
|
||||||
|
{
|
||||||
|
lock (this._lock)
|
||||||
|
{
|
||||||
|
foreach (var cts in this._cancellations)
|
||||||
|
{
|
||||||
|
cts.Cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
this._cancellations.Clear();
|
||||||
|
this._toasts.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.StateChanged?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/Web/Shared/Services/ToastServiceCollectionExtensions.cs
Normal file
26
src/Web/Shared/Services/ToastServiceCollectionExtensions.cs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
// <copyright file="ToastServiceCollectionExtensions.cs" company="MUnique">
|
||||||
|
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
|
||||||
|
// </copyright>
|
||||||
|
|
||||||
|
namespace MUnique.OpenMU.Web.Shared.Services;
|
||||||
|
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using MUnique.OpenMU.Web.Shared.Components.Toast;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extension methods for registering the toast service.
|
||||||
|
/// </summary>
|
||||||
|
public static class ToastServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Adds the toast service to the service collection.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="services">The service collection.</param>
|
||||||
|
/// <returns>The service collection, for chaining.</returns>
|
||||||
|
public static IServiceCollection AddToasts(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddScoped<ToastService>();
|
||||||
|
services.AddScoped<IToastService>(sp => sp.GetRequiredService<ToastService>());
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,9 +6,6 @@
|
|||||||
@using Microsoft.Extensions.Localization
|
@using Microsoft.Extensions.Localization
|
||||||
@using Microsoft.JSInterop
|
@using Microsoft.JSInterop
|
||||||
|
|
||||||
@using Blazored.Toast
|
|
||||||
@using Blazored.Toast.Services
|
|
||||||
|
|
||||||
@using BlazorInputFile
|
@using BlazorInputFile
|
||||||
|
|
||||||
@using MUnique.OpenMU.Web.Shared
|
@using MUnique.OpenMU.Web.Shared
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// <copyright file="ConfigurationChangePublishingTests.cs" company="MUnique">
|
||||||
|
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
|
||||||
|
// </copyright>
|
||||||
|
|
||||||
|
namespace MUnique.OpenMU.Persistence.Initialization.Tests;
|
||||||
|
|
||||||
|
using MUnique.OpenMU.Persistence.EntityFramework;
|
||||||
|
using MUnique.OpenMU.Persistence.EntityFramework.Model;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests filtering of Entity Framework configuration change notifications.
|
||||||
|
/// </summary>
|
||||||
|
[TestFixture]
|
||||||
|
internal class ConfigurationChangePublishingTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that only configuration entities are published to the configuration change listener.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="entityType">The entity type.</param>
|
||||||
|
/// <param name="shouldPublish">Whether changes of this entity type should be published.</param>
|
||||||
|
[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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,10 @@ internal class JsonQueryBuilderTests
|
|||||||
[OneTimeSetUp]
|
[OneTimeSetUp]
|
||||||
public void Setup()
|
public void Setup()
|
||||||
{
|
{
|
||||||
ConnectionConfigurator.Initialize(new ConfigFileDatabaseConnectionStringProvider());
|
if (!ConnectionConfigurator.IsInitialized)
|
||||||
|
{
|
||||||
|
ConnectionConfigurator.Initialize(new ConfigFileDatabaseConnectionStringProvider());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -44,22 +44,25 @@ public class BotPartyHandlerTest
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// An inviter whose effective level is too far from the bot's is declined - the group would only
|
/// There is no level gate (matching OpenMU's own party action): a bot accepts an inviter of any
|
||||||
/// be a power-leveling service.
|
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Test]
|
[Test]
|
||||||
public async ValueTask RejectsTooLargeLevelGapAsync()
|
public async ValueTask AcceptsInviteRegardlessOfLevelGapAsync()
|
||||||
{
|
{
|
||||||
var gameContext = GameContextTestHelper.CreateGameContext();
|
var gameContext = GameContextTestHelper.CreateGameContext();
|
||||||
var bot = await CreateBotAsync(gameContext, "Bot").ConfigureAwait(false);
|
var bot = await CreateBotAsync(gameContext, "Bot").ConfigureAwait(false);
|
||||||
var requester = await CreateHumanAsync(gameContext, "Human").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);
|
var scheduled = await BotPartyHandler.TryScheduleAcceptAsync(bot, requester, TimeSpan.Zero).ConfigureAwait(false);
|
||||||
|
|
||||||
Assert.That(scheduled, Is.False);
|
Assert.That(scheduled, Is.True);
|
||||||
Assert.That(bot.PendingPartyInvite, Is.Null);
|
Assert.That(bot.PendingPartyInvite, Is.Not.Null);
|
||||||
Assert.That(bot.LastPartyRequester, Is.Null);
|
Assert.That(bot.LastPartyRequester, Is.SameAs(requester));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
114
tests/MUnique.OpenMU.Tests/PersistenceLockTest.cs
Normal file
114
tests/MUnique.OpenMU.Tests/PersistenceLockTest.cs
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
// <copyright file="PersistenceLockTest.cs" company="MUnique">
|
||||||
|
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
|
||||||
|
// </copyright>
|
||||||
|
|
||||||
|
namespace MUnique.OpenMU.Tests;
|
||||||
|
|
||||||
|
using System.Threading;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for the per-player persistence lock (<see cref="MUnique.OpenMU.GameLogic.Player.RunPersistenceExclusiveAsync(System.Func{System.Threading.Tasks.ValueTask},System.Threading.CancellationToken)"/>),
|
||||||
|
/// 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 <c>SaveChangesAsync</c> corrupts the
|
||||||
|
/// change tracker and rolls the whole session back.
|
||||||
|
/// </summary>
|
||||||
|
[TestFixture]
|
||||||
|
public class PersistenceLockTest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that concurrent exclusive operations for the same player never overlap.
|
||||||
|
/// </summary>
|
||||||
|
[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.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
[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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user